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
2026-10-06 15:46:49 +02:00
parent 9e032bdc3e
commit bcdccd14c4
13 changed files with 155 additions and 33 deletions
+2 -2
View File
@@ -276,7 +276,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`; Ctrl+W deliberately still closes `activeSessionId`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The planned tile grid reuses `TerminalTile` (`docs/tile-grid-plan.md`). → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) **Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The planned tile grid reuses `TerminalTile` (`docs/tile-grid-plan.md`). → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions)
**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) **Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection)
@@ -389,7 +389,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min). **Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. **Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
### Security ### Security
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -8,7 +8,8 @@
> (`terminal-tile.js`) and is no longer as plain as this spec describes: it > (`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 > 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 > 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`. > See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
## Problem ## 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 - Every tile is equal: same terminal class, same features, same header. One
tile is **focused** and receives the keyboard. tile is **focused** and receives the keyboard.
- The rest of the app follows the focused tile: files panel, git status, - 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 - Tiles survive a Codeman restart (reconnect) and a page reload (per-device
persistence). persistence).
- The split pane stays as it is for now (decided). The grid is a separate - 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 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`) brings it back (decision 1). An app-driven selection (`auto: true`)
never collapses the grid; see "Selections while the grid is open". never collapses the grid; see "Selections while the grid is open".
- Ctrl+L clears the focused tile; Ctrl+W closes the focused session (killing - Ctrl+L clears the focused tile; Ctrl+W is delete-word in the focused tile
it without a confirm, exactly as in the single view, where the focused tile (it is not an app shortcut, decision 5); Ctrl +/-
is the active session); Ctrl +/-
changes the tile font size; Ctrl+Shift+R restores the focused tile's size. changes the tile font size; Ctrl+Shift+R restores the focused tile's size.
### Persistence ### Persistence
@@ -461,7 +460,7 @@ the grid. Each one gets an explicit grid-aware behavior:
| Path | Today | With the grid open | | 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. | | 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") | | 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) | | 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 | | 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 })` | | 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 | | 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 | | Font, family, weight, skin | `setFontSize` / `setFontFamily` / `setFontWeight` / `applyTerminalSkin` special-case `this._splitPane` | Loop over all tiles |
### 7. Fonts ### 7. Fonts
@@ -551,7 +550,7 @@ and share one tile class:
| Situation | Behavior | | 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 | | 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 | | 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 | | 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 | | 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 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 B has focus clears or closes Pane A"). Typing into Pane B now clears its
idle alert through `_ackDelivery`, which it never did. idle alert through `_ackDelivery`, which it never did.
**Ctrl+W is deliberately NOT retargeted in PR 1** (open decision 5): **Ctrl+W no longer closes anything** (decision 5): Close Session has no
`killActiveSession` calls `closeSession(id)` with `killMux = true` and no default key, so Ctrl+W reaches the focused pane as delete-word.
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.
4. **Docs.** Update the split-pane paragraph in CLAUDE.md and 4. **Docs.** Update the split-pane paragraph in CLAUDE.md and
`docs/architecture-invariants.md#split-pane-sessions` (Pane B now `docs/architecture-invariants.md#split-pane-sessions` (Pane B now
reconnects, delivers input exactly once, has links and image paste, and 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. redelivery per socket, POST fallback when no socket is registered.
- `test/focused-pane-shortcuts.test.ts`: with Pane B focused, Ctrl+L clears - `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, 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. with Pane A focused nothing changes.
- Geometry: with the split divider at its 20% clamp, Pane B's xterm and the - 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). 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 `_cleanupPreviousSession`; acknowledgement only when user-initiated; an
`auto: true` selection of a non-tiled session leaves the grid open; a `auto: true` selection of a non-tiled session leaves the grid open; a
user-initiated one closes it; `leaveTiles: true` closes it. 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 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 the first `sessionOrder` entry is not tiled; closing the last tile falls back
to the normal pick. 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. 3. **Persistence.** Decided: per device, restored on reload.
4. **PR shape.** Decided: two PRs. PR 1 is the tile foundation (the split 4. **PR shape.** Decided: two PRs. PR 1 is the tile foundation (the split
improves on its own), PR 2 is the grid. improves on its own), PR 2 is the grid.
5. **Ctrl+W in the split while Pane B has focus.** Open, default applied: 5. **Ctrl+W.** Decided: it never closes a session. Close Session has no
keep it closing Pane A's session (today's behavior), because Ctrl+W kills default key (Ctrl+W is delete-word in every shell and agent CLI, and it
without a confirm. Alternative: retarget it to the focused pane like the killed sessions with no confirm); it stays bindable in App Settings →
other shortcuts. In the grid it always follows focus, since the focused tile Shortcuts.
IS the active session there.
## Code anchors ## 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 | | Shortcut | Action |
| ------------------------------- | --------------------------------------------------------------- | | ------------------------------- | --------------------------------------------------------------- |
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. | | `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. | | `Ctrl+Tab` | Next session. |
| `Alt+[` / `Alt+]` | Previous / next tab. | | `Alt+[` / `Alt+]` | Previous / next tab. |
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. | | `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. | | `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. | | `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 ## Terminal
| Shortcut | Action | | Shortcut | Action |
+1 -1
View File
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
| To do this | Do that | | To do this | Do that |
| ------------------------- | ------------------------------------------------------------------- | | ------------------------- | ------------------------------------------------------------------- |
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. | | 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 the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
| Stop everything | `tmux -L codeman kill-server`. | | 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) | | Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` | | Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` | | 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`) | | Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
Tabs can also be dragged to reorder. Tabs can also be dragged to reorder.
+11 -1
View File
@@ -419,7 +419,14 @@ const DEFAULT_SHORTCUTS = [
id: 'close-session', id: 'close-session',
group: 'Session', group: 'Session',
label: 'Close Session', label: 'Close Session',
bindings: [{ modifiers: ['ctrl'], key: 'w' }], // ⚠️ No default key. This used to be Ctrl+W, which is "delete the previous
// word" in every shell, readline prompt and agent CLI, so muscle memory
// killed the session (tmux and the CLI, with no confirm) mid-sentence, and
// with the split open it was not even the pane being typed in. Ctrl+W now
// reaches the terminal like any other key. Closing stays on the tab's close
// control and menu (with their confirm), and anyone who wants a key binds
// one in App Settings → Shortcuts.
bindings: [],
action: 'killActiveSession', action: 'killActiveSession',
}, },
{ {
@@ -9134,6 +9141,9 @@ class CodemanApp {
const fmtBindings = (s) => { const fmtBindings = (s) => {
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / '); if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
if (!s.bindings) return ''; if (!s.bindings) return '';
// An action with no key (Close Session by default) is still listed, so the
// overlay says so instead of showing an empty key column.
if (s.bindings.length === 0) return '<span class="shortcut-overlay-unbound">not bound</span>';
return s.bindings.map((b) => { return s.bindings.map((b) => {
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || '']; const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`; return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
-1
View File
@@ -807,7 +807,6 @@
<section class="shortcut-section"> <section class="shortcut-section">
<h4>Session</h4> <h4>Session</h4>
<div class="shortcuts-grid"> <div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div> <div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div> <div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div> <div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
+6
View File
@@ -8520,6 +8520,12 @@ kbd {
color: var(--text-dim); color: var(--text-dim);
} }
/* An action with no key bound (Close Session by default): say so, quietly. */
.shortcut-overlay-unbound {
font-size: 0.75rem;
font-style: italic;
}
.shortcut-overlay-footer { .shortcut-overlay-footer {
margin-top: 0.75rem; margin-top: 0.75rem;
padding-top: 0.75rem; padding-top: 0.75rem;
+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview Ctrl+W never closes a session; it reaches the terminal.
*
* "Close Session" used to be bound to Ctrl+W by default. Ctrl+W is "delete the
* previous word" in every shell, readline prompt and agent CLI, so muscle memory
* killed the session (its tmux pane and CLI, with no confirm) in the middle of a
* sentence; with the split pane open it was not even the pane being typed in.
* The action now has NO default key: the capture-phase shortcut handler lets
* Ctrl+W through, and xterm sends ^W to the CLI like any other key. The action
* stays in the registry so a user can still bind a key to it in App Settings →
* Shortcuts.
*
* Real code under test: constants.js + app.js (DEFAULT_SHORTCUTS,
* getShortcutRegistry, matchesShortcutEvent) in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
type Shortcut = { id: string; action?: string; bindings?: unknown[]; disabled?: boolean };
type App = {
getShortcutRegistry(): Shortcut[];
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
loadAppSettingsFromStorage: () => Record<string, unknown>;
};
function makeApp(settings: Record<string, unknown> = {}): App {
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn() },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const app = Object.create(CodemanApp.prototype) as App;
app.loadAppSettingsFromStorage = () => settings;
return app;
}
const keydown = (overrides: Record<string, unknown>) => ({
type: 'keydown',
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
...overrides,
});
describe('Ctrl+W is left to the terminal', () => {
it('Close Session is still in the registry, with no default key', () => {
const close = makeApp()
.getShortcutRegistry()
.find((s) => s.id === 'close-session');
expect(close?.action).toBe('killActiveSession');
expect(close?.bindings).toEqual([]);
});
it.each([
['Ctrl+W', { ctrlKey: true }],
['Cmd+W', { metaKey: true }],
])('no default shortcut answers %s, so the capture handler lets it reach xterm', (_name, mods) => {
const app = makeApp();
const event = keydown(mods);
const matched = app.getShortcutRegistry().filter((s) => !s.disabled && s.action && app.matchesShortcutEvent(event, s));
expect(matched.map((s) => s.id)).toEqual([]);
});
it('a user can still bind a key to Close Session', () => {
const app = makeApp({
shortcutOverrides: { 'close-session': { bindings: [{ modifiers: ['ctrl', 'shift'], key: 'w' }] } },
});
const close = app.getShortcutRegistry().find((s) => s.id === 'close-session')!;
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true, shiftKey: true, key: 'W' }), close)).toBe(true);
expect(app.matchesShortcutEvent(keydown({ ctrlKey: true }), close)).toBe(false);
});
it('the shortcut overlay says an unbound action is not bound', () => {
const appSource = read('app.js');
const overlay = appSource.slice(appSource.indexOf('renderShortcutOverlay() {'), appSource.indexOf('closeShortcutOverlay() {'));
expect(overlay).toContain("if (s.bindings.length === 0) return '<span class=\"shortcut-overlay-unbound\">not bound</span>';");
});
});
+4 -4
View File
@@ -8,9 +8,9 @@
* now answers with the pane whose terminal was focused last, and the actions * now answers with the pane whose terminal was focused last, and the actions
* that are about a TERMINAL (clear, restore size) go through it. * that are about a TERMINAL (clear, restore size) go through it.
* *
* Ctrl+W deliberately does NOT follow focus yet: it kills a session outright, * Close Session is not one of them: it has no default key any more (Ctrl+W is
* with no confirm, so moving it changes which agent a muscle-memory press kills * left to the terminal as delete-word, see ctrl-w-never-closes.test.ts), and a
* (docs/tile-grid-plan.md, decision 5). It stays on the active session. * key a user binds to it closes the active session, as it always did.
* *
* Real code under test: constants.js + terminal-ui.js in a `vm` context. * Real code under test: constants.js + terminal-ui.js in a `vm` context.
*/ */
@@ -136,7 +136,7 @@ describe('terminal shortcuts follow the focused pane', () => {
}); });
}); });
describe('Ctrl+W stays on the active session (decision 5)', () => { describe('Close Session (user-bound key only) stays on the active session', () => {
it('killActiveSession closes activeSessionId and never consults the focused pane', () => { it('killActiveSession closes activeSessionId and never consults the focused pane', () => {
const appSource = read('app.js'); const appSource = read('app.js');
const body = appSource.slice( const body = appSource.slice(
+3 -1
View File
@@ -38,7 +38,9 @@ describe('help modal shortcuts', () => {
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal')); const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
it('documents implemented global and tab shortcuts', () => { it('documents implemented global and tab shortcuts', () => {
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session'); // Ctrl+W is NOT an app shortcut: it is delete-word in the terminal, and as
// Close Session it killed sessions with no confirm (ctrl-w-never-closes.test.ts).
expect(helpModal).not.toMatch(/<kbd>Ctrl<\/kbd>\s*\+\s*<kbd>W<\/kbd>/i);
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session'); expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session'); expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session'); expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');