mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
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:
@@ -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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
**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
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+11
-1
@@ -419,7 +419,14 @@ const DEFAULT_SHORTCUTS = [
|
||||
id: 'close-session',
|
||||
group: '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',
|
||||
},
|
||||
{
|
||||
@@ -9134,6 +9141,9 @@ class CodemanApp {
|
||||
const fmtBindings = (s) => {
|
||||
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
|
||||
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) => {
|
||||
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
||||
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
|
||||
|
||||
@@ -807,7 +807,6 @@
|
||||
<section class="shortcut-section">
|
||||
<h4>Session</h4>
|
||||
<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</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>
|
||||
|
||||
@@ -8520,6 +8520,12 @@ kbd {
|
||||
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 {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
|
||||
@@ -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>';");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,9 @@
|
||||
* now answers with the pane whose terminal was focused last, and the actions
|
||||
* that are about a TERMINAL (clear, restore size) go through it.
|
||||
*
|
||||
* Ctrl+W deliberately does NOT follow focus yet: it kills a session outright,
|
||||
* with no confirm, so moving it changes which agent a muscle-memory press kills
|
||||
* (docs/tile-grid-plan.md, decision 5). It stays on the active session.
|
||||
* Close Session is not one of them: it has no default key any more (Ctrl+W is
|
||||
* left to the terminal as delete-word, see ctrl-w-never-closes.test.ts), and a
|
||||
* 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.
|
||||
*/
|
||||
@@ -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', () => {
|
||||
const appSource = read('app.js');
|
||||
const body = appSource.slice(
|
||||
|
||||
@@ -38,7 +38,9 @@ describe('help modal shortcuts', () => {
|
||||
const helpModal = normalizedHtml(extractElementById(INDEX_HTML, 'helpModal'));
|
||||
|
||||
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, ['Alt/Option', '['], 'Previous / Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
||||
|
||||
Reference in New Issue
Block a user