mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdccd14c4 | ||
|
|
9e032bdc3e | ||
|
|
497711a05e | ||
|
|
fe9b209f67 | ||
|
|
fbd69e62aa | ||
|
|
a54ad81684 | ||
|
|
0ec17633ba | ||
|
|
5e3dbf2057 | ||
|
|
d1bbb4cc26 | ||
|
|
e2f56dc077 | ||
|
|
ead3d34411 | ||
|
|
f1537a7887 |
@@ -274,9 +274,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
**Circuit breakers**: the Ralph breaker prevents respawn thrashing (`CLOSED` → `HALF_OPEN` → `OPEN`; reset via `/api/sessions/:id/ralph-circuit-breaker/reset`). **Distinct: the PTY-exit breaker** (`session-pty-exit-breaker.ts`) trips after repeated rapid PTY exits and blocks auto-restarts. ⚠️ It resets ONLY via an explicit `{clearBreaker:true}` body on `POST /api/sessions/:id/interactive`; the frontend's auto-reattach in `selectSession()` sends no body and must never clear it. → [architecture-invariants#circuit-breakers-ralph--pty-exit](docs/architecture-invariants.md#circuit-breakers-ralph-and-pty-exit)
|
||||||
|
|
||||||
**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 (`SplitTerminalPane._pullHistory`, terminal-split.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 its own `SplitTerminalPane` (terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Deliberately plainer than the primary pane — no local-echo overlay, CJK IME, or touch handlers — and NOT persisted across reloads. → [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)
|
||||||
|
|
||||||
@@ -325,7 +325,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The trade is that a keydown decides with less evidence than the timer did, since xterm's own keyCode-229 rescue has not run yet; that is safe for Enter, which clears the textarea so the pending diff emits nothing. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||||
|
|
||||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<html>`; the default `legacy` theme short-circuits every hook. ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||||
|
|
||||||
@@ -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
@@ -4,6 +4,14 @@
|
|||||||
**Author**: Claude (session with Tim), 2026-09-15
|
**Author**: Claude (session with Tim), 2026-09-15
|
||||||
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
**Scope**: v1 only. v2 items are named and explicitly deferred, not designed.
|
||||||
|
|
||||||
|
> **Update (tile grid, PR 1):** Pane B is now a `TerminalTile`
|
||||||
|
> (`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 no longer
|
||||||
|
> closes anything (Close Session has no default key).
|
||||||
|
> See `docs/tile-grid-plan.md` and `architecture-invariants#split-pane-sessions`.
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
Codeman's terminal area shows exactly one active session (pane) at a time —
|
Codeman's terminal area shows exactly one active session (pane) at a time —
|
||||||
|
|||||||
@@ -0,0 +1,860 @@
|
|||||||
|
# Tile Grid: Design Spec
|
||||||
|
|
||||||
|
**Status**: PR 1 (tile foundation) implemented on `feat/terminal-tile`, local only; PR 2 (the grid) proposed. Builds on `docs/split-pane-sessions-plan.md`; the split pane stays.
|
||||||
|
**Author**: Claude (planning session with the maintainer), 2026-10-06
|
||||||
|
**Branches**: PR 1 `feat/terminal-tile`, PR 2 `feat/tile-grid` stacked on it (worktree `claudeman-tiles`)
|
||||||
|
**Scope**: v1 is fully designed here; follow-ups are named at the end and explicitly deferred.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Codeman's terminal area shows exactly one session at a time. The split pane
|
||||||
|
(`showSplitButton`, `src/web/public/terminal-split.js`) added a second live
|
||||||
|
session beside the active one, but stops at two, gives the second pane
|
||||||
|
("Pane B") a deliberately reduced feature set, and has no reconnect: every
|
||||||
|
Codeman restart (every release deploy) leaves Pane B dead until the split is
|
||||||
|
closed and reopened.
|
||||||
|
|
||||||
|
The goal is a dashboard of agents: four, six or nine live Claude sessions on
|
||||||
|
one monitor, each readable and typeable, with its state visible at a glance.
|
||||||
|
The target picture is a 3x2 grid of tiles, each tile a full terminal with a
|
||||||
|
small header: status dot, session name, a `⋯` menu, maximize, `+` and `×`.
|
||||||
|
|
||||||
|
## Goal (v1)
|
||||||
|
|
||||||
|
- A **tile grid** of 1 to 9 live sessions in one Codeman window, laid out
|
||||||
|
automatically by count, with draggable column and row dividers.
|
||||||
|
- 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.
|
||||||
|
- 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
|
||||||
|
mode that shares the same tile class with it; the two are never open at the
|
||||||
|
same time (see "Coexistence with the split pane").
|
||||||
|
|
||||||
|
## Non-goals (v1)
|
||||||
|
|
||||||
|
- Phones and tablets. The grid is desktop-only, gated at 1180 px like the
|
||||||
|
split (`SPLIT_PANE_MIN_WIDTH`) and the home rail (`HOME_SESSIONS_MIN_WIDTH`).
|
||||||
|
- More than 9 tiles.
|
||||||
|
- WebGL rendering inside tiles (see "Rendering" below).
|
||||||
|
- Full parity with the main terminal's touch and IME features: local-echo
|
||||||
|
overlay, the CJK input textarea, the keyboard accessory bar, touch gestures,
|
||||||
|
mouse-wheel forwarding to Claude's fullscreen renderer, the "Load full
|
||||||
|
history" banner. These exist for touch devices or rare cases; a desktop
|
||||||
|
keyboard user types straight into xterm, which is how Codeman behaved before
|
||||||
|
those features existed.
|
||||||
|
- Server-side persistence of grids (named presets per owner).
|
||||||
|
- Pop-out windows (`/session/:id`, solo mode) showing a grid.
|
||||||
|
|
||||||
|
## Current architecture (why this is not a CSS change)
|
||||||
|
|
||||||
|
`terminal-ui.js` is built around ONE terminal: `this.terminal` (one xterm),
|
||||||
|
`this._ws` / `this._wsSessionId` (one WebSocket, rebound on every tab switch
|
||||||
|
via `_disconnectWs()` + `_connectWs(id)`), `this._xtermSnapshots` (scrollback
|
||||||
|
snapshots restored into that one terminal), and about 280 references to that
|
||||||
|
singleton across input handling, sizing, link providers, local echo, IME,
|
||||||
|
touch handling and the keyboard accessory bar.
|
||||||
|
|
||||||
|
The split pane works around it with a second, independent object,
|
||||||
|
`SplitTerminalPane`: its own xterm, its own fit addon, its own
|
||||||
|
`/ws/sessions/:id/terminal` socket. That class is the seed of this feature.
|
||||||
|
It already handles, and this design keeps:
|
||||||
|
|
||||||
|
- single-flight buffer loads (`_loadBuffer` / `_refreshBuffer` /
|
||||||
|
`_endBufferLoad`), so two replays never interleave;
|
||||||
|
- the "disconnected" marker that must be the LAST thing on screen
|
||||||
|
(`_markerOwed`, `_stampMarkerIfOwed`);
|
||||||
|
- the bounded scroll-to-top history pull for shell sessions (`_pullHistory`);
|
||||||
|
- its own key handler gating app chords (palette, Alt nav, Ctrl+Z, Shift/Ctrl+Enter
|
||||||
|
via `send-key`, smart copy);
|
||||||
|
- divider drag with pointer capture, rAF-coalesced local fit, one PTY resize at
|
||||||
|
pointer-up, and teardown when the split collapses mid-drag.
|
||||||
|
|
||||||
|
## Key decision: equal tiles, main terminal parked
|
||||||
|
|
||||||
|
Three ways to put N sessions on screen were evaluated:
|
||||||
|
|
||||||
|
| Approach | How | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| A. Anchor plus light tiles | The main terminal stays as tile 1; other tiles are Pane-B style | Rejected. Tile 1 is privileged. The panels follow tile 1, not the tile you are typing in. Making another tile the "main" one costs two buffer reloads per click. |
|
||||||
|
| B. Equal tiles, main parked | Every tile is a `TerminalTile`. The main terminal is hidden and disconnected while the grid is open. `activeSessionId` always equals the focused tile's session. | **Chosen** |
|
||||||
|
| C. iframes of solo windows | Each tile is `/session/:id` in an iframe | Rejected. All frames share `codeman:clientId` (localStorage), and `SseStreamManager.addClient` evicts the previous stream with the same clientId, so frames knock each other's SSE offline about every 45 s (the staleness watchdog reconnects, evicting the next one). They also share `codeman:pendingInput`, load N full copies of the app, and play N notification sounds. |
|
||||||
|
|
||||||
|
Why B:
|
||||||
|
|
||||||
|
- **Focus changes are instant.** Moving focus is `xterm.focus()` plus an
|
||||||
|
`activeSessionId` update. No fetch, no replay, no flicker.
|
||||||
|
- **Panels follow focus for free.** Everything keyed on `activeSessionId`
|
||||||
|
(files panel, git status poll, respawn and Ralph panels, subagent window
|
||||||
|
visibility, voice, image upload, kill/close, tab highlight) follows the
|
||||||
|
focused tile once the tile branch of `selectSession` runs the same panel
|
||||||
|
refresh as a normal switch.
|
||||||
|
- **Hiding the main terminal is already proven safe.** Web tabs set
|
||||||
|
`display:none` on `.terminal-wrap` today (`.main.webview-active`). With the
|
||||||
|
container hidden, FitAddon's `proposeDimensions()` reads `auto` widths and
|
||||||
|
returns NaN, `clampTerminalDimensions` returns null, and no resize is sent.
|
||||||
|
- **The end state is clean.** Long term, the main terminal can itself become a
|
||||||
|
1x1 grid of the same class, which deletes the singleton. B moves toward that;
|
||||||
|
A entrenches it.
|
||||||
|
|
||||||
|
The cost of B is that tiles must reach desktop parity with the main terminal
|
||||||
|
on the features that matter at a desk: reliable input, reconnect, file-path
|
||||||
|
links, copy, image paste, voice. Those are listed under "Seams". Most of that
|
||||||
|
work also fixes gaps the split pane has today.
|
||||||
|
|
||||||
|
## User-facing behavior
|
||||||
|
|
||||||
|
### Entry points
|
||||||
|
|
||||||
|
- **Header Tiles button** (its own button, beside Split). Opens a picker with
|
||||||
|
checkboxes over open sessions, ordered like the tab strip. When the grid is
|
||||||
|
open, the button toggles it closed.
|
||||||
|
- **Ctrl/Cmd+click a tab**: add that session to the grid (opens the grid if
|
||||||
|
closed).
|
||||||
|
- **Drag a tab** from the strip onto a tile to replace it, or onto an empty
|
||||||
|
slot to add it.
|
||||||
|
- **"Open group as tiles"** in the tab-group menu (vertical rail, where group
|
||||||
|
menus exist).
|
||||||
|
- **New sessions started from this browser tab's Run button** while the grid is
|
||||||
|
open join the next free slot and take focus. Sessions created elsewhere
|
||||||
|
(agents, other devices, cron) do not join.
|
||||||
|
- **Toggle shortcut** (registry action `toggleTileGrid`).
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
Automatic by tile count, computed by a pure helper:
|
||||||
|
|
||||||
|
| Tiles | Layout |
|
||||||
|
|---|---|
|
||||||
|
| 1 | 1x1 |
|
||||||
|
| 2 | 2x1 |
|
||||||
|
| 3 | 3x1 if the grid area is at least ~1800 px wide, else 2x2 with one empty slot |
|
||||||
|
| 4 | 2x2 |
|
||||||
|
| 5-6 | 3x2 |
|
||||||
|
| 7-9 | 3x3 |
|
||||||
|
|
||||||
|
Hard cap 9. Capacity is also bounded by a minimum tile size (about 480x240 px,
|
||||||
|
roughly 60 columns at the default tile font), so the picker disables additions
|
||||||
|
the window cannot fit.
|
||||||
|
|
||||||
|
Column and row dividers are draggable (generalizing the split divider): the
|
||||||
|
grid stores track fractions (`grid-template-columns: <a>fr <b>fr …`), each
|
||||||
|
drag clamps both neighbors to the minimum tile size, reflows locally per
|
||||||
|
animation frame, and sends one resize per affected tile at pointer-up.
|
||||||
|
|
||||||
|
### Tile header
|
||||||
|
|
||||||
|
`● name ……… ⋯ ⤢ + ×`
|
||||||
|
|
||||||
|
- **●** status dot from the existing six-state classifier
|
||||||
|
(`app._sidebarRichRow(id, session)`, built on `_mobileOverviewState`):
|
||||||
|
`needs`, `error`, `waiting`, `working`, `idle`, `done`, styled with the
|
||||||
|
existing unscoped `.home-sessions-dot--*` classes. A `needs` tile also gets a
|
||||||
|
pulsing red border so a permission prompt is visible across the room. The
|
||||||
|
label ("working 3m") shows on hover via `_mobileOverviewSince` /
|
||||||
|
`_mobileOverviewStampText`.
|
||||||
|
- **name** via `textContent` with `data-i18n-skip` (a session literally named
|
||||||
|
"Sessions" must not be translated). Double-click renames through
|
||||||
|
`_queueInlineSessionName(id, name)`.
|
||||||
|
- **⋯** reuses `openTabRailActionMenu(event, id)` (tab-rail-resize.js): Session
|
||||||
|
options, Open in a new window, Close session.
|
||||||
|
- **⤢** zooms the tile to fill the grid, like tmux zoom. The other tiles stay
|
||||||
|
connected but hidden; hidden tiles measure NaN and send no resizes. Pressing
|
||||||
|
it again (or the shortcut) restores the grid.
|
||||||
|
- **+** adds a session: a picker of open sessions not yet tiled, plus "New
|
||||||
|
session in this case", which runs the normal quick-start for the tile's case
|
||||||
|
and drops the result into the next slot.
|
||||||
|
- **×** removes the tile ONLY. The session keeps running. Killing stays behind
|
||||||
|
`⋯ → Close session` and its existing confirm modal (`requestCloseSession`).
|
||||||
|
|
||||||
|
### Focus and keyboard
|
||||||
|
|
||||||
|
- The focused tile gets an accent border; its session is `activeSessionId`.
|
||||||
|
- Tabs of tiled sessions carry an `.in-tiles` marker; the focused one is
|
||||||
|
`.active` as usual.
|
||||||
|
- Clicking a tile focuses it (a human selection, see "Focus and alert rules").
|
||||||
|
- New registry actions (all rebindable, all swallowed in every xterm key
|
||||||
|
handler so the chord never reaches a PTY):
|
||||||
|
- `toggleTileGrid` (proposed Ctrl+Shift+G)
|
||||||
|
- `focusTileLeft/Right/Up/Down` (proposed Alt+Shift+Arrows)
|
||||||
|
- `zoomTile` (proposed Alt+Shift+Enter)
|
||||||
|
- `removeTile` (unbound by default)
|
||||||
|
|
||||||
|
The defaults must be checked against xterm passthrough, Claude Code's own
|
||||||
|
bindings and browser chords before they are fixed.
|
||||||
|
- While the grid is open, Ctrl+Tab and Alt+[ / Alt+] cycle through tiles.
|
||||||
|
- A USER-initiated selection of a session that is NOT tiled (clicking its tab,
|
||||||
|
Alt+1-9 onto it, the command palette) leaves the grid and shows that session
|
||||||
|
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 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
|
||||||
|
|
||||||
|
Decided: per device, restored on reload. Stored in localStorage key
|
||||||
|
`codeman:tile-grid`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "v": 1, "open": true, "ids": ["…", "…"], "focused": "…", "zoomed": null,
|
||||||
|
"colFr": [1, 1, 1], "rowFr": [1, 1] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ids only, never content. A pure sanitizer drops unknown, deleted, detached and
|
||||||
|
duplicate ids on load. Never restored in a solo window.
|
||||||
|
|
||||||
|
The restore runs INSIDE `handleInit`, in place of its initial
|
||||||
|
`selectSession(restoreId, { auto: true })` (the non-`keepTerminal` branch), not
|
||||||
|
after it. Otherwise the page first selects the active session in the main
|
||||||
|
terminal, whose first non-shell select per page pulls an unbounded `?full=1`
|
||||||
|
capture, only to park that terminal a moment later. With a stored open grid,
|
||||||
|
the main terminal never loads on that page load. A later `handleInit` (SSE
|
||||||
|
reconnect after a server restart, the `keepTerminal` branch) reconciles ids
|
||||||
|
against the live list without rebuilding tiles that are still alive.
|
||||||
|
|
||||||
|
### Gating
|
||||||
|
|
||||||
|
- Setting `showTileGridButton`, per device (in `displayKeys`, stripped from the
|
||||||
|
settings PUT, NOT in `SettingsUpdateSchema`), default OFF. Independent of
|
||||||
|
`showSplitButton`, which is unchanged; a desk can show both buttons.
|
||||||
|
- Hidden below 1180 px by both a JS width check with a `matchMedia` listener and
|
||||||
|
a CSS `@media (max-width: 1179px)` backstop, exactly like the split button.
|
||||||
|
Narrowing the window while the grid is open returns to the single view and
|
||||||
|
keeps the stored grid.
|
||||||
|
- Hidden in solo windows (`body.solo-mode`).
|
||||||
|
- `test/mobile-header-buttons-policy.test.ts` keeps it off phones.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. `TerminalTile` (`terminal-tile.js`, load order 7.4, PR 1)
|
||||||
|
|
||||||
|
The `SplitTerminalPane` class moves out of `terminal-split.js` into a new
|
||||||
|
`src/web/public/terminal-tile.js` and is renamed `TerminalTile`, keeping every
|
||||||
|
behavior listed under "Current architecture". The split orchestration stays in
|
||||||
|
`terminal-split.js` and constructs a `TerminalTile` for Pane B; the grid (PR 2)
|
||||||
|
constructs one per tile. New in the class:
|
||||||
|
|
||||||
|
**Reconnect.** The primary pane's backoff ladder (`CodemanWsReconnect`,
|
||||||
|
constants.js: 0, 250 ms, 500 ms, ... capped at 10 s) plus up to 250 ms of
|
||||||
|
jitter, the attempt count reset only by a successful open. A reconnect is also kicked when SSE `handleInit` reports the
|
||||||
|
server is back. After every reopen the tile runs a bounded refresh (the same
|
||||||
|
in-stream `\x1bc` clear plus replay as `_refreshBuffer`), because output
|
||||||
|
frames carry no sequence number and a gap cannot be replayed otherwise. That
|
||||||
|
refresh goes through the grid's load queue like every other load (see "Load
|
||||||
|
cost"): after a deploy restart all N tiles reopen within the same second, and
|
||||||
|
N unqueued refreshes are exactly the capture storm the queue exists to
|
||||||
|
prevent. Close codes that must NOT reconnect:
|
||||||
|
|
||||||
|
| Code | Meaning | Tile does | Owner decides (via `onExit`) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 4009 | Session exited | Stops reconnecting, reports the code | PR 1 split: an "exited" marker in Pane B (the split's existing delete path collapses it if the session is removed). PR 2 grid: the Attach overlay |
|
||||||
|
| 4003 / 4004 | Forbidden / session gone | Stops reconnecting, reports the code (4003 is stopped by the tile itself: `CodemanWsReconnect` classes it as transient) | PR 1 split: a marker saying why. PR 2 grid: removes the tile |
|
||||||
|
| 4010 | Superseded by a socket with the same cid | Stops, but only for the CURRENT socket (see below) | Same marker as 4003 |
|
||||||
|
|
||||||
|
The class never decides what happens to its container; it reports the close
|
||||||
|
code through the `onExit` callback and the owner (split or grid) acts.
|
||||||
|
|
||||||
|
**Replacing a socket is race-free.** A reconnect can be kicked (by
|
||||||
|
`handleInit`) while the old socket still looks open on the client: a half-open
|
||||||
|
connection whose `onclose` has not fired yet. The new socket carries the same
|
||||||
|
cid, so the server supersedes the old one with a 4010, and that late `onclose`
|
||||||
|
would run the "4010: stop" branch on a perfectly healthy tile. So before
|
||||||
|
opening a replacement the tile detaches the old socket's handlers (as
|
||||||
|
`destroy()` already does: null `onopen`/`onmessage`/`onclose`/`onerror`, then
|
||||||
|
`close()`), and every handler checks `event.target === this.ws` and ignores
|
||||||
|
events from any socket that is no longer current.
|
||||||
|
|
||||||
|
The marker text becomes `[disconnected, reconnecting…]` (a stop writes
|
||||||
|
`[disconnected: <why>]`, `TerminalTile.STOP_MARKERS`) and keeps its "last
|
||||||
|
thing on screen" rule. On reopen the closed state is cleared BEFORE the gap
|
||||||
|
refresh, or the refresh re-owes the marker and stamps it under a healthy
|
||||||
|
pane. `reconnectNow()` skips the backoff and never replaces an open socket.
|
||||||
|
|
||||||
|
**Client id on the upgrade URL.** `cid=${clientId}:${tabNonce}:tile`. The
|
||||||
|
connection registry supersedes by cid PER SESSION, so a distinct suffix means a
|
||||||
|
tile can never evict the main terminal's socket in a 4010 loop even if the same
|
||||||
|
session were ever on both. Input frames keep the BARE `clientId` (that is what
|
||||||
|
the server dedups on).
|
||||||
|
|
||||||
|
**Reliable input.** `terminal.onData` calls
|
||||||
|
`app._sendInputAsync(this.sessionId, data)`, which rides the tile's own socket
|
||||||
|
through the input-socket map (see "Seams"). This gives the tile the same
|
||||||
|
exactly-once delivery as the main terminal (per-session `seq`, ACKed, persisted
|
||||||
|
until ACK), and through `_ackDelivery` it clears the session's idle alert when
|
||||||
|
you type, which Pane B never did.
|
||||||
|
|
||||||
|
**Geometry** (rules from #464, see `docs/architecture-invariants.md`):
|
||||||
|
|
||||||
|
- One method, `syncGeometry()`, measures (`proposeDimensions()`), resizes the
|
||||||
|
xterm and sends `{t:'z', c, r, v:'desktop'}` with THE SAME raw dimensions.
|
||||||
|
The xterm and the PTY never disagree. There is deliberately NO 40x10 floor
|
||||||
|
(unlike the main terminal's `clampTerminalDimensions`): commit `57406f6c`
|
||||||
|
removed it from Pane B because the split's 20% divider clamp can leave Pane
|
||||||
|
B at about 240 px, roughly 28 columns, and a floored xterm is wider than its
|
||||||
|
container and clips columns. The server's own range (columns 1-500, rows
|
||||||
|
1-200) is the only bound. Grid tiles never get that narrow anyway (the
|
||||||
|
minimum tile size keeps them near 60 columns).
|
||||||
|
- Unchanged dimensions are not resent (Pane B had no such dedupe and fanned out
|
||||||
|
a `tmux resize-window` per animation frame during drags before the rAF fix).
|
||||||
|
- The `{t:'zc'}` reply is handled: adopt the PTY's COLUMNS only, keep local
|
||||||
|
rows, via the pure `reconcilePtyGeometry` (constants.js). Pane B ignores
|
||||||
|
`zc` today.
|
||||||
|
- A hidden tile (zoomed out, web tab active) measures NaN and does nothing; it
|
||||||
|
syncs when shown again.
|
||||||
|
- Detached sessions are never tiled, so the split's `detachedSessions` yield in
|
||||||
|
`_sendResize` becomes a removal (see "Edge cases").
|
||||||
|
|
||||||
|
**Rendering.** DOM renderer, no WebGL addon, as Pane B does today. Chrome
|
||||||
|
allows roughly 16 live WebGL contexts per page and the main terminal keeps
|
||||||
|
one. Measure nine busy tiles on the DOM renderer before considering WebGL
|
||||||
|
(follow-up).
|
||||||
|
|
||||||
|
**Scrollback.** Tiles use their own cap, `TILE_SCROLLBACK` (proposed 10,000
|
||||||
|
lines), not `DEFAULT_SCROLLBACK` (50,000). Nine DOM-rendered xterms at 50k
|
||||||
|
lines each is a real memory cost, and a tile's initial load is already bounded
|
||||||
|
to a 1 MiB window, so a larger buffer only fills with live output over time.
|
||||||
|
The shell history pull's "pane full" check reads `term.options.scrollback`, so
|
||||||
|
it adapts to the lower cap unchanged.
|
||||||
|
|
||||||
|
**Key handler.** Pane B's `attachCustomKeyEventHandler` stays in
|
||||||
|
`TerminalTile` (every tile IS a `TerminalTile`, so no separate factory is
|
||||||
|
needed), plus:
|
||||||
|
|
||||||
|
- Ctrl+V routes into the image-paste trap with this tile's terminal and session;
|
||||||
|
- the new tile chords are swallowed (return false) so they never reach the PTY.
|
||||||
|
|
||||||
|
**Links and copy.** The tile registers the file-path link provider and uses the
|
||||||
|
shared copy helper (see "Seams"), so clicking a path printed in a tile opens the
|
||||||
|
file preview for THAT session.
|
||||||
|
|
||||||
|
**Disposal.** `destroy()` closes the socket (handlers nulled first, as today),
|
||||||
|
unregisters from the input-socket map, removes listeners and disposes the
|
||||||
|
xterm. Nothing may outlive a removed tile (24-hour sessions rule).
|
||||||
|
|
||||||
|
### 2. `TileGrid` controller and layout helper
|
||||||
|
|
||||||
|
- `computeTileLayout({ count, width, height, minTileW, minTileH })` and
|
||||||
|
`tileGridCapacity(...)`: pure, in `constants.js`, exported on
|
||||||
|
`window.CodemanTileGrid` beside the existing helper namespaces.
|
||||||
|
`sanitizeTileGridState(raw, liveSessions, detachedIds)`: pure, same place.
|
||||||
|
- The controller (in a new `src/web/public/tile-grid.js`, load order 7.6, as
|
||||||
|
`CodemanApp.prototype` methods like the split code) owns: the ordered tile list, `focusedId`,
|
||||||
|
`zoomedId`, track fractions, the `<section class="tile-grid">` element, one
|
||||||
|
`ResizeObserver` on that section (the main terminal's observer watches a
|
||||||
|
hidden node and stops firing), the divider drags and the load queue.
|
||||||
|
- DOM: the grid is a NEW sibling of `.terminal-wrap` inside `.main`, toggled by
|
||||||
|
`.main.tiles-active`. Unlike the split there is no reparenting of
|
||||||
|
`.terminal-wrap`. CSS adds `.main.webview-active .tile-grid { display: none }`
|
||||||
|
beside the existing `.terminal-split-container` rule.
|
||||||
|
|
||||||
|
### 3. Parking the main terminal
|
||||||
|
|
||||||
|
**Enter:**
|
||||||
|
|
||||||
|
1. `_cleanupPreviousSession()` once. Its snapshot of the current session is
|
||||||
|
correct at that moment, and it disconnects the main socket and flushes local
|
||||||
|
echo.
|
||||||
|
2. Add `.main.tiles-active`: `.terminal-wrap` hidden, `.tile-grid` shown.
|
||||||
|
3. `hideWelcome()`.
|
||||||
|
|
||||||
|
**Guards.** With the main socket closed, `_wsReady` is false and the SSE
|
||||||
|
terminal fallback would start writing the focused session's output into the
|
||||||
|
hidden xterm (every handler keys on `activeSessionId`). One predicate,
|
||||||
|
`_tilesOwnTerminal()`, turns these into no-ops while the grid is open:
|
||||||
|
|
||||||
|
- `_onSessionTerminal`, `_onSessionClearTerminal`, `_onSessionNeedsRefresh`
|
||||||
|
(it would fetch `?full=1` for nothing), `_scheduleDroppedOutputRecovery`;
|
||||||
|
- the `terminal.writeln` calls in `_onSessionCompletion` and `_onSessionError`;
|
||||||
|
- `retryConnection` and the `keepTerminal` branch of `handleInit`, which would
|
||||||
|
reconnect the main socket;
|
||||||
|
- as a backstop, beside the existing `detachedSessions` checks in
|
||||||
|
`sendResize`, `throttledResize`, `_maybeRefetchFullHistory` and
|
||||||
|
`restoreTerminalSize`;
|
||||||
|
- the WebGL long-task guard (`_installWebGLLongTaskGuard`). Its
|
||||||
|
`PerformanceObserver` watches the WHOLE page and counts every long task while
|
||||||
|
the main terminal's WebGL addon exists, so long tasks caused by tile rendering
|
||||||
|
or tile replays would trip it and write the sticky `codeman-webgl-disabled`
|
||||||
|
marker (7 days), silently moving the main terminal to the DOM renderer for
|
||||||
|
reasons that have nothing to do with WebGL. While tiles own the terminal the
|
||||||
|
observer callback must not count entries.
|
||||||
|
|
||||||
|
A missed guard is mostly harmless (exit replays from scratch) but costs fetches
|
||||||
|
and CPU, so the guard test enumerates them.
|
||||||
|
|
||||||
|
`_computeConnectionDescriptor` must derive the header connection state from the
|
||||||
|
tile sockets while the grid is open (all open: connected; any reconnecting:
|
||||||
|
degraded), or the header shows "Connecting" forever.
|
||||||
|
|
||||||
|
The SSE subscription stays `[activeSessionId]` as in single view. Those frames
|
||||||
|
are dropped by the guards. (An empty list means "all sessions", so there is no
|
||||||
|
"none" to subscribe to; this matches today's single-view duplication anyway.)
|
||||||
|
|
||||||
|
**Exit:**
|
||||||
|
|
||||||
|
1. Destroy every tile, remove `.main.tiles-active`.
|
||||||
|
2. `this._lastResizeDims = null` (as `_redock` does).
|
||||||
|
3. Invalidate the main terminal's cached content for EVERY tiled id, not just
|
||||||
|
the focused one: the `_xtermSnapshots` entry, the `codeman-xs-<id>`
|
||||||
|
localStorage key and the buffer-cache entry. Those were written before the
|
||||||
|
grid opened, possibly hours earlier, and `selectSession` paints a snapshot as
|
||||||
|
a first frame before its fetch replaces it, so the next switch to a formerly
|
||||||
|
tiled session would flash content from before the grid.
|
||||||
|
4. `selectSession(focusedId, { forceReload: true, auto: true })`. For the
|
||||||
|
already-active id that path drops the stale snapshot and nulls
|
||||||
|
`activeSessionId` BEFORE `_cleanupPreviousSession`, so nothing wrong is
|
||||||
|
saved, then reconnects and replays normally.
|
||||||
|
|
||||||
|
### 4. The tile branch of `selectSession`
|
||||||
|
|
||||||
|
Placed in `app.js` directly after the "already active" early return (~7876),
|
||||||
|
so tapping the focused tab still acknowledges its alert and the detached-window
|
||||||
|
check (~7855) still runs first:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (this._tileGrid?.open) {
|
||||||
|
if (this._tileGrid.has(sessionId)) return this._selectTiledSession(sessionId, options);
|
||||||
|
// Decision 1: only a USER-initiated pick (or an explicit leaveTiles) of a
|
||||||
|
// non-tiled session leaves the grid. An app-driven one never collapses it.
|
||||||
|
if (options.auto === true && !options.leaveTiles) return;
|
||||||
|
this.closeTileGrid({ keepStored: true });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_selectTiledSession` keeps:
|
||||||
|
|
||||||
|
- `++this._selectGeneration` (aborts any in-flight normal select at its next
|
||||||
|
`_isStaleSelect` check);
|
||||||
|
- `_hideWebviewLayer()`; `activeSessionId = id`; `_activateFileBrowserSession`;
|
||||||
|
the `codeman-active-session` key; `hideWelcome()`;
|
||||||
|
- `markIdleAlertSeen(id)` only when user-initiated (`options.auto !== true`);
|
||||||
|
- `_updateActiveTabImmediate`, tab glow, `renderSessionTabs`,
|
||||||
|
`closeSessionSidebarOnHandheld`;
|
||||||
|
- `updateAttachmentHistoryBadge`, `KeyboardAccessoryBar.refreshForActiveSession`,
|
||||||
|
`refreshHostWakeBanner`, `currentSessionWorkingDir`;
|
||||||
|
- the deferred panel block (respawn banner and countdown, action log, task
|
||||||
|
panel, Ralph state, CLI info, project insights, subagent window visibility,
|
||||||
|
file browser). That block (~8452-8523) is first extracted into
|
||||||
|
`_refreshSessionPanels(id, generation)` so both paths share one copy;
|
||||||
|
- focusing the tile's xterm.
|
||||||
|
|
||||||
|
It skips everything bound to the main terminal: `terminal.focus()`,
|
||||||
|
`_cleanupPreviousSession`, the truncation banner, `playTerminalEntrance`,
|
||||||
|
local-echo state, `_beginBufferLoad`, `syncTerminalGeometry`, both
|
||||||
|
`sendResize` calls, snapshot/cache/fetch replay, `_fullHistoryLoaded`,
|
||||||
|
`_markTerminalBufferReconciled`, `_connectWs`, scroll-to-bottom, the resize
|
||||||
|
retry, and the `pid === null` attach POST (the tile's Attach overlay owns it).
|
||||||
|
|
||||||
|
The split's own `selectSession` and `_onSessionDeleted` wrappers stay. They
|
||||||
|
act only while `this._splitPane` is set, and the grid never opens alongside a
|
||||||
|
split (see "Coexistence with the split pane"), so the two never compete.
|
||||||
|
|
||||||
|
#### Selections while the grid is open
|
||||||
|
|
||||||
|
Several app-driven paths call `selectSession` on their own, and without the
|
||||||
|
`auto` rule above each of them would land on a non-tiled session and collapse
|
||||||
|
the grid. Each one gets an explicit grid-aware behavior:
|
||||||
|
|
||||||
|
| Path | Today | With the grid open |
|
||||||
|
|---|---|---|
|
||||||
|
| 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) |
|
||||||
|
| Pane promotion after a delete (split) | `selectSession(promoted, { auto: true })` | Unchanged for the split; unreachable while the grid is open, since no split can be open then |
|
||||||
|
| Auto-join of a session created by this tab's Run | Run selects the new session | The session joins the grid first, then is selected through the tile branch |
|
||||||
|
|
||||||
|
### 5. Focus and alert rules
|
||||||
|
|
||||||
|
These follow the Approvals Inbox acknowledgement rule
|
||||||
|
(`docs/architecture-invariants.md#approvals-inbox`):
|
||||||
|
|
||||||
|
| Event | Acknowledges the idle alert? |
|
||||||
|
|---|---|
|
||||||
|
| Pointerdown on a tile, a tile-nav chord, a click on its tab | Yes (human selection) |
|
||||||
|
| Typing into a tile | Yes, via `_ackDelivery` on the input ACK |
|
||||||
|
| Opening the grid, restoring it, promoting a neighbor after a delete, auto-join of a new session | No (`auto: true`) |
|
||||||
|
| A tile merely being visible | No |
|
||||||
|
| Anything | Never clears permission/question (action) alerts; those clear only on resolution |
|
||||||
|
|
||||||
|
Notifications are unchanged: a visible but unfocused tile still raises its
|
||||||
|
sound/title/desktop notification, so nothing is silently swallowed.
|
||||||
|
|
||||||
|
### 6. Seams (small refactors, no behavior change on their own)
|
||||||
|
|
||||||
|
| Feature | Today | Change |
|
||||||
|
|---|---|---|
|
||||||
|
| Input socket | `_drainSession`, `_sendInputEphemeral`, `_redeliverSweep` and `_onWsInputAck` assume the single `_ws` / `_wsSessionId` / `_wsLastRecvAt` | An `_inputSocketFor(sessionId)` map that the main terminal and each tile register into (`{ ws, ready(), lastRecvAt }`). `{t:'ia'}` acks carry no session id, so each socket's `onmessage` passes its own: `_onWsInputAck(seq, msg, sessionId)`. Without the map, tile input still works through the HTTP POST fallback (durable, but one awaited POST per record). |
|
||||||
|
| File-path links | `registerFilePathLinkProvider()` reads `self.terminal` and opens with `self.activeSessionId` | `registerFilePathLinkProvider(terminal = this.terminal, getSessionId = () => this.activeSessionId)` returning the provider; about five references change |
|
||||||
|
| 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 }`; 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
|
||||||
|
|
||||||
|
Tiles get their own per-device font size, `codeman-tile-font-size` (default
|
||||||
|
13), because a tile is a fraction of the screen. While the grid is open,
|
||||||
|
Ctrl +/- changes the tile font for all tiles. A font change is a geometry
|
||||||
|
change (#464): every tile re-runs `syncGeometry()` afterwards.
|
||||||
|
|
||||||
|
### 8. Load cost
|
||||||
|
|
||||||
|
`GET /api/sessions/:id/terminal` runs three SYNCHRONOUS tmux calls
|
||||||
|
(`list-panes`, `capture-pane`, `display-message` via `execSync`, each with a
|
||||||
|
5 s timeout). Nine `full=1` loads at once would not run in parallel; they would
|
||||||
|
run back to back on the event loop and stall every WS and SSE stream on the
|
||||||
|
server for seconds.
|
||||||
|
|
||||||
|
So EVERY tile load goes through ONE grid-level client queue (PR 2, plugged in
|
||||||
|
through a `scheduleLoad` option PR 2 adds to `TerminalTile`): the initial load,
|
||||||
|
the refresh after a reconnect, a server `{t:'r'}` refresh, and the shell
|
||||||
|
history pull. No tile calls `fetch('/terminal…')` on its own. The tile's
|
||||||
|
single-flight flag stays (it is what keeps one tile's replays from
|
||||||
|
interleaving); the queue sits in front of it and bounds the whole grid.
|
||||||
|
|
||||||
|
- concurrency 1, focused tile first, then reading order (a user-triggered
|
||||||
|
history pull jumps ahead of background refreshes);
|
||||||
|
- after a deploy restart, the N reconnect refreshes drain one at a time
|
||||||
|
instead of hitting the server together;
|
||||||
|
- each load uses the BOUNDED window `?full=1&tail=TERMINAL_TAIL_SIZE` (1 MiB)
|
||||||
|
for TUI sessions and `?tail=…` for shells, never an unbounded `full=1`;
|
||||||
|
- a tile shows a quiet "loading" state until its turn;
|
||||||
|
- full history is one action away: zoom then exit tiles, or exit tiles.
|
||||||
|
|
||||||
|
### 9. Coexistence with the split pane
|
||||||
|
|
||||||
|
Decided: the split pane stays for now. The two modes are mutually exclusive
|
||||||
|
and share one tile class:
|
||||||
|
|
||||||
|
- Opening the grid while a split is open closes the split first and seeds the
|
||||||
|
grid with both of its sessions (Pane A's focused, Pane B's beside it), so
|
||||||
|
"split, then want more" is one click.
|
||||||
|
- While the grid is open, `openSplitPicker` and `openSplitPane` refuse and the
|
||||||
|
Split button shows as disabled (`aria-disabled`), the same refusal pattern
|
||||||
|
`openSplitPane` already uses for web tabs and the welcome screen.
|
||||||
|
- Closing the grid never reopens a split.
|
||||||
|
- The split's wrappers (`selectSession`, `_onSessionDeleted`) key on
|
||||||
|
`this._splitPane`, which is null whenever the grid is open, so they stay
|
||||||
|
inert there without changes.
|
||||||
|
- Shared code lives in `TerminalTile` and the seams from PR 1, so a fix to tile
|
||||||
|
behavior reaches both modes. Whether to retire the split later (a 2-tile grid
|
||||||
|
covers it) is left for after the grid has been used for a while.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| Window narrower than 1180 px | Back to single view of the focused session; stored grid kept |
|
||||||
|
| Window shrinks below the tiles' minimum size | The focused tile zooms with a short hint; widening restores the grid |
|
||||||
|
| Codeman restarts (deploy) | Tiles reconnect; their refreshes drain through the load queue one at a time; `handleInit` reconciles ids without rebuilding live tiles |
|
||||||
|
| A half-open tile socket when a reconnect is kicked | The old socket's handlers are detached before the replacement opens, so its late 4010 close is ignored |
|
||||||
|
| A phone opens a tiled session | Its resize is declined while the desktop holds a sizing claim and was active in the last 90 s (existing `Session.resize` arbitration) |
|
||||||
|
| A second desktop browser shows a tiled session full-size | Last resize wins and only the resizing socket hears `zc` (existing behavior, see follow-up 2) |
|
||||||
|
| Split collapses or a tile is removed mid-divider-drag | Drag teardown first (carried over from the split's mid-drag fix) |
|
||||||
|
| Remote (SSH) and Docker sessions | Work unchanged: their pane is a local tmux pane like any other |
|
||||||
|
| Multi-user mode | The picker lists only visible sessions (the client map is already scoped); the socket upgrade checks ownership server-side |
|
||||||
|
| Solo window | Tiles unavailable |
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
**No server change is required.**
|
||||||
|
|
||||||
|
- N sockets to N different sessions each use one slot of that session's
|
||||||
|
`MAX_WS_PER_SESSION = 5` (`ws-routes.ts`). There is no per-client or global WS
|
||||||
|
cap.
|
||||||
|
- One `/api/events` SSE stream already carries every session's lifecycle and
|
||||||
|
hook events, so tiles need no extra stream for their status dots.
|
||||||
|
- `v:'desktop'` resizes already register a sizing claim, so a phone cannot
|
||||||
|
shrink a tiled session while the desktop is active.
|
||||||
|
- The WS sends nothing on connect, which is why each tile loads its buffer
|
||||||
|
first (already true for Pane B).
|
||||||
|
|
||||||
|
Separate follow-up PRs worth doing (see "Follow-ups").
|
||||||
|
|
||||||
|
## Invariants this feature must keep
|
||||||
|
|
||||||
|
- **#464 geometry**: a tile's xterm and its PTY never disagree; one function
|
||||||
|
sizes both; `zc` adoption is columns only; withhold the fit wherever the
|
||||||
|
resize is withheld.
|
||||||
|
- **Grid and split are never open together**: opening the grid closes a split;
|
||||||
|
the split refuses to open while the grid is open.
|
||||||
|
- **One place per session in this browser tab**: the main terminal is parked
|
||||||
|
while the grid is open, a session is in at most one tile, detached sessions
|
||||||
|
are never tiled.
|
||||||
|
- **App-driven selections never collapse the grid**: only a user-initiated pick
|
||||||
|
(or an explicit `leaveTiles`) of a non-tiled session leaves it; every
|
||||||
|
app-driven fallback (close, delete, restore) picks a tile.
|
||||||
|
- **One load queue**: no tile fetches `/terminal` outside the grid's queue
|
||||||
|
(initial, reconnect, `{t:'r'}`, history pull), because each capture blocks
|
||||||
|
the server's event loop.
|
||||||
|
- **Only the current socket counts**: a tile ignores events from any socket
|
||||||
|
that is no longer `this.ws`, and detaches handlers before replacing one.
|
||||||
|
- **Tiles never touch main-terminal state**: no long tasks counted against the
|
||||||
|
main terminal's WebGL, and its snapshots for tiled ids are invalidated on
|
||||||
|
exit.
|
||||||
|
- **Alerts**: visible is not acknowledged; only a human selection or delivered
|
||||||
|
input acknowledges idle; action alerts are never cleared by view or input;
|
||||||
|
app-driven selections pass `auto: true`.
|
||||||
|
- **PTY-exit breaker**: never `clearBreaker` from an automatic path.
|
||||||
|
- **Replay clears are in-stream** (`\x1bc` queued in the write stream), never
|
||||||
|
`reset()`.
|
||||||
|
- **Capture fetches carry deadlines that cover the body** (reuse
|
||||||
|
`CodemanFetchDeadline`, as `_pullHistory` does).
|
||||||
|
- **Per-device setting**: in `displayKeys`, stripped from the PUT, not in the
|
||||||
|
`.strict()` schema; the `--hidden` marker class has a `display: none` rule.
|
||||||
|
- **Palette chords** are swallowed in every xterm key handler.
|
||||||
|
- **Escape**: the picker's close method returns early when the picker is not
|
||||||
|
open (the global Escape handler calls every close method).
|
||||||
|
- **User text** (names) via `textContent` / attributes, never `innerHTML`.
|
||||||
|
- **No secrets in localStorage**: the stored grid holds ids only.
|
||||||
|
- **Memory**: everything a tile creates is released in `destroy()`.
|
||||||
|
|
||||||
|
## Delivery: two PRs
|
||||||
|
|
||||||
|
Decided: the work ships as two PRs. PR 1 stands on its own: it builds the tile
|
||||||
|
class and the seams, and the existing split pane runs on them, so users get a
|
||||||
|
better split before the grid exists. PR 2 adds the grid on top.
|
||||||
|
|
||||||
|
### PR 1: tile foundation (the split pane gets better)
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. **Seams.** The input-socket map; the parameterized link provider, copy,
|
||||||
|
image paste and voice target; `_focusedPane()`; a `_forEachTile()` helper
|
||||||
|
that replaces the `this._splitPane` special cases in the font, family,
|
||||||
|
weight and skin setters. No behavior change on its own. Unit tests for each.
|
||||||
|
2. **`TerminalTile`.** The class moves out of `terminal-split.js` into a new
|
||||||
|
`src/web/public/terminal-tile.js` (load order 7.4, before
|
||||||
|
`terminal-split.js` at 7.5) and is renamed from `SplitTerminalPane`. The
|
||||||
|
split's orchestration (`openSplitPane`, `closeSplitPane`, the picker, the
|
||||||
|
divider drag, the wrappers) stays in `terminal-split.js` and constructs a
|
||||||
|
`TerminalTile` for Pane B. New in the class: reconnect with race-free socket
|
||||||
|
replacement and the close-code table, the `:tile` cid suffix, reliable input
|
||||||
|
through the socket map, `zc` handling with one geometry method, the
|
||||||
|
file-path link provider and image paste, and an `onExit` callback. The
|
||||||
|
`scrollback` and `scheduleLoad` options were deferred to PR 2, which
|
||||||
|
introduces them together with the grid's load queue, their first user.
|
||||||
|
3. **Split pane follows focus.** `_focusedPane()` returns Pane B while its
|
||||||
|
xterm has focus, so Ctrl+L, Ctrl+Shift+R, voice and image paste act on the
|
||||||
|
pane you are typing in. This removes most of the asymmetry the split-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
|
||||||
|
idle alert through `_ackDelivery`, which it never did.
|
||||||
|
**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
|
||||||
|
follows focus; the "deliberately plainer" list shrinks accordingly), add
|
||||||
|
`terminal-tile.js`(7.4) to the frontend load order, add a note at the top of
|
||||||
|
`docs/split-pane-sessions-plan.md` pointing here.
|
||||||
|
|
||||||
|
What users get from PR 1 alone: a split pane that survives deploys, never
|
||||||
|
loses or doubles a keystroke across a reconnect, has clickable file paths and
|
||||||
|
image paste, and whose shortcuts act on the pane that has focus.
|
||||||
|
|
||||||
|
PR 1 tests (gate):
|
||||||
|
|
||||||
|
- `test/terminal-tile-unit.test.ts`, moved from
|
||||||
|
`split-pane-terminal-unit.test.ts` with every existing case kept
|
||||||
|
(single-flight, marker-last including the async-parse fake, history pull),
|
||||||
|
plus: reconnect backoff and each close code, a late `onclose` (4010) from a
|
||||||
|
replaced socket is ignored and the tile keeps running, `zc` columns-only
|
||||||
|
adoption, the cid suffix, input routed through `_sendInputAsync` (as built:
|
||||||
|
`test/terminal-tile-input.test.ts`, which runs `connect()` for real).
|
||||||
|
- `test/input-socket-map.test.ts`: acks routed to the right session's queue,
|
||||||
|
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 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).
|
||||||
|
- `onExit`: each close code is reported once to the owner and stops
|
||||||
|
reconnecting; the split shows the matching marker.
|
||||||
|
- The image-paste wrong-session fix: an upload that finishes after a tab switch
|
||||||
|
still inserts into the session it started in.
|
||||||
|
- Every existing `split-pane-*` test keeps passing (class name updated where it
|
||||||
|
is referenced).
|
||||||
|
|
||||||
|
PR 1 browser tests: the existing `split-pane-*.browser.test.ts` files (they
|
||||||
|
match master, one pre-existing environmental failure in both). Pane B
|
||||||
|
reconnecting after a server restart and a click on a printed path opening
|
||||||
|
Pane B's file preview are covered by unit tests and checked live on the beta
|
||||||
|
instance rather than as browser tests (the harness cannot restart its own
|
||||||
|
server).
|
||||||
|
|
||||||
|
PR 1 verification: a split with two real Claude sessions on the beta instance;
|
||||||
|
restart the server mid-typing in Pane B (reconnect, refresh, no lost or doubled
|
||||||
|
input); type into Pane B while it has an idle alert (the alert clears); Ctrl+L
|
||||||
|
in Pane B; image paste into Pane B.
|
||||||
|
|
||||||
|
### PR 2: tile grid
|
||||||
|
|
||||||
|
Commits:
|
||||||
|
|
||||||
|
1. **Grid core.** `_refreshSessionPanels()` extraction from `selectSession`
|
||||||
|
(no behavior change, its own commit first), layout helpers, the grid section
|
||||||
|
and CSS, parking with guards (SSE handlers, reconnect paths, WebGL long-task
|
||||||
|
observer), the `selectSession` tile branch with the `auto` rule, the
|
||||||
|
grid-aware `closeSession` fallback, focus rules, tile chords in the shortcut
|
||||||
|
registry, the single grid-level load queue that every tile load goes
|
||||||
|
through (a new `scheduleLoad` option on `TerminalTile`, plus a `scrollback`
|
||||||
|
option for `TILE_SCROLLBACK`), coexistence with the split.
|
||||||
|
2. **Tile chrome and entry points.** Header (dot, name, menu, zoom, add,
|
||||||
|
remove), the Attach overlay, picker, dividers, drag-a-tab, Ctrl/Cmd+click,
|
||||||
|
"Open group as tiles".
|
||||||
|
3. **Persistence.** `codeman:tile-grid` restore inside `handleInit` (in place
|
||||||
|
of the initial select), snapshot invalidation on exit, auto-join of new
|
||||||
|
sessions from this tab, the `showTileGridButton` setting.
|
||||||
|
4. **Docs.** A tile-grid paragraph in CLAUDE.md beside the split-pane one,
|
||||||
|
`tile-grid.js`(7.6) in the load order, header-button and z-index notes,
|
||||||
|
`docs/architecture-invariants.md#tile-grid`, a wiki page under
|
||||||
|
`docs/wiki/`.
|
||||||
|
|
||||||
|
## Testing (PR 2)
|
||||||
|
|
||||||
|
**Gate (`npm test`):**
|
||||||
|
|
||||||
|
- `test/tile-grid-layout.test.ts`: `computeTileLayout`, `tileGridCapacity`,
|
||||||
|
`sanitizeTileGridState` (unknown, deleted, detached, duplicate ids).
|
||||||
|
- `test/tile-grid-select-branch.test.ts` and an extension of
|
||||||
|
`test/session-select-ack-gate.test.ts`: the branch never calls `_connectWs` or
|
||||||
|
`_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`: 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.
|
||||||
|
- `test/tile-grid-park-guards.test.ts`: every guarded SSE handler is a no-op
|
||||||
|
while tiles own the terminal, and the WebGL long-task observer counts nothing
|
||||||
|
while tiles own the terminal.
|
||||||
|
- `test/tile-grid-load-queue.test.ts`: N tiles reconnecting together produce at
|
||||||
|
most one in-flight `/terminal` fetch at a time; `{t:'r'}` and history pulls go
|
||||||
|
through the same queue; a destroyed tile's queued load is dropped.
|
||||||
|
- `test/tile-grid-restore.test.ts`: with a stored open grid, `handleInit`
|
||||||
|
restores the grid and never calls the main terminal's buffer load; on exit,
|
||||||
|
snapshot and cache entries for every tiled id are invalidated.
|
||||||
|
- `test/tile-grid-split-coexistence.test.ts`: opening the grid closes an open
|
||||||
|
split and seeds the grid with both of its sessions; the split cannot open
|
||||||
|
while the grid is open; the split's wrappers do nothing while the grid is
|
||||||
|
open.
|
||||||
|
- `test/tile-grid-per-device-setting.test.ts` and a hidden-button CSS case, in
|
||||||
|
the shape of the split-pane ones: `showTileGridButton` is in `displayKeys`,
|
||||||
|
stripped from the PUT, not in the schema, and `.btn-tile-grid--hidden` has a
|
||||||
|
`display: none` rule.
|
||||||
|
- Shortcut tests: the new chords are swallowed in the main and tile key
|
||||||
|
handlers; `mobile-header-buttons-policy` keeps the button off phones.
|
||||||
|
|
||||||
|
**Browser (`npm run test:browser -- <file>`, NOT in the gate):**
|
||||||
|
|
||||||
|
- Open 4 tiles; all render live output independently.
|
||||||
|
- Click-to-focus routes keystrokes to the right PTY (assert with
|
||||||
|
`tmux capture-pane`, never on HTTP 200).
|
||||||
|
- Ctrl+L clears only the focused tile; Shift+Enter inserts a newline in a tile.
|
||||||
|
- Zoom and unzoom refit; a divider drag sends exactly one resize per affected
|
||||||
|
tile at pointer-up.
|
||||||
|
- Deleting a tiled session removes its tile and moves focus.
|
||||||
|
- Reload restores the grid; a server restart reconnects every tile.
|
||||||
|
- Narrowing below 1180 px returns to the single view.
|
||||||
|
|
||||||
|
Reminder: `npm test -- <browser file>` matches nothing, runs zero tests and
|
||||||
|
exits green. Use the browser runner for those files and read the file count.
|
||||||
|
|
||||||
|
## Verification before merging PR 2
|
||||||
|
|
||||||
|
- Build in the worktree and run an isolated beta instance (its own
|
||||||
|
`CODEMAN_INSTANCE`, so its own data dir and tmux socket), reached through
|
||||||
|
`tailscale serve` on a never-used port.
|
||||||
|
- Six throwaway Claude sessions, as in the target picture; Playwright captures
|
||||||
|
at `deviceScaleFactor: 1`, unique filenames.
|
||||||
|
- A Chrome performance trace with all six tiles working at once for 60 s,
|
||||||
|
recorded in the PR, with pass/fail bars:
|
||||||
|
- p95 frame time at or below 25 ms (40 fps or better);
|
||||||
|
- no long task of 200 ms or more after the initial load settles (three of
|
||||||
|
those in 30 s is what trips the WebGL fallback, so the bar matches the
|
||||||
|
codebase's own threshold);
|
||||||
|
- JS heap within ±10% between minute 1 and minute 10 of a ten-minute run
|
||||||
|
(no growth from tile churn: add and remove tiles and zoom a few times in
|
||||||
|
between);
|
||||||
|
- the initial load of six tiles completes with the server's event loop never
|
||||||
|
blocked for more than one capture at a time (check `Server-Timing` on each
|
||||||
|
`/terminal` response).
|
||||||
|
|
||||||
|
Missing a bar blocks the merge or lowers the tile cap, not the bar.
|
||||||
|
- A server restart while typing into a tile: reconnect, refresh, no lost or
|
||||||
|
doubled input.
|
||||||
|
- A phone opened on one tiled session: the desktop tile keeps its size while
|
||||||
|
active.
|
||||||
|
|
||||||
|
## Follow-ups (not in these PRs)
|
||||||
|
|
||||||
|
1. Make `captureActivePaneBuffer` async (`execFile`) and add a server-side limit
|
||||||
|
on concurrent captures, so no client can stall the event loop with captures.
|
||||||
|
2. When a session's PTY size changes, send `zc` to EVERY socket on that
|
||||||
|
session. Today only the resizing socket hears back, so a second desktop
|
||||||
|
viewer keeps a stale width and renders garbled output (#464).
|
||||||
|
3. WebSocket backpressure (`bufferedAmount` threshold, drop and send `{t:'r'}`
|
||||||
|
on drain) for grids over slow links.
|
||||||
|
4. Tile parity extras: mouse-wheel forwarding for Claude's fullscreen renderer,
|
||||||
|
a "Load full history" action inside a tile.
|
||||||
|
5. WebGL in tiles, after measuring the DOM renderer with nine busy tiles.
|
||||||
|
6. Named grid presets, possibly per owner on the server.
|
||||||
|
7. The end state: the main terminal becomes a 1x1 grid of `TerminalTile`,
|
||||||
|
which removes the singleton from `terminal-ui.js`.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Clicking a tab that is not tiled.** Decided: a user-initiated pick
|
||||||
|
leaves the grid and shows that session in the single
|
||||||
|
view; the grid is kept for one-click return. App-driven selections never
|
||||||
|
leave it either way. Alternative: swap that session into the focused tile.
|
||||||
|
2. **The split pane.** Decided: keep it alongside the grid for now; the two
|
||||||
|
share `TerminalTile` and never open together.
|
||||||
|
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.** 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
|
||||||
|
|
||||||
|
Line numbers are approximate (as of 1.35.0) and drift; the names are stable.
|
||||||
|
|
||||||
|
| Area | Where |
|
||||||
|
|---|---|
|
||||||
|
| Split pane class and orchestration | `src/web/public/terminal-split.js` (`SplitTerminalPane`, `openSplitPane`, `closeSplitPane`, `_installSplitDividerDrag`, the `selectSession` and `_onSessionDeleted` wrappers) |
|
||||||
|
| Split helpers | `src/web/public/constants.js` ~1592-1634 (`SPLIT_PANE_MIN_WIDTH`, `clampDividerPercent`, `buildSplitPickerSessions`), exported as `window.CodemanSplitPane` |
|
||||||
|
| `selectSession` | `src/web/public/app.js` ~7844-8644; early return ~7868-7876; deferred panels ~8452-8523 |
|
||||||
|
| `_cleanupPreviousSession` | `app.js` ~7377 |
|
||||||
|
| Reliable input | `app.js` ~3558-3920 (`_sendInputAsync`, `_reliableSend`, `_nextSeq`, `_drainSession`, `_ackDelivery`, `_onWsInputAck`, `_redeliverSweep`); main socket URL ~3341 |
|
||||||
|
| SSE terminal fallback | `app.js` `_onSessionTerminal` ~2192, `_onSessionNeedsRefresh` ~2916, `_onSessionClearTerminal` ~3020 |
|
||||||
|
| Geometry | `terminal-ui.js` `syncTerminalGeometry` ~5894, `sendResize` ~5981, `_onPtyGeometryReport` ~6072, `throttledResize` ~1292-1429; `constants.js` `reconcilePtyGeometry` ~1850 |
|
||||||
|
| Link provider | `terminal-ui.js` `registerFilePathLinkProvider` ~1831 |
|
||||||
|
| Main key handler | `terminal-ui.js` ~552-718 |
|
||||||
|
| Shortcut registry | `app.js` `DEFAULT_SHORTCUTS` ~406-557, `SHORTCUT_ACTIONS` ~1248, capture handler ~1263-1363 |
|
||||||
|
| Status classifier | `app.js` `_sidebarRichRow` ~5058; `mobile-overview.js` `_mobileOverviewState` ~130; dot CSS `.home-sessions-dot--*` in styles.css |
|
||||||
|
| Session action menu | `tab-rail-resize.js` `openTabRailActionMenu` ~318 |
|
||||||
|
| Tab drag | `app.js` `setupTabDragHandlers` ~7137 |
|
||||||
|
| Tab-group menu | `app.js` `openTabGroupMenu` ~6737 |
|
||||||
|
| Image paste | `image-input.js` `_handleImagePaste` ~53, `_uploadAndInsertImages` ~130 |
|
||||||
|
| Voice target | `voice-input.js` `start` ~666, `_insertText` ~962 |
|
||||||
|
| WS route and caps | `src/web/routes/ws-routes.ts` (`MAX_WS_PER_SESSION`, frame handling), `src/web/ws-connection-registry.ts` |
|
||||||
|
| Resize arbitration | `src/session.ts` `resize` / `claimDesktopSizing` ~4339-4426 |
|
||||||
|
| Terminal capture | `src/web/routes/session-routes.ts` `GET /api/sessions/:id/terminal` ~2986; `src/tmux-manager.ts` `captureActivePaneBuffer` ~3825 |
|
||||||
|
| Attach | `session-routes.ts` `POST /api/sessions/:id/interactive` ~1566 |
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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`. |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+81
-14
@@ -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',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -867,6 +874,11 @@ class CodemanApp {
|
|||||||
// writes when the freshly computed descriptor is identical (COD-136).
|
// writes when the freshly computed descriptor is identical (COD-136).
|
||||||
this._lastIndicatorDescriptor = null;
|
this._lastIndicatorDescriptor = null;
|
||||||
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
|
||||||
|
// Terminal sockets OTHER than the primary one (`this._ws`), keyed by the
|
||||||
|
// session they are bound to: the split pane's second terminal registers its
|
||||||
|
// socket here so its input rides the same exactly-once queue. Values are
|
||||||
|
// `{ ws, lastRecvAt }` handles owned by that terminal (see _inputSocketFor).
|
||||||
|
this._extraInputSockets = new Map();
|
||||||
this._persistReliableTimer = null;
|
this._persistReliableTimer = null;
|
||||||
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||||
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
||||||
@@ -3397,7 +3409,7 @@ class CodemanApp {
|
|||||||
} else if (msg.t === 'ia') {
|
} else if (msg.t === 'ia') {
|
||||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||||
// the durable queue so it can never be re-delivered/lost.
|
// the durable queue so it can never be re-delivered/lost.
|
||||||
this._onWsInputAck(msg.seq, msg);
|
this._onWsInputAck(msg.seq, msg, sessionId);
|
||||||
} else if (msg.t === 'zc') {
|
} else if (msg.t === 'zc') {
|
||||||
// Resize confirm — the geometry the PTY actually holds, which is not
|
// Resize confirm — the geometry the PTY actually holds, which is not
|
||||||
// always the one this client asked for (issue #464).
|
// always the one this client asked for (issue #464).
|
||||||
@@ -3579,6 +3591,49 @@ class CodemanApp {
|
|||||||
this._reliableSend(sessionId, input, useMux);
|
this._reliableSend(sessionId, input, useMux);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OPEN terminal socket bound to `sessionId`, or null: the primary socket
|
||||||
|
* first, then one registered by a second terminal (the split pane). The
|
||||||
|
* delivery layer below asks this instead of reading `this._ws` directly, so a
|
||||||
|
* second terminal's input gets the same exactly-once queue, ACKs and
|
||||||
|
* half-open detection as the primary's.
|
||||||
|
*
|
||||||
|
* Returns `{ ws, lastRecvAt }`: `lastRecvAt` is the socket's last received
|
||||||
|
* frame, which `_redeliverSweep` reads to tell a dead socket from a slow ACK.
|
||||||
|
*/
|
||||||
|
_inputSocketFor(sessionId) {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||||
|
return { ws: this._ws, lastRecvAt: this._wsLastRecvAt };
|
||||||
|
}
|
||||||
|
const handle = this._extraInputSockets?.get(sessionId);
|
||||||
|
if (handle && handle.ws && handle.ws.readyState === WebSocket.OPEN) return handle;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a second terminal's socket for `sessionId` (call from its onopen).
|
||||||
|
* `handle` is `{ ws, lastRecvAt }`, owned by the caller, which must bump
|
||||||
|
* `handle.lastRecvAt` on every received frame. Stamped here so a socket that
|
||||||
|
* just opened does not look silent to the redelivery sweep, which would
|
||||||
|
* otherwise force-close it as half-open on the first stale record.
|
||||||
|
*/
|
||||||
|
_registerInputSocket(sessionId, handle) {
|
||||||
|
if (!sessionId || !handle) return;
|
||||||
|
handle.lastRecvAt = Date.now();
|
||||||
|
if (!this._extraInputSockets) this._extraInputSockets = new Map();
|
||||||
|
this._extraInputSockets.set(sessionId, handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop a registration, but only the one `handle` made: a replacement socket
|
||||||
|
* registers its own handle before the old one's close lands, and that late
|
||||||
|
* close must not unregister the socket that replaced it.
|
||||||
|
*/
|
||||||
|
_unregisterInputSocket(sessionId, handle) {
|
||||||
|
if (this._extraInputSockets?.get(sessionId) === handle) this._extraInputSockets.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
* Fire-and-forget input for EPHEMERAL, loss-tolerant streams (e.g. wheel-scroll
|
||||||
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
||||||
@@ -3590,9 +3645,10 @@ class CodemanApp {
|
|||||||
*/
|
*/
|
||||||
_sendInputEphemeral(sessionId, input) {
|
_sendInputEphemeral(sessionId, input) {
|
||||||
if (!sessionId || !input) return;
|
if (!sessionId || !input) return;
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
const sock = this._inputSocketFor(sessionId);
|
||||||
|
if (sock) {
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
sock.ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||||
return;
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
// socket died mid-send — fall through to a best-effort POST
|
// socket died mid-send — fall through to a best-effort POST
|
||||||
@@ -3646,11 +3702,12 @@ class CodemanApp {
|
|||||||
// over the single ordered stream. They stay pending until the server ACKs
|
// over the single ordered stream. They stay pending until the server ACKs
|
||||||
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
// them ({t:'ia'}); a frame swallowed by a half-open socket is re-sent after
|
||||||
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
// the sweep force-reconnects (which resets sentAt=0 in _onWsReady).
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
const sock = this._inputSocketFor(sessionId);
|
||||||
|
if (sock) {
|
||||||
for (const rec of list) {
|
for (const rec of list) {
|
||||||
if (rec.sentAt !== 0) continue;
|
if (rec.sentAt !== 0) continue;
|
||||||
try {
|
try {
|
||||||
this._ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
sock.ws.send(JSON.stringify({ t: 'i', d: rec.data, seq: rec.seq, cid: this._clientId }));
|
||||||
rec.sentAt = Date.now();
|
rec.sentAt = Date.now();
|
||||||
rec.tries++;
|
rec.tries++;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -3671,7 +3728,7 @@ class CodemanApp {
|
|||||||
if (!cur || cur.length === 0) break;
|
if (!cur || cur.length === 0) break;
|
||||||
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
// If the WebSocket came back mid-drain, yield to it (the acked stream)
|
||||||
// so we don't redundantly re-POST what onopen is already re-sending.
|
// so we don't redundantly re-POST what onopen is already re-sending.
|
||||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
if (this._inputSocketFor(sessionId)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const rec = cur[0];
|
const rec = cur[0];
|
||||||
@@ -3759,8 +3816,10 @@ class CodemanApp {
|
|||||||
* called a duplicate is the mechanism working as designed — the original did
|
* called a duplicate is the mechanism working as designed — the original did
|
||||||
* land — and re-sending it would type the same thing twice.
|
* land — and re-sending it would type the same thing twice.
|
||||||
*/
|
*/
|
||||||
_onWsInputAck(seq, msg) {
|
_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {
|
||||||
const sessionId = this._wsSessionId;
|
// `sessionId` is the session of the socket the ACK arrived on: `{t:'ia'}`
|
||||||
|
// frames carry none, and with a second terminal's socket in play
|
||||||
|
// `this._wsSessionId` is no longer the only candidate.
|
||||||
if (!sessionId || !Number.isInteger(seq)) return;
|
if (!sessionId || !Number.isInteger(seq)) return;
|
||||||
if (msg && msg.err) {
|
if (msg && msg.err) {
|
||||||
// Refused for good (e.g. over the size limit): retrying cannot help.
|
// Refused for good (e.g. over the size limit): retrying cannot help.
|
||||||
@@ -3805,19 +3864,20 @@ class CodemanApp {
|
|||||||
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
for (const sessionId of [...this._pendingDeliveries.keys()]) {
|
||||||
const list = this._pendingDeliveries.get(sessionId);
|
const list = this._pendingDeliveries.get(sessionId);
|
||||||
if (!list || list.length === 0) continue;
|
if (!list || list.length === 0) continue;
|
||||||
const isActiveWs =
|
const sock = this._inputSocketFor(sessionId);
|
||||||
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
if (sock) {
|
||||||
if (isActiveWs) {
|
|
||||||
const oldest = list[0];
|
const oldest = list[0];
|
||||||
// Only tear the socket down when the oldest unacked frame is stale AND the
|
// Only tear the socket down when the oldest unacked frame is stale AND the
|
||||||
// socket has been silent for the timeout: a connection still delivering
|
// socket has been silent for the timeout: a connection still delivering
|
||||||
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
// output/ACKs is alive (the ACK is just behind), so force-closing it would
|
||||||
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
|
||||||
|
// Silence is measured on THIS socket: the primary's last frame says
|
||||||
|
// nothing about a second terminal's connection, and the reverse.
|
||||||
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
|
||||||
const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs;
|
const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs;
|
||||||
if (stale && silent) {
|
if (stale && silent) {
|
||||||
try {
|
try {
|
||||||
this._ws.close(); // half-open: never recovers on its own — force reconnect
|
sock.ws.close(); // half-open: never recovers on its own — force reconnect
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -4528,6 +4588,10 @@ class CodemanApp {
|
|||||||
// a give-up) nothing else would re-establish it from here.
|
// a give-up) nothing else would re-establish it from here.
|
||||||
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
if (this._wsSessionId !== restoreId) this._connectWs(restoreId);
|
||||||
void this._onSessionNeedsRefresh({ id: restoreId });
|
void this._onSessionNeedsRefresh({ id: restoreId });
|
||||||
|
// The split pane's second terminal reconnects on its own backoff (up to
|
||||||
|
// 10 s between tries); the server is back now, so skip the wait. A no-op
|
||||||
|
// while its socket is open, stopped for good, or destroyed.
|
||||||
|
this._splitPane?.reconnectNow?.();
|
||||||
} else {
|
} else {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
// `auto`: the app is restoring a session on load, not a human opening
|
// `auto`: the app is restoring a session on load, not a human opening
|
||||||
@@ -9077,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>`;
|
||||||
|
|||||||
@@ -1614,7 +1614,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
|||||||
if (id === excludeId) continue;
|
if (id === excludeId) continue;
|
||||||
// A detached (popped-out) session's own window already yields its PTY
|
// A detached (popped-out) session's own window already yields its PTY
|
||||||
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
// size (see sendResize's detachedElsewhere guard in terminal-ui.js) —
|
||||||
// Pane B's SplitTerminalPane._sendResize() has no such check, so letting
|
// Pane B's TerminalTile._sendResize() has no such check, so letting
|
||||||
// one into the picker put its detached window and Pane B in a fight over
|
// one into the picker put its detached window and Pane B in a fight over
|
||||||
// the same PTY's dimensions.
|
// the same PTY's dimensions.
|
||||||
if (detachedIds?.has?.(id)) continue;
|
if (detachedIds?.has?.(id)) continue;
|
||||||
@@ -1622,7 +1622,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
|||||||
if (!session) continue;
|
if (!session) continue;
|
||||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||||
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
// whose breaker tripped, a restore that failed to re-attach) has nothing
|
||||||
// reading its tmux pane. SplitTerminalPane never does selectSession()'s
|
// reading its tmux pane. TerminalTile never does selectSession()'s
|
||||||
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
// re-attach POST, so its socket would open onto a pane nothing feeds:
|
||||||
// no terminal events, and Session.write() silently drops every keystroke
|
// no terminal events, and Session.write() silently drops every keystroke
|
||||||
// with no ack either way (Pane B sends no `seq`), so the loss is
|
// with no ack either way (Pane B sends no `seq`), so the loss is
|
||||||
|
|||||||
@@ -50,8 +50,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||||
_handleImagePaste() {
|
// `target` names the terminal the Ctrl+V came from and its session; both
|
||||||
|
// default to the primary pane. A second terminal (the split pane) passes its
|
||||||
|
// own, so text pastes into THAT xterm and images upload to THAT session.
|
||||||
|
_handleImagePaste(target = {}) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const sessionId = target.sessionId || this.activeSessionId;
|
||||||
|
|
||||||
// Create a hidden contenteditable div to receive the paste
|
// Create a hidden contenteditable div to receive the paste
|
||||||
const trap = document.createElement('div');
|
const trap = document.createElement('div');
|
||||||
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
setTimeout(function() {
|
setTimeout(function() {
|
||||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||||
// Refocus the terminal
|
// Refocus the terminal
|
||||||
if (self.terminal) self.terminal.focus();
|
if (terminal) terminal.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
if (imageFiles.length > 0) {
|
if (imageFiles.length > 0) {
|
||||||
self._uploadAndInsertImages(imageFiles);
|
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||||
} else {
|
} else {
|
||||||
// No image -- route text through xterm's paste() so bracketed-paste
|
// No image -- route text through xterm's paste() so bracketed-paste
|
||||||
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
// markers (CSI 200~ ... CSI 201~) survive when the inner application
|
||||||
@@ -106,7 +111,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// indistinguishable from typed input, weakening the CLI's
|
// indistinguishable from typed input, weakening the CLI's
|
||||||
// prompt-injection defenses.
|
// prompt-injection defenses.
|
||||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||||
if (text && self.terminal) self.terminal.paste(text);
|
if (text && terminal) terminal.paste(text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +131,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
/** Upload a batch and normally insert its paths into the active terminal.
|
/** Upload a batch and normally insert its paths into the active terminal.
|
||||||
* The prompt composer passes `{ insert: false }` so it can put those paths
|
* The prompt composer passes `{ insert: false }` so it can put those paths
|
||||||
* into its textarea instead. Returns successful paths in selection order. */
|
* into its textarea instead. `options.sessionId` names the session to upload
|
||||||
|
* to (default: the active one). Returns successful paths in selection order. */
|
||||||
async _uploadAndInsertImages(fileList, options = {}) {
|
async _uploadAndInsertImages(fileList, options = {}) {
|
||||||
const sessionId = this.activeSessionId;
|
const sessionId = options.sessionId || this.activeSessionId;
|
||||||
if (!sessionId) return [];
|
if (!sessionId) return [];
|
||||||
|
|
||||||
let files = Array.from(fileList || []);
|
let files = Array.from(fileList || []);
|
||||||
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const paths = results.filter(Boolean);
|
const paths = results.filter(Boolean);
|
||||||
if (paths.length > 0 && options.insert !== false) {
|
if (paths.length > 0 && options.insert !== false) {
|
||||||
// Insert all paths in one shot, space-separated, in selection order.
|
// Insert all paths in one shot, space-separated, in selection order, into
|
||||||
await this.sendInput(paths.join(' '));
|
// the session the batch was uploaded TO. Not sendInput(): it re-reads
|
||||||
|
// activeSessionId, and after the awaits above that is whatever tab the
|
||||||
|
// user switched to mid-upload, so the paths landed in the wrong session.
|
||||||
|
// Same delivery sendInput() uses (durable queue, useMux for the POST path).
|
||||||
|
this._sendInputAsync(sessionId, paths.join(' '), { useMux: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final status: successes, plus any failures / cap so nothing is silent.
|
// Final status: successes, plus any failures / cap so nothing is silent.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -3919,6 +3918,7 @@
|
|||||||
<script defer src="app.js"></script>
|
<script defer src="app.js"></script>
|
||||||
<script defer src="tab-rail-resize.js"></script>
|
<script defer src="tab-rail-resize.js"></script>
|
||||||
<script defer src="terminal-ui.js"></script>
|
<script defer src="terminal-ui.js"></script>
|
||||||
|
<script defer src="terminal-tile.js"></script>
|
||||||
<script defer src="terminal-split.js"></script>
|
<script defer src="terminal-split.js"></script>
|
||||||
<script defer src="respawn-ui.js"></script>
|
<script defer src="respawn-ui.js"></script>
|
||||||
<script defer src="ralph-panel.js"></script>
|
<script defer src="ralph-panel.js"></script>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,640 +1,16 @@
|
|||||||
// src/web/public/terminal-split.js
|
// src/web/public/terminal-split.js
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
* @fileoverview Split-pane orchestration: opens a second live session
|
||||||
* ("Pane B") for split-view sessions. Deliberately plainer than the primary
|
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
||||||
* pane (this.terminal/this._ws in terminal-ui.js): no local-echo overlay, no
|
* a draggable divider, a session picker, and auto-collapse when either
|
||||||
* CJK IME, no touch/mobile handlers, no keyboard accessory bar. Desktop-only
|
* session ends. Desktop-only; see docs/split-pane-sessions-plan.md.
|
||||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
|
||||||
*
|
*
|
||||||
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
* @dependency terminal-tile.js (window.TerminalTile)
|
||||||
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
||||||
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
* @loadorder 7.5 of 16, loaded after terminal-tile.js and before respawn-ui.js
|
||||||
* @loadorder 7.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(function (global) {
|
|
||||||
// How long a scroll-to-top history pull may hold Pane B's live output.
|
|
||||||
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal chunked write for Pane B's own xterm instance — write() in
|
|
||||||
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
|
||||||
* giant synchronous write that blocks the main thread while parsing a long
|
|
||||||
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
|
||||||
* (terminal-ui.js): that one is wired into session-switch generation
|
|
||||||
* counters and the live-output gate this simpler, independently
|
|
||||||
* created/destroyed pane has no equivalent of.
|
|
||||||
*/
|
|
||||||
function writeChunked(terminal, buffer, isDestroyed) {
|
|
||||||
if (!buffer) return Promise.resolve();
|
|
||||||
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
|
||||||
terminal.write(buffer);
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
// Resolves once the LAST chunk is written (or the pane was destroyed
|
|
||||||
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
|
||||||
// across the whole replay rather than just the fetch that precedes it.
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let offset = 0;
|
|
||||||
const writeNext = () => {
|
|
||||||
if (isDestroyed() || !terminal) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
|
||||||
offset += chunk.length;
|
|
||||||
terminal.write(chunk);
|
|
||||||
if (offset < buffer.length) {
|
|
||||||
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
|
||||||
else setTimeout(writeNext, 16);
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
writeNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class SplitTerminalPane {
|
|
||||||
constructor(sessionId, mountEl, opts = {}) {
|
|
||||||
this.sessionId = sessionId;
|
|
||||||
this.mountEl = mountEl;
|
|
||||||
this.sessionMode = opts.mode;
|
|
||||||
this.fontSettings = opts.fontSettings || {};
|
|
||||||
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
|
||||||
// detaching this session AFTER the split is already open must still be
|
|
||||||
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
|
||||||
// fight the split picker already refuses to open at pick time.
|
|
||||||
this.detachedSessions = opts.detachedSessions;
|
|
||||||
this.terminal = null;
|
|
||||||
this.fitAddon = null;
|
|
||||||
this.ws = null;
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = false;
|
|
||||||
this._destroyed = false;
|
|
||||||
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
|
||||||
this._bufferLoading = false;
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
|
||||||
// `_liveQueue` is non-null from the pull's response until its finally
|
|
||||||
// block: live frames are held there with their arrival time instead of
|
|
||||||
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
|
||||||
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
|
||||||
this._historyPullAt = 0;
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
this._liveQueue = null;
|
|
||||||
this._markerOwed = false;
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect() {
|
|
||||||
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
|
||||||
this.terminal = new Terminal({
|
|
||||||
theme: { ...global.codemanCurrentXtermTheme() },
|
|
||||||
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
|
||||||
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
|
||||||
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
|
||||||
lineHeight: 1.2,
|
|
||||||
cursorBlink: false,
|
|
||||||
cursorStyle: 'block',
|
|
||||||
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
|
||||||
scrollback: DEFAULT_SCROLLBACK,
|
|
||||||
allowTransparency: true,
|
|
||||||
allowProposedApi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fitAddon = new FitAddon.FitAddon();
|
|
||||||
this.terminal.loadAddon(this.fitAddon);
|
|
||||||
this.terminal.open(this.mountEl);
|
|
||||||
this.fitAddon.fit();
|
|
||||||
|
|
||||||
this._installWheelListener();
|
|
||||||
|
|
||||||
this.terminal.onData((data) => {
|
|
||||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
||||||
this.ws.send(JSON.stringify({ t: 'i', d: data }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pane B has no gates of its own by default, so every app-level chord
|
|
||||||
// that the document capture-phase handler (app.js) only preventDefault()s
|
|
||||||
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
|
||||||
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
|
||||||
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
|
||||||
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
|
||||||
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
|
||||||
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
|
||||||
// registry-aware predicates so a rebind or a disable restores plain
|
|
||||||
// terminal behavior here too. Ctrl+V is deliberately left on xterm's own
|
|
||||||
// default (plain-text paste): Pane B has no image-paste trap to route it
|
|
||||||
// to, so intercepting it here would only break paste.
|
|
||||||
this.terminal.attachCustomKeyEventHandler((ev) => {
|
|
||||||
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
|
||||||
if (
|
|
||||||
ev.altKey &&
|
|
||||||
!ev.ctrlKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
|
||||||
// swallow — in a plain shell session this is the user's own
|
|
||||||
// job-control tool and must reach the PTY, but in every other mode
|
|
||||||
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
|
||||||
// loop dead. Pane B has its own PTY/session and must not send a
|
|
||||||
// suspend into a non-shell one just because the primary pane's own
|
|
||||||
// gate lives elsewhere.
|
|
||||||
if (
|
|
||||||
ev.type === 'keydown' &&
|
|
||||||
ev.key.toLowerCase() === 'z' &&
|
|
||||||
ev.ctrlKey &&
|
|
||||||
!ev.altKey &&
|
|
||||||
!ev.metaKey &&
|
|
||||||
!ev.shiftKey &&
|
|
||||||
this.sessionMode !== 'shell'
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
|
||||||
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
|
||||||
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
|
||||||
// newline from a submit. Without this gate, Pane B's onData would
|
|
||||||
// send that bare \r straight over the WS and submit an incomplete
|
|
||||||
// prompt instead of adding a line to it. Targets THIS pane's own
|
|
||||||
// session (this.sessionId), never the primary pane's
|
|
||||||
// activeSessionId, and has no local-echo overlay of its own to flush
|
|
||||||
// first (Pane B is deliberately plainer — see the fileoverview).
|
|
||||||
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
|
||||||
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
|
||||||
if (ev.type === 'keydown') {
|
|
||||||
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
|
||||||
}).catch(() => {
|
|
||||||
/* Best-effort, matching this pane's tolerance elsewhere. */
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
|
||||||
// selection, Ctrl+C copies THIS pane's own selection instead of
|
|
||||||
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
|
||||||
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
|
||||||
// explicit, never-falls-through copy chord, and the predicate above
|
|
||||||
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
|
||||||
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
|
||||||
// a branch that assigns c.key only for a couple of special cases
|
|
||||||
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
|
||||||
// for Ctrl+Shift+C either way — this is not about an accidental
|
|
||||||
// interrupt byte reaching the PTY (verified live: it does not).
|
|
||||||
// Gating this whole block on hasSelection() (an earlier draft) meant
|
|
||||||
// that with no selection Ctrl+Shift+C skipped straight to `return
|
|
||||||
// true`, silently ceding the keystroke to the BROWSER's own handling
|
|
||||||
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
|
||||||
// attempt to copy, unlike Pane A, which always intercepts it.
|
|
||||||
// Re-implemented against this.terminal rather than reusing
|
|
||||||
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
|
||||||
// and would copy the wrong pane's selection.
|
|
||||||
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
|
||||||
const raw = this.terminal?.getSelection?.() || '';
|
|
||||||
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
|
||||||
// Both clean options are read for THIS pane, never the primary one:
|
|
||||||
// the gutter width comes from this.sessionId's own run mode, and the
|
|
||||||
// partial-first-line flag from this terminal's own selection range.
|
|
||||||
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
|
||||||
// same split and the same keystroke.
|
|
||||||
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
|
||||||
const selection = isColumnSelection
|
|
||||||
? raw
|
|
||||||
: (global.CodemanCopySelection?.clean?.(raw, {
|
|
||||||
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
|
||||||
firstLinePartial: !!range && range.start.x > 0,
|
|
||||||
}) ?? raw);
|
|
||||||
if (selection.trim()) {
|
|
||||||
ev.preventDefault();
|
|
||||||
void global.app._copyText?.(selection).then((ok) => {
|
|
||||||
this.terminal?.clearSelection?.();
|
|
||||||
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Nothing worth copying — clear for feedback (a padding-only
|
|
||||||
// selection cleans to '' and this press still falls through to the
|
|
||||||
// PTY as 0x03, matching the primary pane's own rule).
|
|
||||||
if (this.terminal?.hasSelection?.()) {
|
|
||||||
this.terminal.clearSelection?.();
|
|
||||||
global.app.showToast?.('Nothing to copy', 'warning');
|
|
||||||
}
|
|
||||||
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
|
||||||
// matches terminal-ui.js's own ev.shiftKey branch.
|
|
||||||
if (ev.shiftKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load existing scrollback before going live. The WS below is
|
|
||||||
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
|
||||||
// 'terminal' events), so without this Pane B stays blank until the
|
|
||||||
// target session happens to produce new output. It LOOKED
|
|
||||||
// intermittent rather than always-broken because _sendResize() below
|
|
||||||
// often nudges the shared session's real tmux window to a new size,
|
|
||||||
// and tmux repaints its current screen on resize — that repaint was
|
|
||||||
// getting captured and streamed here, incidentally populating the
|
|
||||||
// pane. When Pane B's computed dimensions happened to already match
|
|
||||||
// the session's last-known size, Session.resize() (session.ts) skips
|
|
||||||
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
|
||||||
// The await covers the whole chunked replay, not just the fetch, so a
|
|
||||||
// live frame from the socket below can never land in the middle of it.
|
|
||||||
await this._loadBuffer();
|
|
||||||
if (this._destroyed) return;
|
|
||||||
|
|
||||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const url = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal`;
|
|
||||||
this.ws = new WebSocket(url);
|
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
|
||||||
this._wsReady = true;
|
|
||||||
this._sendResize();
|
|
||||||
};
|
|
||||||
|
|
||||||
this.ws.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const msg = JSON.parse(event.data);
|
|
||||||
if (msg.t === 'o') {
|
|
||||||
this._onLiveOutput(msg.d);
|
|
||||||
} else if (msg.t === 'c') {
|
|
||||||
this._onLiveClear();
|
|
||||||
} else if (msg.t === 'r') {
|
|
||||||
// Server-triggered refresh (SSE backpressure cleared, terminal
|
|
||||||
// data was dropped). The primary pane routes this to
|
|
||||||
// _onSessionNeedsRefresh (app.js:2990) — Pane B has its own
|
|
||||||
// buffer loader for the same reason connect() does.
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mirror app.js's onclose/onerror pattern (app.js:2905-2964): _wsReady
|
|
||||||
// must go false on a drop or fit()/_sendResize() silently no-ops on a
|
|
||||||
// closed socket per the WebSocket spec (no exception, no log). No
|
|
||||||
// reconnect logic here — Pane B is deliberately plainer than the
|
|
||||||
// primary pane (see the fileoverview above); a drop just stops
|
|
||||||
// resizing until the parent recreates the pane. But onData already
|
|
||||||
// silently drops keystrokes while _wsReady is false (below), so
|
|
||||||
// without a visible marker a dropped socket left Pane B looking
|
|
||||||
// normal while it quietly ate everything typed into it. v1 scope is
|
|
||||||
// "say so", not reconnect — collapsing the split would lose the
|
|
||||||
// user's place in Pane B's scrollback for a transient blip.
|
|
||||||
this.ws.onclose = () => this._onSocketClosed();
|
|
||||||
|
|
||||||
this.ws.onerror = () => {
|
|
||||||
// onclose fires after onerror — cleanup happens there.
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The socket's close, split out of connect() so the tests can drive it.
|
|
||||||
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
|
||||||
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
|
||||||
// written now, it would sit above the output a pull is still holding (flushed
|
|
||||||
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
|
||||||
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
|
||||||
// response holds the marker too, for as long as the request takes (up to its
|
|
||||||
// budget, see _pullHistory()).
|
|
||||||
_onSocketClosed() {
|
|
||||||
this._wsReady = false;
|
|
||||||
this._wsClosed = true;
|
|
||||||
if (this._bufferLoading) this._markerOwed = true;
|
|
||||||
else this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settles a marker the pane owes: set when a close lands during a load (the
|
|
||||||
// replay would otherwise sit below it) or when a load wipes the terminal on
|
|
||||||
// a closed socket. Called from each load's own finally, just before
|
|
||||||
// _endBufferLoad() starts any trailing refresh.
|
|
||||||
_stampMarkerIfOwed() {
|
|
||||||
// A trailing refresh is about to clear() synchronously, while xterm parses
|
|
||||||
// a write() on a later tick: a marker written here would land in the
|
|
||||||
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
|
||||||
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) return;
|
|
||||||
const owed = this._markerOwed;
|
|
||||||
this._markerOwed = false;
|
|
||||||
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
|
||||||
// closed socket can write it (see _stampMarkerIfOwed()).
|
|
||||||
_writeDisconnectedMarker() {
|
|
||||||
this.terminal?.write('\r\n\x1b[2m[Pane B disconnected — close and reopen the split to reconnect]\x1b[0m\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches and writes the session's current scrollback. Used both by
|
|
||||||
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
|
||||||
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
|
||||||
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
|
||||||
// terminal, neither of which applies to this independent pane, so this is
|
|
||||||
// a standalone equivalent rather than a call into it.
|
|
||||||
//
|
|
||||||
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
|
||||||
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
|
||||||
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
|
||||||
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
|
||||||
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
|
||||||
// get one full replay. `fetch` here goes through the global wrapper
|
|
||||||
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
|
||||||
// WebSocket URL above, which does not.
|
|
||||||
//
|
|
||||||
// Single-flight: the flag is held across the fetch AND the chunked write
|
|
||||||
// (writeChunked resolves after its last chunk), so two replays can never
|
|
||||||
// interleave their chunks into one terminal. A second call while one is
|
|
||||||
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
|
||||||
// a trailing re-run instead.
|
|
||||||
async _loadBuffer() {
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
this._bufferLoading = true;
|
|
||||||
try {
|
|
||||||
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
|
||||||
const payload = (await res.json())?.data ?? {};
|
|
||||||
if (payload.terminalBuffer && this.terminal) {
|
|
||||||
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output still arrives once the socket connects. */
|
|
||||||
} finally {
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ends a single-flight load (initial, refresh or history pull): clears the
|
|
||||||
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
|
||||||
_endBufferLoad() {
|
|
||||||
this._bufferLoading = false;
|
|
||||||
if (this._bufferRefreshPending && !this._destroyed) {
|
|
||||||
this._bufferRefreshPending = false;
|
|
||||||
this._refreshBuffer();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Live terminal output. Written straight through, except while a history
|
|
||||||
// pull is replaying: a capture is current only up to the instant tmux took
|
|
||||||
// it, so a frame arriving mid-replay is held with its arrival time and
|
|
||||||
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
|
||||||
// _finishBufferLoad `since` rule), never written underneath it.
|
|
||||||
_onLiveOutput(data) {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
|
||||||
else this.terminal?.write(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
|
||||||
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
|
||||||
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
|
||||||
// one it is restoring. Queued, it lands in order with the frames around it.
|
|
||||||
_onLiveClear() {
|
|
||||||
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
|
||||||
else this.terminal?.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
|
||||||
// event it consumes, so a bubbling listener here would never see the wheel
|
|
||||||
// while the pane still has scrollback to scroll. Passive: this only observes,
|
|
||||||
// xterm keeps doing the scrolling.
|
|
||||||
_installWheelListener() {
|
|
||||||
this._onWheel = (ev) => {
|
|
||||||
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
|
||||||
};
|
|
||||||
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
|
||||||
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
|
||||||
// so this pane's xterm ends up with about one screen of scrollback while
|
|
||||||
// tmux holds every line — and nothing here ever went back to ask, so the
|
|
||||||
// history was unreachable. The primary pane has the same pull
|
|
||||||
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
|
||||||
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
|
||||||
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
|
||||||
// grow tmux history, this just isn't how they recover it). The alternate-
|
|
||||||
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
|
||||||
// tmux the browser xterm never enters the alternate buffer.
|
|
||||||
_maybeLoadMoreHistory() {
|
|
||||||
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
|
||||||
if (this._bufferLoading) return;
|
|
||||||
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
|
||||||
// _sendResize(): a detached session's own window already owns its PTY
|
|
||||||
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const active = this.terminal.buffer.active;
|
|
||||||
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
|
||||||
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
|
||||||
// rather than latch; a pull that could only have downgraded the pane
|
|
||||||
// waits far longer.
|
|
||||||
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this._historyPullAt < cooldown) return;
|
|
||||||
this._historyPullAt = now;
|
|
||||||
void this._pullHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
|
||||||
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
|
||||||
// main thread) and replays it under the reader's current place. Holds the
|
|
||||||
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
|
||||||
async _pullHistory() {
|
|
||||||
this._bufferLoading = true;
|
|
||||||
let replayed = false;
|
|
||||||
let capturedAt = 0;
|
|
||||||
// Two budgets on one signal. The request itself gets the primary pane's
|
|
||||||
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
|
||||||
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
|
||||||
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
|
||||||
// for that whole budget. Once the headers land live output IS held, so the
|
|
||||||
// body read gets the short one instead: a body that hangs would otherwise
|
|
||||||
// freeze the pane for the long budget. Aborting lands in the catch below,
|
|
||||||
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
|
||||||
// be re-armed, hence the controller; without AbortController the pull
|
|
||||||
// simply has no deadline.
|
|
||||||
const controller = global.AbortController ? new global.AbortController() : null;
|
|
||||||
let abortTimer = null;
|
|
||||||
const armDeadline = (ms) => {
|
|
||||||
if (!controller) return;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
abortTimer = setTimeout(() => controller.abort(), ms);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
|
||||||
signal: controller?.signal,
|
|
||||||
});
|
|
||||||
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
|
||||||
// The cutoff below is the response's arrival, the same `since` rule the
|
|
||||||
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
|
||||||
// for the instant tmux took the capture, which lies somewhere in the
|
|
||||||
// round trip, so a frame in that window can be lost or doubled. Bounded
|
|
||||||
// by one round trip and not closable without a server-side capture time.
|
|
||||||
capturedAt = performance.now();
|
|
||||||
// Opened only now: a frame from before the response is either replaced by
|
|
||||||
// the capture or written unchanged, so holding it for the round trip
|
|
||||||
// bought nothing and froze the pane for as long as the fetch took.
|
|
||||||
this._liveQueue = [];
|
|
||||||
const payload = (await res.json())?.data;
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const buffer = payload?.terminalBuffer;
|
|
||||||
const term = this.terminal;
|
|
||||||
if (!buffer || !term || this._destroyed) return;
|
|
||||||
const rowsBefore = term.buffer.active.length;
|
|
||||||
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
|
||||||
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
|
||||||
// lines, so a window of short lines can carry more rows than this pane
|
|
||||||
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
|
||||||
const scrollbackCap = term.options?.scrollback || 0;
|
|
||||||
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
|
||||||
// Nothing to gain (this also covers a downgrade, which would delete
|
|
||||||
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
|
||||||
// untruncated window IS all of tmux's history and the next burst can add
|
|
||||||
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
|
||||||
// what the pane shows, and every ask costs the server a capture-pane of
|
|
||||||
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
|
||||||
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
|
||||||
// off too, since no window can ever fit in it.
|
|
||||||
if (rowsIncoming <= rowsBefore || paneFull) {
|
|
||||||
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._historyPullUseless = false;
|
|
||||||
term.write('\x1bc');
|
|
||||||
replayed = true;
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
await writeChunked(term, buffer, () => this._destroyed);
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// xterm parses asynchronously: an empty write's callback fires only
|
|
||||||
// after everything before it, so the row count below is the settled one.
|
|
||||||
await new Promise((resolve) => this.terminal.write('', resolve));
|
|
||||||
if (this._destroyed || !this.terminal) return;
|
|
||||||
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
|
||||||
// rows down; land there and the recovered history sits above it.
|
|
||||||
const delta = this.terminal.buffer.active.length - rowsBefore;
|
|
||||||
if (delta > 0) this.terminal.scrollToLine(delta);
|
|
||||||
else this.terminal.scrollToTop();
|
|
||||||
} catch {
|
|
||||||
/* Best-effort — live output keeps arriving whatever happens here. */
|
|
||||||
} finally {
|
|
||||||
clearTimeout(abortTimer);
|
|
||||||
const queued = this._liveQueue ?? [];
|
|
||||||
this._liveQueue = null;
|
|
||||||
// After a replay, only frames that arrived after the capture are news;
|
|
||||||
// earlier ones are already in it. With no replay, every held frame is.
|
|
||||||
const cutoff = replayed ? capturedAt : 0;
|
|
||||||
for (const entry of queued) {
|
|
||||||
if (entry.at < cutoff) continue;
|
|
||||||
if (entry.clear) this.terminal?.clear();
|
|
||||||
else this.terminal?.write(entry.data);
|
|
||||||
}
|
|
||||||
// Settled after the queue flush so the marker is the last thing on
|
|
||||||
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
|
||||||
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
|
||||||
// one written before it, which would paint a fresh, current-looking
|
|
||||||
// history while onData keeps silently dropping every keystroke on the
|
|
||||||
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
|
||||||
// is left to that refresh, which writes it below its own replay.
|
|
||||||
this._stampMarkerIfOwed();
|
|
||||||
this._endBufferLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
|
||||||
// frames in a row used to start two concurrent replays, each clearing
|
|
||||||
// the terminal under the other's chunked write. A refresh that arrives
|
|
||||||
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
|
||||||
// the in-flight fetch may predate the drop the new frame is reporting,
|
|
||||||
// and no further frame is coming to correct stale content.
|
|
||||||
_refreshBuffer() {
|
|
||||||
if (this._bufferLoading) {
|
|
||||||
this._bufferRefreshPending = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.terminal?.clear();
|
|
||||||
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
|
||||||
// trailing refresh behind a pull that the socket's close then interrupts),
|
|
||||||
// so a refresh on a closed socket owes it back once its replay is written.
|
|
||||||
if (this._wsClosed) this._markerOwed = true;
|
|
||||||
void this._loadBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
|
||||||
// can reflow both panes at the browser's paint rate (rAF) while sending
|
|
||||||
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
|
||||||
// pane's own convention (throttledResize in terminal-ui.js).
|
|
||||||
localFit() {
|
|
||||||
if (!this.fitAddon) return;
|
|
||||||
this.fitAddon.fit();
|
|
||||||
}
|
|
||||||
|
|
||||||
fit() {
|
|
||||||
this.localFit();
|
|
||||||
this._sendResize();
|
|
||||||
}
|
|
||||||
|
|
||||||
_sendResize() {
|
|
||||||
if (!this._wsReady || !this.fitAddon) return;
|
|
||||||
// One PTY cannot hold two sizes (mirrors sendResize's own
|
|
||||||
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
|
||||||
// to its own window AFTER this split was opened, so its own window now
|
|
||||||
// owns the PTY's size and Pane B must stand aside.
|
|
||||||
if (this.detachedSessions?.has(this.sessionId)) return;
|
|
||||||
const dims = this.fitAddon.proposeDimensions();
|
|
||||||
if (!dims) return;
|
|
||||||
// Send the real proposed dimensions unclamped, matching the primary
|
|
||||||
// pane's convention (terminal-ui.js's getTerminalDimensions()) — the
|
|
||||||
// server enforces its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
|
||||||
// A 40/10 floor here misreported Pane B's real width to the PTY at the
|
|
||||||
// divider's own reachable 20% floor position, causing real
|
|
||||||
// output-wrapping bugs.
|
|
||||||
this.ws.send(JSON.stringify({ t: 'z', c: dims.cols, r: dims.rows, v: 'desktop' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this._destroyed = true;
|
|
||||||
if (this._onWheel) {
|
|
||||||
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
|
||||||
this._onWheel = null;
|
|
||||||
}
|
|
||||||
if (this.ws) {
|
|
||||||
this.ws.onopen = null;
|
|
||||||
this.ws.onmessage = null;
|
|
||||||
// onclose fires asynchronously AFTER close(); without this it ran
|
|
||||||
// its "disconnected" write against a pane already torn down.
|
|
||||||
this.ws.onclose = null;
|
|
||||||
this.ws.onerror = null;
|
|
||||||
this.ws.close();
|
|
||||||
this.ws = null;
|
|
||||||
}
|
|
||||||
if (this.terminal) {
|
|
||||||
this.terminal.dispose();
|
|
||||||
this.terminal = null;
|
|
||||||
}
|
|
||||||
this.fitAddon = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
global.SplitTerminalPane = SplitTerminalPane;
|
|
||||||
})(window);
|
|
||||||
|
|
||||||
Object.assign(CodemanApp.prototype, {
|
Object.assign(CodemanApp.prototype, {
|
||||||
/**
|
/**
|
||||||
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
* Desktop-only gate, same shape as home-sessions.js's shouldShowHomeSessions
|
||||||
@@ -777,7 +153,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// B's own session tab while split can otherwise land here with
|
// B's own session tab while split can otherwise land here with
|
||||||
// sessionId === activeSessionId: two live WebSockets to the same
|
// sessionId === activeSessionId: two live WebSockets to the same
|
||||||
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
// session, each independently claiming PTY dimensions via its own `{t:'z',...}`
|
||||||
// resize frame. Refuse before creating any DOM or SplitTerminalPane.
|
// resize frame. Refuse before creating any DOM or TerminalTile.
|
||||||
if (sessionId === this.activeSessionId) return;
|
if (sessionId === this.activeSessionId) return;
|
||||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||||
// re-applied here: the menu can sit open while a listed session's CLI
|
// re-applied here: the menu can sit open while a listed session's CLI
|
||||||
@@ -818,7 +194,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
container.appendChild(paneB);
|
container.appendChild(paneB);
|
||||||
paneB.style.flexBasis = '50%';
|
paneB.style.flexBasis = '50%';
|
||||||
|
|
||||||
this._splitPane = new window.SplitTerminalPane(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
this._splitPane = new window.TerminalTile(sessionId, paneB.querySelector('.terminal-pane-b-container'), {
|
||||||
mode: session?.mode,
|
mode: session?.mode,
|
||||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||||
detachedSessions: this.detachedSessions,
|
detachedSessions: this.detachedSessions,
|
||||||
@@ -900,7 +276,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// frame). Coalesced to one call per animation frame below — a raw
|
// frame). Coalesced to one call per animation frame below — a raw
|
||||||
// mousemove stream fires far faster than the browser repaints, and
|
// mousemove stream fires far faster than the browser repaints, and
|
||||||
// without the rAF gate each event did a full xterm reflow on BOTH
|
// without the rAF gate each event did a full xterm reflow on BOTH
|
||||||
// panes AND sent Pane B a `{t:'z'}` resize frame (SplitTerminalPane has
|
// panes AND sent Pane B a `{t:'z'}` resize frame (TerminalTile has
|
||||||
// no client-side "dims unchanged" skip), which fanned out into a
|
// no client-side "dims unchanged" skip), which fanned out into a
|
||||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||||
// of each dragging across half a wide viewport.
|
// of each dragging across half a wide viewport.
|
||||||
|
|||||||
@@ -0,0 +1,887 @@
|
|||||||
|
// src/web/public/terminal-tile.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview TerminalTile: one independent live terminal pane bound to one
|
||||||
|
* session, with its own xterm instance and its own
|
||||||
|
* `/ws/sessions/:id/terminal` WebSocket. The split pane (terminal-split.js)
|
||||||
|
* uses one as its second pane ("Pane B"); the tile grid planned in
|
||||||
|
* docs/tile-grid-plan.md reuses the same class for every tile.
|
||||||
|
*
|
||||||
|
* Deliberately plainer than the primary pane (this.terminal/this._ws in
|
||||||
|
* terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile
|
||||||
|
* handlers, no keyboard accessory bar. Desktop-only by nature; see
|
||||||
|
* docs/split-pane-sessions-plan.md.
|
||||||
|
*
|
||||||
|
* @dependency vendor/xterm.js, vendor/xterm-addon-fit.js
|
||||||
|
* @dependency constants.js (window.CodemanTerminalFont, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE)
|
||||||
|
* @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight)
|
||||||
|
* @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
// How long a scroll-to-top history pull may hold Pane B's live output.
|
||||||
|
const HISTORY_PULL_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal chunked write for Pane B's own xterm instance — write() in
|
||||||
|
* TERMINAL_CHUNK_SIZE slices, yielding a frame between each, instead of one
|
||||||
|
* giant synchronous write that blocks the main thread while parsing a long
|
||||||
|
* scrollback. Deliberately NOT the primary pane's chunkedTerminalWrite
|
||||||
|
* (terminal-ui.js): that one is wired into session-switch generation
|
||||||
|
* counters and the live-output gate this simpler, independently
|
||||||
|
* created/destroyed pane has no equivalent of.
|
||||||
|
*/
|
||||||
|
function writeChunked(terminal, buffer, isDestroyed) {
|
||||||
|
if (!buffer) return Promise.resolve();
|
||||||
|
if (buffer.length <= TERMINAL_CHUNK_SIZE) {
|
||||||
|
terminal.write(buffer);
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
// Resolves once the LAST chunk is written (or the pane was destroyed
|
||||||
|
// mid-replay), so _loadBuffer() below can hold its single-flight flag
|
||||||
|
// across the whole replay rather than just the fetch that precedes it.
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let offset = 0;
|
||||||
|
const writeNext = () => {
|
||||||
|
if (isDestroyed() || !terminal) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chunk = buffer.slice(offset, offset + TERMINAL_CHUNK_SIZE);
|
||||||
|
offset += chunk.length;
|
||||||
|
terminal.write(chunk);
|
||||||
|
if (offset < buffer.length) {
|
||||||
|
if (typeof requestAnimationFrame === 'function') requestAnimationFrame(writeNext);
|
||||||
|
else setTimeout(writeNext, 16);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
writeNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TerminalTile {
|
||||||
|
constructor(sessionId, mountEl, opts = {}) {
|
||||||
|
this.sessionId = sessionId;
|
||||||
|
this.mountEl = mountEl;
|
||||||
|
this.sessionMode = opts.mode;
|
||||||
|
this.fontSettings = opts.fontSettings || {};
|
||||||
|
// Live reference (not a snapshot) to the app's detachedSessions Set —
|
||||||
|
// detaching this session AFTER the split is already open must still be
|
||||||
|
// seen by _sendResize() below, or it re-creates the exact PTY-size
|
||||||
|
// fight the split picker already refuses to open at pick time.
|
||||||
|
this.detachedSessions = opts.detachedSessions;
|
||||||
|
this.terminal = null;
|
||||||
|
this.fitAddon = null;
|
||||||
|
this.ws = null;
|
||||||
|
this._wsReady = false;
|
||||||
|
this._wsClosed = false;
|
||||||
|
this._destroyed = false;
|
||||||
|
// Single-flight state for _loadBuffer()/_refreshBuffer() below.
|
||||||
|
this._bufferLoading = false;
|
||||||
|
this._bufferRefreshPending = false;
|
||||||
|
// Scroll-to-top history pull (shell panes only), see _maybeLoadMoreHistory().
|
||||||
|
// `_liveQueue` is non-null from the pull's response until its finally
|
||||||
|
// block: live frames are held there with their arrival time instead of
|
||||||
|
// written under the replay. `_markerOwed` is the "disconnected" marker a
|
||||||
|
// load still has to write (see _onSocketClosed()/_stampMarkerIfOwed()).
|
||||||
|
this._historyPullAt = 0;
|
||||||
|
this._historyPullUseless = false;
|
||||||
|
this._liveQueue = null;
|
||||||
|
this._markerOwed = false;
|
||||||
|
this._onWheel = null;
|
||||||
|
// `{ ws, lastRecvAt }`, registered with the app's input-socket map while
|
||||||
|
// this pane's socket is open, so the exactly-once input queue delivers this
|
||||||
|
// session's keystrokes over it (app.js _inputSocketFor). Null otherwise.
|
||||||
|
this._inputHandle = null;
|
||||||
|
// Reconnect state. `_socketUrl` is set once connect() opens the first
|
||||||
|
// socket: a pane that never connected has nothing to reconnect to.
|
||||||
|
// `_reconnectAttempts` counts consecutive failed opens and is reset ONLY by
|
||||||
|
// a successful open (resetting it per attempt is the tight-loop bug the
|
||||||
|
// primary pane's _disconnectWs documents). `_stoppedCode` is the close code
|
||||||
|
// that ended the pane for good; `onExit(code)` tells the owner once.
|
||||||
|
this._socketUrl = null;
|
||||||
|
this._reconnectAttempts = 0;
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._stoppedCode = null;
|
||||||
|
this._markerText = TerminalTile.MARKER_RECONNECTING;
|
||||||
|
this.onExit = typeof opts.onExit === 'function' ? opts.onExit : null;
|
||||||
|
// The `{ cols, rows }` last sent in a `{t:'z'}` frame, so an unchanged size
|
||||||
|
// is not resent (each one costs a `tmux resize-window` and a SIGWINCH).
|
||||||
|
// Cleared on every open: a fresh socket must announce its size, which is
|
||||||
|
// also what registers it as a desktop viewer server-side.
|
||||||
|
this._lastSentDims = null;
|
||||||
|
// Whether the pointer is over a link in THIS pane (the primary pane's own
|
||||||
|
// flag, app._linkHovered, belongs to its terminal alone).
|
||||||
|
this._linkHovered = false;
|
||||||
|
this._onFocusIn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
const savedFontSize = parseInt(localStorage.getItem('codeman-font-size'), 10);
|
||||||
|
this.terminal = new Terminal({
|
||||||
|
theme: { ...global.codemanCurrentXtermTheme() },
|
||||||
|
fontFamily: global.CodemanTerminalFont.resolve(this.fontSettings.terminalFontFamily),
|
||||||
|
...global.CodemanTerminalFont.resolveWeights(this.fontSettings),
|
||||||
|
fontSize: Number.isFinite(savedFontSize) ? savedFontSize : 14,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
cursorBlink: false,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
minimumContrastRatio: global.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||||
|
scrollback: DEFAULT_SCROLLBACK,
|
||||||
|
allowTransparency: true,
|
||||||
|
allowProposedApi: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.fitAddon = new FitAddon.FitAddon();
|
||||||
|
this.terminal.loadAddon(this.fitAddon);
|
||||||
|
this.terminal.open(this.mountEl);
|
||||||
|
this.fitAddon.fit();
|
||||||
|
|
||||||
|
// File paths and URLs printed here are clickable, through the SAME
|
||||||
|
// provider as the primary pane (registerFilePathLinkProvider,
|
||||||
|
// terminal-ui.js), and open against THIS pane's session.
|
||||||
|
global.app?.registerFilePathLinkProvider?.({
|
||||||
|
terminal: this.terminal,
|
||||||
|
getSessionId: () => this.sessionId,
|
||||||
|
setHovered: (hovered) => {
|
||||||
|
this._linkHovered = hovered;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this._installWheelListener();
|
||||||
|
|
||||||
|
// Focusing this terminal makes it the pane the keyboard is in, so the
|
||||||
|
// app-level shortcuts, voice and paste act on it (app._focusedPane).
|
||||||
|
this._onFocusIn = () => global.app?._noteFocusedTile?.(this);
|
||||||
|
this.terminal.textarea?.addEventListener('focus', this._onFocusIn);
|
||||||
|
|
||||||
|
this.terminal.onData((data) => this._onTerminalData(data));
|
||||||
|
|
||||||
|
// Pane B has no gates of its own by default, so every app-level chord
|
||||||
|
// that the document capture-phase handler (app.js) only preventDefault()s
|
||||||
|
// — never stopPropagation()s — reaches xterm here too and writes its raw
|
||||||
|
// byte/escape sequence into THIS session's PTY on top of whatever the app
|
||||||
|
// action already did to Pane A (COD-153; mirrors the primary pane's own
|
||||||
|
// gates at terminal-ui.js's attachCustomKeyEventHandler: command palette,
|
||||||
|
// Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter
|
||||||
|
// newline, and smart-copy Ctrl+C/Ctrl+Shift+C). Routed through the same
|
||||||
|
// registry-aware predicates so a rebind or a disable restores plain
|
||||||
|
// terminal behavior here too. Ctrl+V goes through the primary pane's
|
||||||
|
// paste trap (image-input.js), aimed at this pane (below).
|
||||||
|
this.terminal.attachCustomKeyEventHandler((ev) => {
|
||||||
|
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
|
||||||
|
if (
|
||||||
|
ev.altKey &&
|
||||||
|
!ev.ctrlKey &&
|
||||||
|
!ev.shiftKey &&
|
||||||
|
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ev.type === 'keydown' && global.app?.shouldOpenCommandPaletteFromShortcut?.(ev)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ev.type === 'keydown' && global.app?.shouldToggleSessionSidebarFromShortcut?.(ev)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Ctrl+V / Cmd+V: the primary pane's paste trap, aimed at THIS pane, so
|
||||||
|
// a pasted image uploads to this pane's session and its path is typed
|
||||||
|
// here, and pasted text goes into this xterm with its bracketed-paste
|
||||||
|
// markers intact. Mirrors terminal-ui.js's own Ctrl+V gate; without it
|
||||||
|
// xterm's default only ever pasted text.
|
||||||
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v' && ev.type === 'keydown') {
|
||||||
|
global.app?._handleImagePaste?.({ terminal: this.terminal, sessionId: this.sessionId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Ctrl+Z (SIGTSTP/job-control suspend): mirrors terminal-ui.js's own
|
||||||
|
// swallow — in a plain shell session this is the user's own
|
||||||
|
// job-control tool and must reach the PTY, but in every other mode
|
||||||
|
// (claude/omp/pi/codex/...) it silently stops an unattended agent
|
||||||
|
// loop dead. Pane B has its own PTY/session and must not send a
|
||||||
|
// suspend into a non-shell one just because the primary pane's own
|
||||||
|
// gate lives elsewhere.
|
||||||
|
if (
|
||||||
|
ev.type === 'keydown' &&
|
||||||
|
ev.key.toLowerCase() === 'z' &&
|
||||||
|
ev.ctrlKey &&
|
||||||
|
!ev.altKey &&
|
||||||
|
!ev.metaKey &&
|
||||||
|
!ev.shiftKey &&
|
||||||
|
this.sessionMode !== 'shell'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Shift+Enter / Ctrl+Enter: insert a newline instead of submitting.
|
||||||
|
// Mirrors terminal-ui.js's own handling — xterm sends plain \r for
|
||||||
|
// every Enter variant, so an Ink app (Claude Code) can't tell a
|
||||||
|
// newline from a submit. Without this gate, Pane B's onData would
|
||||||
|
// send that bare \r straight over the WS and submit an incomplete
|
||||||
|
// prompt instead of adding a line to it. Targets THIS pane's own
|
||||||
|
// session (this.sessionId), never the primary pane's
|
||||||
|
// activeSessionId, and has no local-echo overlay of its own to flush
|
||||||
|
// first (Pane B is deliberately plainer — see the fileoverview).
|
||||||
|
// Swallow keypress/keyup too (xterm would send \r for a Shift-only keypress); only keydown sends.
|
||||||
|
if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey)) {
|
||||||
|
if (ev.type === 'keydown') {
|
||||||
|
fetch(`/api/sessions/${this.sessionId}/send-key`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }),
|
||||||
|
}).catch(() => {
|
||||||
|
/* Best-effort, matching this pane's tolerance elsewhere. */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Smart copy (mirrors terminal-ui.js's Ctrl+C gate, #211): with a
|
||||||
|
// selection, Ctrl+C copies THIS pane's own selection instead of
|
||||||
|
// sending ^C; with none, plain Ctrl+C must fall through unchanged or
|
||||||
|
// the interrupt key is lost. Ctrl+Shift+C is different: it is the
|
||||||
|
// explicit, never-falls-through copy chord, and the predicate above
|
||||||
|
// does not distinguish it from plain Ctrl+C — ev.shiftKey does, below.
|
||||||
|
// xterm's own evaluateKeyboardEvent routes a shifted ctrl-letter into
|
||||||
|
// a branch that assigns c.key only for a couple of special cases
|
||||||
|
// ("_"->US, "@"->NUL), neither of which is "c", so it emits NOTHING
|
||||||
|
// for Ctrl+Shift+C either way — this is not about an accidental
|
||||||
|
// interrupt byte reaching the PTY (verified live: it does not).
|
||||||
|
// Gating this whole block on hasSelection() (an earlier draft) meant
|
||||||
|
// that with no selection Ctrl+Shift+C skipped straight to `return
|
||||||
|
// true`, silently ceding the keystroke to the BROWSER's own handling
|
||||||
|
// (e.g. Chrome's Inspect-Element binding) with no feedback and no
|
||||||
|
// attempt to copy, unlike Pane A, which always intercepts it.
|
||||||
|
// Re-implemented against this.terminal rather than reusing
|
||||||
|
// app.copyTerminalSelection(), which reads app.terminal — Pane A's —
|
||||||
|
// and would copy the wrong pane's selection.
|
||||||
|
if (ev.type === 'keydown' && global.app?.shouldCopyTerminalSelectionFromShortcut?.(ev)) {
|
||||||
|
const raw = this.terminal?.getSelection?.() || '';
|
||||||
|
const isColumnSelection = this.terminal?._core?._selectionService?._activeSelectionMode === 3;
|
||||||
|
// Both clean options are read for THIS pane, never the primary one:
|
||||||
|
// the gutter width comes from this.sessionId's own run mode, and the
|
||||||
|
// partial-first-line flag from this terminal's own selection range.
|
||||||
|
// Passing neither left Pane B keeping a margin Pane A dropped, on the
|
||||||
|
// same split and the same keystroke.
|
||||||
|
const range = global.app?._normalisedSelectionRange?.(this.terminal);
|
||||||
|
const selection = isColumnSelection
|
||||||
|
? raw
|
||||||
|
: (global.CodemanCopySelection?.clean?.(raw, {
|
||||||
|
margin: global.app?._cliGutterColumns?.(this.sessionId) ?? 0,
|
||||||
|
firstLinePartial: !!range && range.start.x > 0,
|
||||||
|
}) ?? raw);
|
||||||
|
if (selection.trim()) {
|
||||||
|
ev.preventDefault();
|
||||||
|
void global.app._copyText?.(selection).then((ok) => {
|
||||||
|
this.terminal?.clearSelection?.();
|
||||||
|
global.app.showToast?.(ok ? 'Copied to clipboard' : 'Failed to copy', ok ? 'success' : 'error');
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Nothing worth copying — clear for feedback (a padding-only
|
||||||
|
// selection cleans to '' and this press still falls through to the
|
||||||
|
// PTY as 0x03, matching the primary pane's own rule).
|
||||||
|
if (this.terminal?.hasSelection?.()) {
|
||||||
|
this.terminal.clearSelection?.();
|
||||||
|
global.app.showToast?.('Nothing to copy', 'warning');
|
||||||
|
}
|
||||||
|
// Ctrl+Shift+C never falls through, even with nothing to copy —
|
||||||
|
// matches terminal-ui.js's own ev.shiftKey branch.
|
||||||
|
if (ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load existing scrollback before going live. The WS below is
|
||||||
|
// subscribe-only (ws-routes.ts sends nothing on connect, only future
|
||||||
|
// 'terminal' events), so without this Pane B stays blank until the
|
||||||
|
// target session happens to produce new output. It LOOKED
|
||||||
|
// intermittent rather than always-broken because _sendResize() below
|
||||||
|
// often nudges the shared session's real tmux window to a new size,
|
||||||
|
// and tmux repaints its current screen on resize — that repaint was
|
||||||
|
// getting captured and streamed here, incidentally populating the
|
||||||
|
// pane. When Pane B's computed dimensions happened to already match
|
||||||
|
// the session's last-known size, Session.resize() (session.ts) skips
|
||||||
|
// the resize as a no-op, no repaint fires, and the pane stayed blank.
|
||||||
|
// The await covers the whole chunked replay, not just the fetch, so a
|
||||||
|
// live frame from the socket below can never land in the middle of it.
|
||||||
|
await this._loadBuffer();
|
||||||
|
if (this._destroyed) return;
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
// The tab's own connection identity plus a `:tile` suffix. The server
|
||||||
|
// supersedes a socket that reuses a cid on the same session (4010), so a
|
||||||
|
// pane must never share the primary pane's exact cid: were both ever on
|
||||||
|
// one session they would evict each other in a loop. Input frames still
|
||||||
|
// carry the BARE clientId, which is what the server dedups on.
|
||||||
|
const app = global.app;
|
||||||
|
const cid = app?._clientId ? `${app._clientId}:${app._wsTabNonce}:tile` : '';
|
||||||
|
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
|
||||||
|
this._socketUrl = `${proto}//${location.host}${window.CodemanBase.base}/ws/sessions/${this.sessionId}/terminal${cidQuery}`;
|
||||||
|
this._openSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens a socket and makes it THE socket. A previous one is detached first
|
||||||
|
// (handlers nulled, then closed), and every handler below checks it still
|
||||||
|
// belongs to the current socket: a replacement opened while the old socket
|
||||||
|
// still looked alive (a half-open connection whose close has not landed)
|
||||||
|
// makes the server supersede the old one with a 4010, and that late close
|
||||||
|
// must not stop a pane that is already running on its successor.
|
||||||
|
_openSocket() {
|
||||||
|
if (this._destroyed || !this._socketUrl) return;
|
||||||
|
this._detachSocket();
|
||||||
|
const ws = new WebSocket(this._socketUrl);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (ws !== this.ws) return;
|
||||||
|
this._onSocketOpen();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (ws !== this.ws) return;
|
||||||
|
if (this._inputHandle) this._inputHandle.lastRecvAt = Date.now();
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.t === 'o') {
|
||||||
|
this._onLiveOutput(msg.d);
|
||||||
|
} else if (msg.t === 'c') {
|
||||||
|
this._onLiveClear();
|
||||||
|
} else if (msg.t === 'r') {
|
||||||
|
// Server-triggered refresh (SSE backpressure cleared, terminal
|
||||||
|
// data was dropped). The primary pane routes this to
|
||||||
|
// _onSessionNeedsRefresh (app.js) — Pane B has its own
|
||||||
|
// buffer loader for the same reason connect() does.
|
||||||
|
this._refreshBuffer();
|
||||||
|
} else if (msg.t === 'ia') {
|
||||||
|
// Input ACK. The frame names no session, so it is this pane's.
|
||||||
|
global.app?._onWsInputAck?.(msg.seq, msg, this.sessionId);
|
||||||
|
} else if (msg.t === 'zc') {
|
||||||
|
this._onPtyGeometryReport(msg.c, msg.r);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Malformed frame — ignore, matches primary pane's tolerance. */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// _wsReady must go false on a drop or fit()/_sendResize() silently
|
||||||
|
// no-op on a closed socket per the WebSocket spec (no exception, no log).
|
||||||
|
// Input is not lost meanwhile: it waits in the app's durable queue and
|
||||||
|
// goes out over HTTP or the next socket. The "disconnected" marker says
|
||||||
|
// so on screen, and a transient close reconnects (_onSocketClosed).
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
if (ws !== this.ws) return;
|
||||||
|
this._onSocketClosed(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
// onclose fires after onerror — cleanup happens there.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lets go of the current socket without running its close handling.
|
||||||
|
_detachSocket() {
|
||||||
|
const ws = this.ws;
|
||||||
|
if (!ws) return;
|
||||||
|
ws.onopen = null;
|
||||||
|
ws.onmessage = null;
|
||||||
|
// onclose fires asynchronously AFTER close(); without this it ran its
|
||||||
|
// "disconnected" write against a pane already torn down or replaced.
|
||||||
|
ws.onclose = null;
|
||||||
|
ws.onerror = null;
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
/* Already closed. */
|
||||||
|
}
|
||||||
|
this.ws = null;
|
||||||
|
this._wsReady = false;
|
||||||
|
this._unregisterInputSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
// A socket came up. After a drop this is a reconnect: the gap left nothing
|
||||||
|
// to replay (output frames carry no sequence number), so the buffer is
|
||||||
|
// refreshed. The closed state is reset FIRST, or the refresh would re-owe
|
||||||
|
// the "disconnected" marker (_refreshBuffer does on a closed socket) and
|
||||||
|
// stamp it under a healthy pane.
|
||||||
|
_onSocketOpen() {
|
||||||
|
const reconnected = this._wsClosed;
|
||||||
|
this._wsReady = true;
|
||||||
|
this._wsClosed = false;
|
||||||
|
this._markerOwed = false;
|
||||||
|
this._reconnectAttempts = 0;
|
||||||
|
this._lastSentDims = null;
|
||||||
|
this._registerInputSocket();
|
||||||
|
this._sendResize();
|
||||||
|
if (reconnected) this._refreshBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opens a replacement socket now instead of waiting out the backoff (for an
|
||||||
|
// owner that just learned the server is back). No-op while the current
|
||||||
|
// socket is open, after a permanent stop, or once destroyed.
|
||||||
|
reconnectNow() {
|
||||||
|
if (this._destroyed || this._stoppedCode !== null || !this._socketUrl) return;
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) return;
|
||||||
|
clearTimeout(this._reconnectTimer);
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._openSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The socket's close, split out of connect() so the tests can drive it.
|
||||||
|
// While any load runs (a history pull or a `{t:'r'}` refresh) the marker is
|
||||||
|
// only owed, and that load's finally block settles it (_stampMarkerIfOwed()):
|
||||||
|
// written now, it would sit above the output a pull is still holding (flushed
|
||||||
|
// after it on a skip, a downgrade or a failed fetch), above a refresh's
|
||||||
|
// replay, or in the middle of a chunked replay. A pull still waiting for its
|
||||||
|
// response holds the marker too, for as long as the request takes (up to its
|
||||||
|
// budget, see _pullHistory()).
|
||||||
|
//
|
||||||
|
// Then decides what comes next. Codes that cannot get better stop the pane
|
||||||
|
// for good and report once through `onExit(code)`: 4004/4009 (the session
|
||||||
|
// is gone), 4003 (refused: Host/Origin/owner, a retry gets the same answer)
|
||||||
|
// and 4010 (another socket with this pane's cid took over; only ever
|
||||||
|
// reaches here for the CURRENT socket, see _openSocket). Everything else,
|
||||||
|
// including the redelivery sweep force-closing a silent socket (1005), is
|
||||||
|
// transient and reconnects on the primary pane's backoff ladder
|
||||||
|
// (CodemanWsReconnect, constants.js) plus jitter.
|
||||||
|
_onSocketClosed(event) {
|
||||||
|
this._wsReady = false;
|
||||||
|
this._wsClosed = true;
|
||||||
|
this._unregisterInputSocket();
|
||||||
|
const code = event?.code;
|
||||||
|
const permanent = TerminalTile.STOP_MARKERS[code];
|
||||||
|
this._markerText = permanent || TerminalTile.MARKER_RECONNECTING;
|
||||||
|
if (this._bufferLoading) this._markerOwed = true;
|
||||||
|
else this._writeDisconnectedMarker();
|
||||||
|
if (this._destroyed) return;
|
||||||
|
if (permanent) {
|
||||||
|
this._stop(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._scheduleReconnect(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
_scheduleReconnect(code) {
|
||||||
|
if (this._destroyed || !this._socketUrl || this._reconnectTimer) return;
|
||||||
|
const plan = global.CodemanWsReconnect?.plan?.(code ?? 1006, this._reconnectAttempts) || {
|
||||||
|
action: 'reconnect',
|
||||||
|
delayMs: 1000,
|
||||||
|
};
|
||||||
|
if (plan.action === 'give-up') {
|
||||||
|
this._stop(code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._reconnectAttempts++;
|
||||||
|
const delay = plan.delayMs + Math.floor(Math.random() * 250); // jitter: tiles must not reconnect in lockstep
|
||||||
|
this._reconnectTimer = setTimeout(() => {
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._openSocket();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
_stop(code) {
|
||||||
|
if (this._stoppedCode !== null) return;
|
||||||
|
this._stoppedCode = code ?? null;
|
||||||
|
clearTimeout(this._reconnectTimer);
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
if (!this._destroyed) this.onExit?.(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keystrokes and pastes go through the app's exactly-once input queue (seq,
|
||||||
|
// ACK, persisted until delivered, redelivered after a drop), over this
|
||||||
|
// pane's own socket while it is open and the HTTP fallback while it is not.
|
||||||
|
// What xterm GENERATES must not be queued: a query reply (DA/CPR/OSC) is
|
||||||
|
// dropped, as the primary pane drops it, because forwarding it types
|
||||||
|
// "0;276;0c" into the CLI, and replaying one after a reload would do so
|
||||||
|
// again into a later screen. A focus or mouse report is real input the
|
||||||
|
// program asked for, but nobody typed it: it goes out once, never
|
||||||
|
// persisted. Same predicates as the primary pane (terminal-ui.js onData).
|
||||||
|
_onTerminalData(data) {
|
||||||
|
const input = global.CodemanTerminalInput;
|
||||||
|
if (input?.shouldSuppressTerminalQueryResponse?.(data)) return;
|
||||||
|
const app = global.app;
|
||||||
|
if (input?.isTerminalFocusOrMouseReport?.(data)) {
|
||||||
|
app?._sendInputEphemeral?.(this.sessionId, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app?._sendInputAsync?.(this.sessionId, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joins the app's input-socket map for this session and flushes anything
|
||||||
|
// already queued for it (typed while the socket was down, or left over from
|
||||||
|
// a reload) over the fresh socket. Called from onopen.
|
||||||
|
_registerInputSocket() {
|
||||||
|
const app = global.app;
|
||||||
|
if (!this.ws || !app?._registerInputSocket) return;
|
||||||
|
this._unregisterInputSocket();
|
||||||
|
this._inputHandle = { ws: this.ws, lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket(this.sessionId, this._inputHandle);
|
||||||
|
app._onWsReady?.(this.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaves the map; only this pane's own handle is removed (a replacement
|
||||||
|
// socket's registration survives a late close of the old one).
|
||||||
|
_unregisterInputSocket() {
|
||||||
|
if (!this._inputHandle) return;
|
||||||
|
global.app?._unregisterInputSocket?.(this.sessionId, this._inputHandle);
|
||||||
|
this._inputHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settles a marker the pane owes: set when a close lands during a load (the
|
||||||
|
// replay would otherwise sit below it) or when a load wipes the terminal on
|
||||||
|
// a closed socket. Called from each load's own finally, just before
|
||||||
|
// _endBufferLoad() starts any trailing refresh.
|
||||||
|
_stampMarkerIfOwed() {
|
||||||
|
// A trailing refresh is about to clear() synchronously, while xterm parses
|
||||||
|
// a write() on a later tick: a marker written here would land in the
|
||||||
|
// freshly cleared buffer ABOVE that refresh's replay, a second, stale copy.
|
||||||
|
// The refresh re-owes the marker on a closed socket and stamps it itself.
|
||||||
|
if (this._bufferRefreshPending && !this._destroyed) return;
|
||||||
|
const owed = this._markerOwed;
|
||||||
|
this._markerOwed = false;
|
||||||
|
if (owed && this._wsClosed && !this._destroyed) this._writeDisconnectedMarker();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracted so both _onSocketClosed() and a load that ends owing it on a
|
||||||
|
// closed socket can write it (see _stampMarkerIfOwed()).
|
||||||
|
_writeDisconnectedMarker() {
|
||||||
|
this.terminal?.write(`\r\n\x1b[2m${this._markerText}\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches and writes the session's current scrollback. Used both by
|
||||||
|
// connect() (initial load) and by the `{t:'r'}` server-refresh frame
|
||||||
|
// (below) — the primary pane's own _onSessionNeedsRefresh (app.js) is
|
||||||
|
// scoped to `this.activeSessionId` and clears/rewrites the primary
|
||||||
|
// terminal, neither of which applies to this independent pane, so this is
|
||||||
|
// a standalone equivalent rather than a call into it.
|
||||||
|
//
|
||||||
|
// Mirrors the primary pane's own mode check (app.js's selectSession /
|
||||||
|
// _onSessionNeedsRefresh): a shell session can retain hundreds of
|
||||||
|
// thousands of plain scrollback lines, so pulling `?full=1` there parses
|
||||||
|
// an unbounded, server-capped (up to terminalBufferMaxBytes, 32MB) body
|
||||||
|
// into a 50000-line xterm on every load. Non-shell (TUI) sessions still
|
||||||
|
// get one full replay. `fetch` here goes through the global wrapper
|
||||||
|
// (constants.js), which already prefixes CodemanBase — unlike the raw
|
||||||
|
// WebSocket URL above, which does not.
|
||||||
|
//
|
||||||
|
// Single-flight: the flag is held across the fetch AND the chunked write
|
||||||
|
// (writeChunked resolves after its last chunk), so two replays can never
|
||||||
|
// interleave their chunks into one terminal. A second call while one is
|
||||||
|
// in flight is dropped here; _refreshBuffer() is the caller that queues
|
||||||
|
// a trailing re-run instead.
|
||||||
|
async _loadBuffer() {
|
||||||
|
if (this._bufferLoading) return;
|
||||||
|
this._bufferLoading = true;
|
||||||
|
try {
|
||||||
|
const query = this.sessionMode === 'shell' ? `tail=${TERMINAL_TAIL_SIZE}` : 'full=1';
|
||||||
|
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?${query}`);
|
||||||
|
const payload = (await res.json())?.data ?? {};
|
||||||
|
if (payload.terminalBuffer && this.terminal) {
|
||||||
|
await writeChunked(this.terminal, payload.terminalBuffer, () => this._destroyed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Best-effort — live output still arrives once the socket connects. */
|
||||||
|
} finally {
|
||||||
|
this._stampMarkerIfOwed();
|
||||||
|
this._endBufferLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ends a single-flight load (initial, refresh or history pull): clears the
|
||||||
|
// flag, then runs the ONE trailing refresh that arrived while it was busy.
|
||||||
|
_endBufferLoad() {
|
||||||
|
this._bufferLoading = false;
|
||||||
|
if (this._bufferRefreshPending && !this._destroyed) {
|
||||||
|
this._bufferRefreshPending = false;
|
||||||
|
this._refreshBuffer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live terminal output. Written straight through, except while a history
|
||||||
|
// pull is replaying: a capture is current only up to the instant tmux took
|
||||||
|
// it, so a frame arriving mid-replay is held with its arrival time and
|
||||||
|
// replayed behind the snapshot by _pullHistory() (the primary pane's
|
||||||
|
// _finishBufferLoad `since` rule), never written underneath it.
|
||||||
|
_onLiveOutput(data) {
|
||||||
|
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), data });
|
||||||
|
else this.terminal?.write(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server's `{t:'c'}` clear frame takes the same route as output, for the
|
||||||
|
// same reason: clearing straight away, mid-replay, would wipe the half-written
|
||||||
|
// snapshot and leave _pullHistory() measuring a buffer that is no longer the
|
||||||
|
// one it is restoring. Queued, it lands in order with the frames around it.
|
||||||
|
_onLiveClear() {
|
||||||
|
if (this._liveQueue) this._liveQueue.push({ at: performance.now(), clear: true });
|
||||||
|
else this.terminal?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture phase, because xterm's own wheel handler stopPropagation()s every
|
||||||
|
// event it consumes, so a bubbling listener here would never see the wheel
|
||||||
|
// while the pane still has scrollback to scroll. Passive: this only observes,
|
||||||
|
// xterm keeps doing the scrolling.
|
||||||
|
_installWheelListener() {
|
||||||
|
this._onWheel = (ev) => {
|
||||||
|
if (ev.deltaY < 0) this._maybeLoadMoreHistory();
|
||||||
|
};
|
||||||
|
this.mountEl.addEventListener('wheel', this._onWheel, { capture: true, passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel-up at the top of a SHELL pane's scrollback. tmux repaints a burst of
|
||||||
|
// output (`cat` of a file longer than the screen) instead of scrolling it,
|
||||||
|
// so this pane's xterm ends up with about one screen of scrollback while
|
||||||
|
// tmux holds every line — and nothing here ever went back to ask, so the
|
||||||
|
// history was unreachable. The primary pane has the same pull
|
||||||
|
// (app.js _maybeRefetchFullHistory); Pane B is a separate xterm and needs its
|
||||||
|
// own. Shell only: a non-shell CLI's history is out of scope for this pull
|
||||||
|
// (its load already takes `full=1`; codex and Claude's inline renderer do
|
||||||
|
// grow tmux history, this just isn't how they recover it). The alternate-
|
||||||
|
// screen skip (nano, vim, less) only matters for a direct-PTY shell — under
|
||||||
|
// tmux the browser xterm never enters the alternate buffer.
|
||||||
|
_maybeLoadMoreHistory() {
|
||||||
|
if (this.sessionMode !== 'shell' || this._destroyed || !this.terminal) return;
|
||||||
|
if (this._bufferLoading) return;
|
||||||
|
// Mirrors app.js _maybeRefetchFullHistory and this pane's own
|
||||||
|
// _sendResize(): a detached session's own window already owns its PTY
|
||||||
|
// size and scrollback, so Pane B has nothing of its own to reconcile.
|
||||||
|
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||||
|
const active = this.terminal.buffer.active;
|
||||||
|
if (active.type !== 'normal' || active.viewportY !== 0) return;
|
||||||
|
// Momentum scrolling fires this dozens of times per flick, so cooldown
|
||||||
|
// rather than latch; a pull that could only have downgraded the pane
|
||||||
|
// waits far longer.
|
||||||
|
const cooldown = this._historyPullUseless ? 60000 : 4000;
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this._historyPullAt < cooldown) return;
|
||||||
|
this._historyPullAt = now;
|
||||||
|
void this._pullHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulls a BOUNDED window of tmux's full history (the same TERMINAL_TAIL_SIZE
|
||||||
|
// a tab switch loads, so a multi-megabyte capture never lands on xterm's
|
||||||
|
// main thread) and replays it under the reader's current place. Holds the
|
||||||
|
// single-flight flag across the fetch AND the replay, like _loadBuffer().
|
||||||
|
async _pullHistory() {
|
||||||
|
this._bufferLoading = true;
|
||||||
|
let replayed = false;
|
||||||
|
let capturedAt = 0;
|
||||||
|
// Two budgets on one signal. The request itself gets the primary pane's
|
||||||
|
// (CodemanFetchDeadline, constants.js): live output is not held while it
|
||||||
|
// runs, but the single-flight flag is, so a coalesced `{t:'r'}` refresh and
|
||||||
|
// the marker owed by a close (_onSocketClosed()) both wait for it, at worst
|
||||||
|
// for that whole budget. Once the headers land live output IS held, so the
|
||||||
|
// body read gets the short one instead: a body that hangs would otherwise
|
||||||
|
// freeze the pane for the long budget. Aborting lands in the catch below,
|
||||||
|
// which releases the flag and the queue. AbortSignal.timeout() alone cannot
|
||||||
|
// be re-armed, hence the controller; without AbortController the pull
|
||||||
|
// simply has no deadline.
|
||||||
|
const controller = global.AbortController ? new global.AbortController() : null;
|
||||||
|
let abortTimer = null;
|
||||||
|
const armDeadline = (ms) => {
|
||||||
|
if (!controller) return;
|
||||||
|
clearTimeout(abortTimer);
|
||||||
|
abortTimer = setTimeout(() => controller.abort(), ms);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
armDeadline(global.CodemanFetchDeadline?.terminalFetchDeadlineMs?.({ full: true }) ?? HISTORY_PULL_TIMEOUT_MS);
|
||||||
|
const res = await fetch(`/api/sessions/${this.sessionId}/terminal?full=1&tail=${TERMINAL_TAIL_SIZE}`, {
|
||||||
|
signal: controller?.signal,
|
||||||
|
});
|
||||||
|
armDeadline(HISTORY_PULL_TIMEOUT_MS);
|
||||||
|
// The cutoff below is the response's arrival, the same `since` rule the
|
||||||
|
// primary pane uses (_finishBufferLoad). It is a client clock standing in
|
||||||
|
// for the instant tmux took the capture, which lies somewhere in the
|
||||||
|
// round trip, so a frame in that window can be lost or doubled. Bounded
|
||||||
|
// by one round trip and not closable without a server-side capture time.
|
||||||
|
capturedAt = performance.now();
|
||||||
|
// Opened only now: a frame from before the response is either replaced by
|
||||||
|
// the capture or written unchanged, so holding it for the round trip
|
||||||
|
// bought nothing and froze the pane for as long as the fetch took.
|
||||||
|
this._liveQueue = [];
|
||||||
|
const payload = (await res.json())?.data;
|
||||||
|
clearTimeout(abortTimer);
|
||||||
|
const buffer = payload?.terminalBuffer;
|
||||||
|
const term = this.terminal;
|
||||||
|
if (!buffer || !term || this._destroyed) return;
|
||||||
|
const rowsBefore = term.buffer.active.length;
|
||||||
|
const rowsIncoming = global.app?._estimateReplayRows?.(buffer, term.cols) ?? buffer.split('\n').length;
|
||||||
|
// xterm keeps at most `scrollback + rows` rows while tmux keeps far more
|
||||||
|
// lines, so a window of short lines can carry more rows than this pane
|
||||||
|
// can ever hold, and `rowsIncoming <= rowsBefore` would never come true.
|
||||||
|
const scrollbackCap = term.options?.scrollback || 0;
|
||||||
|
const paneFull = scrollbackCap > 0 && rowsBefore >= scrollbackCap + term.rows;
|
||||||
|
// Nothing to gain (this also covers a downgrade, which would delete
|
||||||
|
// history mid-scroll), and a reset+rewrite would jump the viewport. An
|
||||||
|
// untruncated window IS all of tmux's history and the next burst can add
|
||||||
|
// more, so keep the 4 s cooldown. A truncated window can never reach past
|
||||||
|
// what the pane shows, and every ask costs the server a capture-pane of
|
||||||
|
// the whole history (`tail` is cut after it): back off to 60 s, as the
|
||||||
|
// primary pane does (app.js _maybeRefetchFullHistory). A full pane backs
|
||||||
|
// off too, since no window can ever fit in it.
|
||||||
|
if (rowsIncoming <= rowsBefore || paneFull) {
|
||||||
|
if (payload.truncated || paneFull) this._historyPullUseless = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._historyPullUseless = false;
|
||||||
|
term.write('\x1bc');
|
||||||
|
replayed = true;
|
||||||
|
if (this._wsClosed) this._markerOwed = true;
|
||||||
|
await writeChunked(term, buffer, () => this._destroyed);
|
||||||
|
if (this._destroyed || !this.terminal) return;
|
||||||
|
// xterm parses asynchronously: an empty write's callback fires only
|
||||||
|
// after everything before it, so the row count below is the settled one.
|
||||||
|
await new Promise((resolve) => this.terminal.write('', resolve));
|
||||||
|
if (this._destroyed || !this.terminal) return;
|
||||||
|
// The replay grew the buffer UPWARD, so what was row 0 is now `delta`
|
||||||
|
// rows down; land there and the recovered history sits above it.
|
||||||
|
const delta = this.terminal.buffer.active.length - rowsBefore;
|
||||||
|
if (delta > 0) this.terminal.scrollToLine(delta);
|
||||||
|
else this.terminal.scrollToTop();
|
||||||
|
} catch {
|
||||||
|
/* Best-effort — live output keeps arriving whatever happens here. */
|
||||||
|
} finally {
|
||||||
|
clearTimeout(abortTimer);
|
||||||
|
const queued = this._liveQueue ?? [];
|
||||||
|
this._liveQueue = null;
|
||||||
|
// After a replay, only frames that arrived after the capture are news;
|
||||||
|
// earlier ones are already in it. With no replay, every held frame is.
|
||||||
|
const cutoff = replayed ? capturedAt : 0;
|
||||||
|
for (const entry of queued) {
|
||||||
|
if (entry.at < cutoff) continue;
|
||||||
|
if (entry.clear) this.terminal?.clear();
|
||||||
|
else this.terminal?.write(entry.data);
|
||||||
|
}
|
||||||
|
// Settled after the queue flush so the marker is the last thing on
|
||||||
|
// screen: a close during the pull wrote nothing (_onSocketClosed() defers
|
||||||
|
// it while a load runs), and a replay's own `\x1bc` (flagged above) wipes
|
||||||
|
// one written before it, which would paint a fresh, current-looking
|
||||||
|
// history while onData keeps silently dropping every keystroke on the
|
||||||
|
// dead socket. With a trailing refresh pending (_endBufferLoad) the marker
|
||||||
|
// is left to that refresh, which writes it below its own replay.
|
||||||
|
this._stampMarkerIfOwed();
|
||||||
|
this._endBufferLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `{t:'r'}` server-refresh path: clear, then replay. Two refresh
|
||||||
|
// frames in a row used to start two concurrent replays, each clearing
|
||||||
|
// the terminal under the other's chunked write. A refresh that arrives
|
||||||
|
// mid-replay is COALESCED into one trailing re-run rather than ignored:
|
||||||
|
// the in-flight fetch may predate the drop the new frame is reporting,
|
||||||
|
// and no further frame is coming to correct stale content.
|
||||||
|
_refreshBuffer() {
|
||||||
|
if (this._bufferLoading) {
|
||||||
|
this._bufferRefreshPending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.terminal?.clear();
|
||||||
|
// The clear wipes a "disconnected" marker (a `{t:'r'}` frame can queue a
|
||||||
|
// trailing refresh behind a pull that the socket's close then interrupts),
|
||||||
|
// so a refresh on a closed socket owes it back once its replay is written.
|
||||||
|
if (this._wsClosed) this._markerOwed = true;
|
||||||
|
void this._loadBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local reflow only — no PTY resize frame. Split out so a divider drag
|
||||||
|
// can reflow both panes at the browser's paint rate (rAF) while sending
|
||||||
|
// the actual `{t:'z'}` resize once, at drag end, matching the primary
|
||||||
|
// pane's own convention (throttledResize in terminal-ui.js).
|
||||||
|
localFit() {
|
||||||
|
if (!this.fitAddon) return;
|
||||||
|
this.fitAddon.fit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflow to the container and tell the PTY, as one step: the xterm and the
|
||||||
|
// PTY must never disagree about size (#464), and a font change is a size
|
||||||
|
// change too, so the font setters call this rather than localFit().
|
||||||
|
// `force` resends an unchanged size.
|
||||||
|
fit({ force = false } = {}) {
|
||||||
|
this.localFit();
|
||||||
|
this._sendResize({ force });
|
||||||
|
}
|
||||||
|
|
||||||
|
_sendResize({ force = false } = {}) {
|
||||||
|
if (!this._wsReady || !this.fitAddon || !this.terminal) return;
|
||||||
|
// One PTY cannot hold two sizes (mirrors sendResize's own
|
||||||
|
// detachedElsewhere yield in terminal-ui.js): the session got detached
|
||||||
|
// to its own window AFTER this split was opened, so its own window now
|
||||||
|
// owns the PTY's size and Pane B must stand aside.
|
||||||
|
if (this.detachedSessions?.has(this.sessionId)) return;
|
||||||
|
// A hidden pane (a web tab over it, a zoomed neighbour) measures NaN, and
|
||||||
|
// fit() then leaves the xterm alone: there is no size worth reporting.
|
||||||
|
const dims = this.fitAddon.proposeDimensions();
|
||||||
|
if (!dims || !Number.isFinite(dims.cols) || !Number.isFinite(dims.rows)) return;
|
||||||
|
// Report what the xterm actually holds, so the PTY gets exactly the size
|
||||||
|
// the pane renders at. Unclamped, unlike the primary pane's 40x10 floor:
|
||||||
|
// a floor here misreported Pane B's width at the divider's reachable 20%
|
||||||
|
// position (about 28 columns), causing real output-wrapping bugs, and a
|
||||||
|
// floored xterm would be wider than its container. The server enforces
|
||||||
|
// its own valid range ([1,500]/[1,200] in ws-routes.ts).
|
||||||
|
const cols = this.terminal.cols;
|
||||||
|
const rows = this.terminal.rows;
|
||||||
|
const last = this._lastSentDims;
|
||||||
|
if (!force && last && last.cols === cols && last.rows === rows) return;
|
||||||
|
this._lastSentDims = { cols, rows };
|
||||||
|
this.ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: 'desktop' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The geometry the PTY actually holds (`{t:'zc'}`, the server's answer to
|
||||||
|
// every resize). A PTY and a terminal that disagree on WIDTH render
|
||||||
|
// garbled, so a different column count is adopted; rows stay local, as in
|
||||||
|
// the primary pane (_onPtyGeometryReport in terminal-ui.js, #464). The
|
||||||
|
// pure verdict is the primary's too (reconcilePtyGeometry, constants.js).
|
||||||
|
_onPtyGeometryReport(cols, rows) {
|
||||||
|
const terminal = this.terminal;
|
||||||
|
if (!terminal) return;
|
||||||
|
const verdict = global.CodemanTerminalGeometry?.reconcilePtyGeometry?.(
|
||||||
|
{ cols: terminal.cols, rows: terminal.rows },
|
||||||
|
{ cols, rows }
|
||||||
|
);
|
||||||
|
if (!verdict?.adopt) return;
|
||||||
|
terminal.resize(verdict.cols, terminal.rows);
|
||||||
|
this._lastSentDims = { cols: verdict.cols, rows: terminal.rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._destroyed = true;
|
||||||
|
// Anything still queued for this session stays in the app's queue and is
|
||||||
|
// delivered over HTTP by the redelivery sweep, so closing the pane mid-
|
||||||
|
// keystroke loses nothing.
|
||||||
|
clearTimeout(this._reconnectTimer);
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
if (this._onWheel) {
|
||||||
|
this.mountEl?.removeEventListener('wheel', this._onWheel, { capture: true });
|
||||||
|
this._onWheel = null;
|
||||||
|
}
|
||||||
|
this._detachSocket();
|
||||||
|
if (this._onFocusIn) {
|
||||||
|
this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn);
|
||||||
|
this._onFocusIn = null;
|
||||||
|
}
|
||||||
|
// A destroyed pane cannot hold the keyboard: shortcuts fall back to the
|
||||||
|
// primary terminal (_focusedPane also skips a destroyed tile on its own).
|
||||||
|
if (global.app?._focusedTile === this) global.app._noteFocusedTile?.(null);
|
||||||
|
if (this.terminal) {
|
||||||
|
this.terminal.dispose();
|
||||||
|
this.terminal = null;
|
||||||
|
}
|
||||||
|
this.fitAddon = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The marker a pane writes when its socket drops: a transient drop says it is
|
||||||
|
// reconnecting; a permanent stop says why, keyed by close code. All start
|
||||||
|
// with `[disconnected` so a reader (and a test) can tell any of them apart
|
||||||
|
// from session output.
|
||||||
|
TerminalTile.MARKER_RECONNECTING = '[disconnected, reconnecting…]';
|
||||||
|
TerminalTile.STOP_MARKERS = {
|
||||||
|
4003: '[disconnected: the server refused this connection]',
|
||||||
|
4004: '[disconnected: the session ended]',
|
||||||
|
4009: '[disconnected: the session ended]',
|
||||||
|
4010: '[disconnected: another connection took over this pane]',
|
||||||
|
};
|
||||||
|
|
||||||
|
global.TerminalTile = TerminalTile;
|
||||||
|
})(window);
|
||||||
+113
-42
@@ -544,6 +544,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._installMobileTapMouseGuard();
|
this._installMobileTapMouseGuard();
|
||||||
this._installShiftDragSelection();
|
this._installShiftDragSelection();
|
||||||
this._installTouchSelectionFocusGuard();
|
this._installTouchSelectionFocusGuard();
|
||||||
|
// Focus coming back to the primary terminal ends a second pane's claim on
|
||||||
|
// the keyboard (see _focusedPane).
|
||||||
|
this.terminal.textarea?.addEventListener('focus', () => this._noteFocusedTile(null));
|
||||||
|
|
||||||
// Let xterm's CompositionHelper own IME key events. In particular, a
|
// Let xterm's CompositionHelper own IME key events. In particular, a
|
||||||
// non-composing keyCode 229 is how an active IME commits numbers and
|
// non-composing keyCode 229 is how an active IME commits numbers and
|
||||||
@@ -1416,7 +1419,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// without this call Pane B never learned about a window resize, an
|
// without this call Pane B never learned about a window resize, an
|
||||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||||
// stayed at whatever size it was last dragged to.
|
// stayed at whatever size it was last dragged to.
|
||||||
this._splitPane?.fit();
|
this._forEachTile?.((tile) => tile.fit());
|
||||||
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
}, 300); // Trailing-edge: only fire after 300ms of no resize events
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1827,9 +1830,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Register a custom link provider for xterm.js that detects file paths
|
* Register a custom link provider for xterm.js that detects file paths
|
||||||
* in terminal output and makes them clickable.
|
* in terminal output and makes them clickable.
|
||||||
* When clicked, opens a floating log viewer window with live streaming.
|
* When clicked, opens a floating log viewer window with live streaming.
|
||||||
|
*
|
||||||
|
* `target` defaults to the primary terminal and the active session. A second
|
||||||
|
* terminal (the split pane) passes its own `{ terminal, getSessionId,
|
||||||
|
* setHovered }`, so a path printed there opens against THAT pane's session and
|
||||||
|
* hovering it never flips the primary pane's `_linkHovered`. Only the primary
|
||||||
|
* registration is kept on `_terminalLinkProvider`, which the touch path reads.
|
||||||
|
* Returns the provider.
|
||||||
*/
|
*/
|
||||||
registerFilePathLinkProvider() {
|
registerFilePathLinkProvider(target = {}) {
|
||||||
const self = this;
|
const self = this;
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const getSessionId = target.getSessionId || (() => this.activeSessionId);
|
||||||
|
const setHovered =
|
||||||
|
target.setHovered ||
|
||||||
|
((hovered) => {
|
||||||
|
this._linkHovered = hovered;
|
||||||
|
});
|
||||||
|
const isPrimary = terminal === this.terminal;
|
||||||
|
|
||||||
// Debug: Track if provider is being invoked
|
// Debug: Track if provider is being invoked
|
||||||
let lastInvokedLine = -1;
|
let lastInvokedLine = -1;
|
||||||
@@ -1842,7 +1860,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
console.debug('[LinkProvider] Checking line:', bufferLineNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer = self.terminal.buffer.active;
|
const buffer = terminal.buffer.active;
|
||||||
// provideLinks passes 1-based line number, getLine expects 0-based
|
// provideLinks passes 1-based line number, getLine expects 0-based
|
||||||
const line = buffer.getLine(bufferLineNumber - 1);
|
const line = buffer.getLine(bufferLineNumber - 1);
|
||||||
|
|
||||||
@@ -1866,7 +1884,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||||
buffer,
|
buffer,
|
||||||
bufferLineNumber - 1,
|
bufferLineNumber - 1,
|
||||||
self.terminal.cols,
|
terminal.cols,
|
||||||
MAX_STITCHED_ROWS
|
MAX_STITCHED_ROWS
|
||||||
);
|
);
|
||||||
if (!logical) {
|
if (!logical) {
|
||||||
@@ -1919,10 +1937,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
window.open(text, '_blank', 'noopener,noreferrer');
|
window.open(text, '_blank', 'noopener,noreferrer');
|
||||||
},
|
},
|
||||||
hover() {
|
hover() {
|
||||||
self._linkHovered = true;
|
setHovered(true);
|
||||||
},
|
},
|
||||||
leave() {
|
leave() {
|
||||||
self._linkHovered = false;
|
setHovered(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1978,17 +1996,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// path clicked in the response viewer previewed fine. The preview
|
// path clicked in the response viewer previewed fine. The preview
|
||||||
// reads those through the guarded attachment routes, so external
|
// reads those through the guarded attachment routes, so external
|
||||||
// paths route there and the two surfaces agree.
|
// paths route there and the two surfaces agree.
|
||||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
const sessionId = getSessionId();
|
||||||
self.openFilePreview(text, self.activeSessionId);
|
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||||
|
self.openFilePreview(text, sessionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.openLogViewerWindow(text, self.activeSessionId);
|
self.openLogViewerWindow(text, sessionId);
|
||||||
},
|
},
|
||||||
hover() {
|
hover() {
|
||||||
self._linkHovered = true;
|
setHovered(true);
|
||||||
},
|
},
|
||||||
leave() {
|
leave() {
|
||||||
self._linkHovered = false;
|
setHovered(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2031,10 +2050,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// produce), so the tap path asks this SAME provider what is under the finger
|
// produce), so the tap path asks this SAME provider what is under the finger
|
||||||
// rather than growing a second, driftable copy of the patterns.
|
// rather than growing a second, driftable copy of the patterns.
|
||||||
// See _terminalLinkAtPoint.
|
// See _terminalLinkAtPoint.
|
||||||
this._terminalLinkProvider = provider;
|
if (isPrimary) this._terminalLinkProvider = provider;
|
||||||
this.terminal.registerLinkProvider(provider);
|
terminal.registerLinkProvider(provider);
|
||||||
|
|
||||||
console.log('[LinkProvider] File path link provider registered');
|
console.log('[LinkProvider] File path link provider registered');
|
||||||
|
return provider;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4480,8 +4500,45 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Terminal Controls
|
// Terminal Controls
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal the keyboard is in, as `{ terminal, sessionId, isPrimary, tile }`.
|
||||||
|
*
|
||||||
|
* The ONE place a shortcut, voice or paste should ask "which pane?", rather
|
||||||
|
* than reading `this.terminal` / `this.activeSessionId`, which always mean the
|
||||||
|
* primary pane. It answers with the pane whose terminal was focused LAST, not
|
||||||
|
* with `document.activeElement`: clicking the mic or a header button moves
|
||||||
|
* DOM focus to that button, and the dictation it starts still belongs to the
|
||||||
|
* pane the user was typing in. A second pane (the split pane's Pane B) claims
|
||||||
|
* it from its own terminal's focus; the primary terminal's focus gives it back.
|
||||||
|
*/
|
||||||
|
_focusedPane() {
|
||||||
|
const tile = this._focusedTile;
|
||||||
|
if (tile && !tile._destroyed && tile.terminal) {
|
||||||
|
return { terminal: tile.terminal, sessionId: tile.sessionId, isPrimary: false, tile };
|
||||||
|
}
|
||||||
|
return { terminal: this.terminal, sessionId: this.activeSessionId, isPrimary: true, tile: null };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Record which second pane holds the keyboard (null: the primary terminal). */
|
||||||
|
_noteFocusedTile(tile) {
|
||||||
|
this._focusedTile = tile || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `fn(tile)` for every secondary terminal pane on screen: today the split
|
||||||
|
* pane's second terminal, when one is open. Font, weight, family and skin
|
||||||
|
* changes go through here so they reach every pane without a special case
|
||||||
|
* per pane kind. Agent Teams terminals size themselves and are not tiles.
|
||||||
|
*/
|
||||||
|
_forEachTile(fn) {
|
||||||
|
if (this._splitPane?.terminal) fn(this._splitPane);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Clears the pane the keyboard is in. The chord itself also reaches that
|
||||||
|
// pane's xterm (the capture handler only preventDefault()s), so the ^L lands
|
||||||
|
// in the same pane whose display is cleared, never a different one.
|
||||||
clearTerminal() {
|
clearTerminal() {
|
||||||
this.terminal.clear();
|
this._focusedPane().terminal?.clear();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Insert editable text at the active prompt without pressing Enter. */
|
/** Insert editable text at the active prompt without pressing Enter. */
|
||||||
@@ -4545,6 +4602,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
* Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation").
|
||||||
*/
|
*/
|
||||||
async restoreTerminalSize() {
|
async restoreTerminalSize() {
|
||||||
|
// A second pane owns its own geometry: refit it and force its PTY to the
|
||||||
|
// size it renders at (TerminalTile.fit), whatever another device set.
|
||||||
|
const pane = this._focusedPane();
|
||||||
|
if (!pane.isPrimary) {
|
||||||
|
pane.tile.fit({ force: true });
|
||||||
|
this.showToast(`Terminal restored to ${pane.terminal.cols}x${pane.terminal.rows}`, 'success');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.activeSessionId) {
|
if (!this.activeSessionId) {
|
||||||
this.showToast('No active session', 'warning');
|
this.showToast('No active session', 'warning');
|
||||||
return;
|
return;
|
||||||
@@ -4639,15 +4704,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
||||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||||
*/
|
*/
|
||||||
cleanedTerminalSelection(text) {
|
cleanedTerminalSelection(text, target = {}) {
|
||||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
// `target` names a second terminal (the split pane) and its session; both
|
||||||
|
// default to the primary pane, whose `this.terminal` this file otherwise reads.
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const raw = text ?? (terminal?.hasSelection?.() ? terminal.getSelection() : '');
|
||||||
if (!raw) return '';
|
if (!raw) return '';
|
||||||
if (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||||
const clean = window.CodemanCopySelection?.clean;
|
const clean = window.CodemanCopySelection?.clean;
|
||||||
if (!clean) return raw;
|
if (!clean) return raw;
|
||||||
const range = this._normalisedSelectionRange();
|
const range = this._normalisedSelectionRange(terminal);
|
||||||
return clean(raw, {
|
return clean(raw, {
|
||||||
margin: this._cliGutterColumns(),
|
margin: this._cliGutterColumns(target.sessionId),
|
||||||
firstLinePartial: !!range && range.start.x > 0,
|
firstLinePartial: !!range && range.start.x > 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -4717,8 +4785,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||||
async copyTerminalSelection(text) {
|
async copyTerminalSelection(text, target = {}) {
|
||||||
const selection = this.cleanedTerminalSelection(text);
|
// Every terminal touched below is the TARGET one: clearing or refocusing the
|
||||||
|
// primary after copying from the split pane would hit the wrong pane.
|
||||||
|
const terminal = target.terminal || this.terminal;
|
||||||
|
const selection = this.cleanedTerminalSelection(text, target);
|
||||||
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
// trim(), not emptiness: a multi-row drag across padding cleans to newlines
|
||||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||||
// or a shell submits the line. decideAutoCopy applies the same rule.
|
// or a shell submits the line. decideAutoCopy applies the same rule.
|
||||||
@@ -4727,7 +4798,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// selection, so a padding-only selection left set can no longer swallow a
|
// selection, so a padding-only selection left set can no longer swallow a
|
||||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||||
// clear avoids is a highlight that sits there having copied nothing.
|
// clear avoids is a highlight that sits there having copied nothing.
|
||||||
this.terminal?.clearSelection?.();
|
terminal?.clearSelection?.();
|
||||||
this.showToast('Nothing to copy', 'warning');
|
this.showToast('Nothing to copy', 'warning');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -4735,14 +4806,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (ok) {
|
if (ok) {
|
||||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||||
// drops the selection on any keypress, so this matches existing feel).
|
// drops the selection on any keypress, so this matches existing feel).
|
||||||
this.terminal.clearSelection?.();
|
terminal.clearSelection?.();
|
||||||
this.showToast('Copied to clipboard', 'success');
|
this.showToast('Copied to clipboard', 'success');
|
||||||
} else {
|
} else {
|
||||||
this.showToast('Failed to copy', 'error');
|
this.showToast('Failed to copy', 'error');
|
||||||
}
|
}
|
||||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||||
// is the CJK-aware focus router, not xterm's raw focus().
|
// is the CJK-aware focus router, not xterm's raw focus().
|
||||||
this.terminal.focus();
|
terminal.focus();
|
||||||
return ok;
|
return ok;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -5688,10 +5759,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Update overlay font cache and re-render at new cell dimensions
|
// Update overlay font cache and re-render at new cell dimensions
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontSize = size;
|
tile.terminal.options.fontSize = size;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5717,10 +5788,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._refitAfterCellSizeChange();
|
this._refitAfterCellSizeChange();
|
||||||
this._localEchoOverlay?.refreshFont();
|
this._localEchoOverlay?.refreshFont();
|
||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontFamily = resolved;
|
tile.terminal.options.fontFamily = resolved;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5772,11 +5843,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
/* pane not laid out yet — its own resize observer refits it */
|
/* pane not laid out yet — its own resize observer refits it */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
tile.terminal.options.fontWeight = fontWeight;
|
||||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||||
this._splitPane.fitAddon?.fit();
|
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
@@ -6245,13 +6316,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this._splitPane?.terminal) {
|
this._forEachTile?.((tile) => {
|
||||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||||
this._splitPane.terminal.options.theme = { ...theme };
|
tile.terminal.options.theme = { ...theme };
|
||||||
try {
|
try {
|
||||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
tile.terminal.refresh(0, tile.terminal.rows - 1);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -554,6 +554,11 @@ const VoiceInput = {
|
|||||||
_analyserSource: null, // MediaStreamSource for level meter
|
_analyserSource: null, // MediaStreamSource for level meter
|
||||||
_audioContext: null, // AudioContext for level meter
|
_audioContext: null, // AudioContext for level meter
|
||||||
_levelAnimFrame: null, // rAF handle for level meter
|
_levelAnimFrame: null, // rAF handle for level meter
|
||||||
|
// The session dictation was started FOR, captured in start(). Transcripts
|
||||||
|
// arrive seconds later and the green send button / compose overlay can be
|
||||||
|
// used later still; reading app.activeSessionId at that point sent the text
|
||||||
|
// to whatever tab the user had switched to in the meantime.
|
||||||
|
_targetSessionId: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._initRecognition();
|
this._initRecognition();
|
||||||
@@ -663,10 +668,12 @@ const VoiceInput = {
|
|||||||
|
|
||||||
start() {
|
start() {
|
||||||
if (this.isRecording) return;
|
if (this.isRecording) return;
|
||||||
if (!app.activeSessionId) {
|
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
|
||||||
|
if (!target) {
|
||||||
app.showToast('No active session', 'warning');
|
app.showToast('No active session', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this._targetSessionId = target;
|
||||||
this._retryCount = 0;
|
this._retryCount = 0;
|
||||||
|
|
||||||
const provider = this._resolveProvider();
|
const provider = this._resolveProvider();
|
||||||
@@ -959,8 +966,31 @@ const VoiceInput = {
|
|||||||
this.stop();
|
this.stop();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** The session this dictation belongs to (see _targetSessionId). */
|
||||||
|
_targetSession() {
|
||||||
|
return this._targetSessionId || app.activeSessionId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send text to the dictation's own session. The active session keeps going
|
||||||
|
* through app.sendInput() exactly as before; any other session goes straight
|
||||||
|
* to the durable queue with the same useMux flag sendInput() passes.
|
||||||
|
*/
|
||||||
|
_sendToTarget(target, text) {
|
||||||
|
if (target === app.activeSessionId) return app.sendInput(text);
|
||||||
|
// Closed while dictating: say so instead of queueing text for a session
|
||||||
|
// that will only answer 404 (and never typing it into some other tab).
|
||||||
|
if (app.sessions && !app.sessions.has(target)) {
|
||||||
|
app.showToast?.('That session has closed; dictation not sent', 'warning');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
app._sendInputAsync(target, text, { useMux: true });
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
_insertText(text) {
|
_insertText(text) {
|
||||||
if (!app.activeSessionId || !text.trim()) return;
|
const target = this._targetSession();
|
||||||
|
if (!target || !text.trim()) return;
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
||||||
|
|
||||||
@@ -975,14 +1005,17 @@ const VoiceInput = {
|
|||||||
this._showComposeOverlay(trimmed);
|
this._showComposeOverlay(trimmed);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Direct mode: inject into local echo overlay if available, else send to PTY
|
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
// The overlay belongs to the ACTIVE session's terminal, so text dictated
|
||||||
|
// for any other session must not be typed into it.
|
||||||
|
const isActive = target === app.activeSessionId;
|
||||||
|
if (isActive && app._localEchoEnabled && app._localEchoOverlay) {
|
||||||
app._localEchoOverlay.appendText(trimmed);
|
app._localEchoOverlay.appendText(trimmed);
|
||||||
} else {
|
} else {
|
||||||
app.sendInput(trimmed).catch(() => {});
|
this._sendToTarget(target, trimmed).catch(() => {});
|
||||||
}
|
}
|
||||||
this._showVoiceSendBtn();
|
this._showVoiceSendBtn();
|
||||||
setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150);
|
setTimeout(() => { if (isActive && app.terminal) app.terminal.focus(); }, 150);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1008,10 +1041,15 @@ const VoiceInput = {
|
|||||||
|
|
||||||
// Click handler
|
// Click handler
|
||||||
this._voiceSendHandler = () => {
|
this._voiceSendHandler = () => {
|
||||||
if (!app.activeSessionId) return;
|
const target = this._targetSession();
|
||||||
|
if (!target) return;
|
||||||
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
// Simulate Enter key: if local echo is active, flush its buffer + send \r;
|
||||||
// otherwise just send \r directly to the PTY
|
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
// predictions belong to the ACTIVE session's terminal, so a dictation
|
||||||
|
// for another session just sends its Enter there.
|
||||||
|
if (target !== app.activeSessionId) {
|
||||||
|
this._sendToTarget(target, '\r').catch(() => {});
|
||||||
|
} else if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||||
const text = app._localEchoOverlay.pendingText || '';
|
const text = app._localEchoOverlay.pendingText || '';
|
||||||
app._localEchoOverlay.clear();
|
app._localEchoOverlay.clear();
|
||||||
app._localEchoOverlay.suppressBufferDetection();
|
app._localEchoOverlay.suppressBufferDetection();
|
||||||
@@ -1065,7 +1103,7 @@ const VoiceInput = {
|
|||||||
const send = () => {
|
const send = () => {
|
||||||
const val = textarea.value.trim();
|
const val = textarea.value.trim();
|
||||||
overlay.remove();
|
overlay.remove();
|
||||||
if (val) app.sendInput(val + '\r').catch(() => {});
|
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
|
||||||
};
|
};
|
||||||
const cancel = () => overlay.remove();
|
const cancel = () => overlay.remove();
|
||||||
const newInput = () => {
|
const newInput = () => {
|
||||||
|
|||||||
@@ -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>';");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview App-level terminal shortcuts act on the pane the keyboard is in.
|
||||||
|
*
|
||||||
|
* With the split pane open, the global shortcut handler (app.js, capture phase)
|
||||||
|
* used to resolve every terminal action against the PRIMARY pane: Ctrl+L typed
|
||||||
|
* into Pane B cleared Pane A's display while xterm sent the ^L into Pane B's
|
||||||
|
* PTY, and Ctrl+Shift+R restored Pane A's size. `_focusedPane()` (terminal-ui.js)
|
||||||
|
* now answers with the pane whose terminal was focused last, and the actions
|
||||||
|
* that are about a TERMINAL (clear, restore size) go through it.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
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');
|
||||||
|
|
||||||
|
function loadApp() {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: {},
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
setTimeout: () => 1,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
app.activeSessionId = 'session-a';
|
||||||
|
app.terminal = { clear: vi.fn(), cols: 120, rows: 40 };
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
app.sendResize = vi.fn(async () => true);
|
||||||
|
app.getTerminalDimensions = () => ({ cols: 120, rows: 40 });
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function paneB(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
sessionId: 'session-b',
|
||||||
|
terminal: { clear: vi.fn(), cols: 60, rows: 30 },
|
||||||
|
fit: vi.fn(),
|
||||||
|
_destroyed: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('_focusedPane()', () => {
|
||||||
|
it('is the primary pane until another pane claims the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
const pane = app._focusedPane();
|
||||||
|
|
||||||
|
expect(pane.isPrimary).toBe(true);
|
||||||
|
expect(pane.terminal).toBe(app.terminal);
|
||||||
|
expect(pane.sessionId).toBe('session-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the second pane after its terminal was focused, and the primary again after the primary was', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
expect(app._focusedPane()).toMatchObject({ isPrimary: false, sessionId: 'session-b', terminal: tile.terminal });
|
||||||
|
|
||||||
|
app._noteFocusedTile(null);
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never answers with a destroyed pane', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
tile._destroyed = true;
|
||||||
|
|
||||||
|
expect(app._focusedPane().isPrimary).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal shortcuts follow the focused pane', () => {
|
||||||
|
it('Ctrl+L clears the focused second pane, not the primary', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(tile.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.terminal.clear).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+L still clears the primary when it holds the keyboard', () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
app.clearTerminal();
|
||||||
|
|
||||||
|
expect(app.terminal.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Ctrl+Shift+R forces the focused second pane's size onto its own PTY", async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
const tile = paneB();
|
||||||
|
app._noteFocusedTile(tile);
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(tile.fit).toHaveBeenCalledWith({ force: true });
|
||||||
|
expect(app.sendResize).not.toHaveBeenCalled();
|
||||||
|
expect(app.showToast).toHaveBeenCalledWith('Terminal restored to 60x30', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ctrl+Shift+R keeps restoring the primary when it holds the keyboard', async () => {
|
||||||
|
const app = loadApp();
|
||||||
|
|
||||||
|
await app.restoreTerminalSize();
|
||||||
|
|
||||||
|
expect(app.sendResize).toHaveBeenCalledWith('session-a', { force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
||||||
|
appSource.indexOf('async killActiveSession() {'),
|
||||||
|
appSource.indexOf('async killAllSessions() {')
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body).toContain('await this.closeSession(this.activeSessionId);');
|
||||||
|
expect(body).not.toContain('_focusedPane');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ function loadImageInputApp() {
|
|||||||
app.activeSessionId = 'session-1';
|
app.activeSessionId = 'session-1';
|
||||||
app.showToast = vi.fn();
|
app.showToast = vi.fn();
|
||||||
app.sendInput = vi.fn(async () => {});
|
app.sendInput = vi.fn(async () => {});
|
||||||
|
app._sendInputAsync = vi.fn();
|
||||||
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||||
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||||
return app as Record<string, any>;
|
return app as Record<string, any>;
|
||||||
@@ -199,6 +200,7 @@ describe('image upload insertion policy', () => {
|
|||||||
|
|
||||||
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
expect(Array.from(paths)).toEqual(['/tmp/first.png', '/tmp/second.png']);
|
||||||
expect(app.sendInput).not.toHaveBeenCalled();
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves terminal insertion by default', async () => {
|
it('preserves terminal insertion by default', async () => {
|
||||||
@@ -207,6 +209,33 @@ describe('image upload insertion policy', () => {
|
|||||||
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
const paths = await app._uploadAndInsertImages([{ path: '/tmp/legacy.png' }]);
|
||||||
|
|
||||||
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
expect(Array.from(paths)).toEqual(['/tmp/legacy.png']);
|
||||||
expect(app.sendInput).toHaveBeenCalledWith('/tmp/legacy.png');
|
// The same delivery sendInput() uses (durable queue, useMux for the POST
|
||||||
|
// fallback), but addressed to the session the batch was uploaded to.
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/legacy.png', { useMux: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts into the session the upload started in, even after a tab switch mid-upload', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
// The user switches tabs while the upload is in flight. sendInput() re-read
|
||||||
|
// activeSessionId after the awaits, so the paths used to land in session-2.
|
||||||
|
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => {
|
||||||
|
app.activeSessionId = 'session-2';
|
||||||
|
return file.path;
|
||||||
|
});
|
||||||
|
|
||||||
|
await app._uploadAndInsertImages([{ path: '/tmp/shot.png' }]);
|
||||||
|
|
||||||
|
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-1', { path: '/tmp/shot.png' });
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '/tmp/shot.png', { useMux: true });
|
||||||
|
expect(app.sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uploads to and inserts into an explicitly named session', async () => {
|
||||||
|
const app = loadImageInputApp();
|
||||||
|
|
||||||
|
await app._uploadAndInsertImages([{ path: '/tmp/pane-b.png' }], { sessionId: 'session-b' });
|
||||||
|
|
||||||
|
expect(app._uploadPasteImage).toHaveBeenCalledWith('session-b', { path: '/tmp/pane-b.png' });
|
||||||
|
expect(app._sendInputAsync).toHaveBeenCalledWith('session-b', '/tmp/pane-b.png', { useMux: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe('the client never queues or keeps an undeliverable frame', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('drops a frame the WebSocket refused with an error ACK', () => {
|
it('drops a frame the WebSocket refused with an error ACK', () => {
|
||||||
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg) {'));
|
const handler = appSource.slice(appSource.indexOf('_onWsInputAck(seq, msg, sessionId = this._wsSessionId) {'));
|
||||||
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
|
expect(handler.slice(0, 600)).toMatch(/if \(msg && msg\.err\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The durable input layer delivers over ANY registered terminal
|
||||||
|
* socket, not only the primary one (`_inputSocketFor`, app.js).
|
||||||
|
*
|
||||||
|
* The exactly-once queue (`_sendInputAsync` → `_reliableSend` → `_drainSession`,
|
||||||
|
* ACKed by `{t:'ia'}`, swept by `_redeliverSweep`) used to read the single
|
||||||
|
* primary socket (`this._ws` / `this._wsSessionId`) directly. A second terminal
|
||||||
|
* bound to another session (the split pane's Pane B, later a grid tile) had no
|
||||||
|
* way in, so its keystrokes went out as seq-less frames with no ACK, no retry
|
||||||
|
* and no idle-alert acknowledgement. These tests pin the seam: a registered
|
||||||
|
* socket gets seq frames, its ACK lands on ITS session's queue, a stale
|
||||||
|
* registration can never evict the socket that replaced it, and the sweep
|
||||||
|
* judges a socket's silence by that socket's own last frame.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` with a stubbed context (no jsdom), like input-send-order.
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
|
||||||
|
function loadCodemanAppClass() {
|
||||||
|
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: { OPEN: 1 },
|
||||||
|
fetch: (...args: Parameters<typeof fetch>) => global.fetch(...args),
|
||||||
|
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(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
|
||||||
|
return (context as { __CodemanApp: new () => unknown }).__CodemanApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodemanApp = loadCodemanAppClass();
|
||||||
|
|
||||||
|
type Frame = { t: string; d: string; seq?: number; cid?: string };
|
||||||
|
type FakeSocket = { readyState: number; send: (data: string) => void; close: ReturnType<typeof vi.fn> };
|
||||||
|
type Handle = { ws: FakeSocket; lastRecvAt: number };
|
||||||
|
type Rec = { seq: number; data: string; sentAt: number; tries: number };
|
||||||
|
|
||||||
|
type App = {
|
||||||
|
_sendInputAsync: (sessionId: string, input: string, opts?: { useMux?: boolean }) => void;
|
||||||
|
_sendInputEphemeral: (sessionId: string, input: string) => void;
|
||||||
|
_inputSocketFor: (sessionId: string) => { ws: FakeSocket; lastRecvAt: number } | null;
|
||||||
|
_registerInputSocket: (sessionId: string, handle: Handle) => void;
|
||||||
|
_unregisterInputSocket: (sessionId: string, handle: Handle) => void;
|
||||||
|
_onWsInputAck: (seq: number, msg: Record<string, unknown>, sessionId?: string) => void;
|
||||||
|
_onWsReady: (sessionId: string) => void;
|
||||||
|
_redeliverSweep: () => void;
|
||||||
|
_pendingDeliveries: Map<string, Rec[]>;
|
||||||
|
_seqCounters: Map<string, number>;
|
||||||
|
_ws: FakeSocket | null;
|
||||||
|
_wsSessionId: string | null;
|
||||||
|
_wsLastRecvAt: number;
|
||||||
|
_reliableAckTimeoutMs: number;
|
||||||
|
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(): App {
|
||||||
|
const app = Object.create((CodemanApp as { prototype: object }).prototype) as App & Record<string, unknown>;
|
||||||
|
app._clientId = 'c-test';
|
||||||
|
app._seqCounters = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._postDraining = new Set();
|
||||||
|
app._persistReliableState = vi.fn();
|
||||||
|
app._persistReliableNow = vi.fn();
|
||||||
|
app._updateConnectionIndicator = vi.fn();
|
||||||
|
app.markIdleAlertSeen = vi.fn();
|
||||||
|
app.activeSessionId = 'primary';
|
||||||
|
app._ws = null;
|
||||||
|
app._wsSessionId = null;
|
||||||
|
app._wsLastRecvAt = 0;
|
||||||
|
app._reliableAckTimeoutMs = 4000;
|
||||||
|
return app as unknown as App;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeSocket(frames: Frame[]): FakeSocket {
|
||||||
|
return { readyState: 1, send: (d: string) => frames.push(JSON.parse(d) as Frame), close: vi.fn() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a registered second socket joins the exactly-once queue', () => {
|
||||||
|
it('delivers seq-tagged frames over the registered socket for its session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const primaryFrames: Frame[] = [];
|
||||||
|
const tileFrames: Frame[] = [];
|
||||||
|
app._ws = fakeSocket(primaryFrames);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||||
|
|
||||||
|
app._sendInputAsync('other', 'x');
|
||||||
|
app._sendInputAsync('other', 'y');
|
||||||
|
|
||||||
|
expect(tileFrames.map((f) => [f.d, f.seq, f.cid])).toEqual([
|
||||||
|
['x', 1, 'c-test'],
|
||||||
|
['y', 2, 'c-test'],
|
||||||
|
]);
|
||||||
|
// Nothing for the other session leaks onto the primary socket.
|
||||||
|
expect(primaryFrames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still prefers the primary socket for the primary session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const primaryFrames: Frame[] = [];
|
||||||
|
app._ws = fakeSocket(primaryFrames);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
|
||||||
|
app._sendInputAsync('primary', 'a');
|
||||||
|
|
||||||
|
expect(primaryFrames.map((f) => f.d)).toEqual(['a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a registered socket that is not OPEN (falls back to POST)', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const posts: Array<{ input: string; seq: number }> = [];
|
||||||
|
global.fetch = vi.fn(async (_url, init) => {
|
||||||
|
posts.push(JSON.parse(String(init?.body)));
|
||||||
|
return new Response('{}', { status: 200 });
|
||||||
|
});
|
||||||
|
const closing = { ...fakeSocket([]), readyState: 3 };
|
||||||
|
app._registerInputSocket('other', { ws: closing, lastRecvAt: 0 });
|
||||||
|
|
||||||
|
expect(app._inputSocketFor('other')).toBeNull();
|
||||||
|
app._sendInputAsync('other', 'z');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes ephemeral input over the registered socket too', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const tileFrames: Frame[] = [];
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(tileFrames), lastRecvAt: 0 });
|
||||||
|
|
||||||
|
app._sendInputEphemeral('other', '\x1b[<64;1;1M');
|
||||||
|
|
||||||
|
expect(tileFrames).toEqual([{ t: 'i', d: '\x1b[<64;1;1M' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ACK routing', () => {
|
||||||
|
it('drops the record from the queue of the session the ACK arrived for', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket([]), lastRecvAt: 0 });
|
||||||
|
app._sendInputAsync('primary', 'p');
|
||||||
|
app._sendInputAsync('other', 'o');
|
||||||
|
|
||||||
|
// Both sessions issued seq 1. The ACK names no session, so the socket's own
|
||||||
|
// session decides which queue it drains; the primary's must be untouched.
|
||||||
|
app._onWsInputAck(1, { t: 'ia', seq: 1 }, 'other');
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('other')).toBeUndefined();
|
||||||
|
expect(app._pendingDeliveries.get('primary')?.map((r) => r.data)).toEqual(['p']);
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the primary socket session when no session is passed', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._sendInputAsync('primary', 'p');
|
||||||
|
|
||||||
|
app._onWsInputAck(1, { t: 'ia', seq: 1 });
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('primary')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-sends everything pending over a freshly registered socket on ready', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const first: Frame[] = [];
|
||||||
|
const firstHandle = { ws: fakeSocket(first), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', firstHandle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
expect(first.map((f) => f.d)).toEqual(['a']);
|
||||||
|
|
||||||
|
// The socket dies without an ACK; a replacement opens and asks for a flush.
|
||||||
|
firstHandle.ws.readyState = 3;
|
||||||
|
const second: Frame[] = [];
|
||||||
|
app._registerInputSocket('other', { ws: fakeSocket(second), lastRecvAt: 0 });
|
||||||
|
app._onWsReady('other');
|
||||||
|
|
||||||
|
expect(second.map((f) => [f.d, f.seq])).toEqual([['a', 1]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registration ownership', () => {
|
||||||
|
it('a stale handle cannot unregister the socket that replaced it', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const oldHandle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
const newFrames: Frame[] = [];
|
||||||
|
const newHandle = { ws: fakeSocket(newFrames), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', oldHandle);
|
||||||
|
app._registerInputSocket('other', newHandle);
|
||||||
|
|
||||||
|
// The old socket's close lands late and tries to clean up after itself.
|
||||||
|
app._unregisterInputSocket('other', oldHandle);
|
||||||
|
|
||||||
|
app._sendInputAsync('other', 'still-here');
|
||||||
|
expect(newFrames.map((f) => f.d)).toEqual(['still-here']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the owning handle does unregister', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._unregisterInputSocket('other', handle);
|
||||||
|
|
||||||
|
expect(app._inputSocketFor('other')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stamps lastRecvAt at registration so a fresh socket never looks silent', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
const before = Date.now();
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
|
||||||
|
expect(handle.lastRecvAt).toBeGreaterThanOrEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the redelivery sweep judges each socket by its own last frame', () => {
|
||||||
|
function staleRecord(app: App, sessionId: string) {
|
||||||
|
const rec = app._pendingDeliveries.get(sessionId)![0];
|
||||||
|
rec.sentAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('force-closes a silent registered socket with a stale record', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const handle = { ws: fakeSocket([]), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
staleRecord(app, 'other');
|
||||||
|
handle.lastRecvAt = Date.now() - (app._reliableAckTimeoutMs + 1000);
|
||||||
|
// The primary socket is chatty; that must not vouch for the other one.
|
||||||
|
app._ws = fakeSocket([]);
|
||||||
|
app._wsSessionId = 'primary';
|
||||||
|
app._wsLastRecvAt = Date.now();
|
||||||
|
|
||||||
|
app._redeliverSweep();
|
||||||
|
|
||||||
|
expect(handle.ws.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app._ws.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-drives (does not close) a registered socket that is still receiving', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const frames: Frame[] = [];
|
||||||
|
const handle = { ws: fakeSocket(frames), lastRecvAt: 0 };
|
||||||
|
app._registerInputSocket('other', handle);
|
||||||
|
app._sendInputAsync('other', 'a');
|
||||||
|
staleRecord(app, 'other');
|
||||||
|
handle.lastRecvAt = Date.now();
|
||||||
|
// A SILENT primary must not condemn the live registered socket either.
|
||||||
|
app._wsLastRecvAt = 0;
|
||||||
|
|
||||||
|
app._redeliverSweep();
|
||||||
|
|
||||||
|
expect(handle.ws.close).not.toHaveBeenCalled();
|
||||||
|
expect(frames.map((f) => f.d)).toEqual(['a', 'a']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,7 @@ describe('the duplicate ACK carries what the client needs', () => {
|
|||||||
|
|
||||||
describe('the client lifts itself over the watermark', () => {
|
describe('the client lifts itself over the watermark', () => {
|
||||||
const handler = appSource.slice(
|
const handler = appSource.slice(
|
||||||
appSource.indexOf('_onWsInputAck(seq, msg)'),
|
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
|
||||||
appSource.indexOf('/** Called from ws.onopen')
|
appSource.indexOf('/** Called from ws.onopen')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
const PUBLIC = join(new URL('.', import.meta.url).pathname, '../src/web/public');
|
||||||
|
|
||||||
describe.each(['terminal-ui.js', 'terminal-split.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
describe.each(['terminal-ui.js', 'terminal-tile.js'])('%s Shift/Ctrl+Enter handler', (file) => {
|
||||||
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
const src = readFileSync(join(PUBLIC, file), 'utf8');
|
||||||
|
|
||||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
* worked all along because Chromium fires no keypress for it.
|
* worked all along because Chromium fires no keypress for it.
|
||||||
*
|
*
|
||||||
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
* The page is the real app served by a real WebServer, so the handlers under test are the ones
|
||||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
* terminal-ui.js (the main pane, `app.terminal`) and terminal-tile.js (Pane B, a real
|
||||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
* `TerminalTile`) attach. Nothing restates their predicate. What stands in for the server is
|
||||||
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
* only the edge: a fetch wrapper records the send-key POSTs instead of letting them reach tmux, and
|
||||||
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
* no session exists behind the ids, so nothing is ever typed into a real pane.
|
||||||
*
|
*
|
||||||
@@ -74,7 +74,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
|
|||||||
const mount = document.createElement('div');
|
const mount = document.createElement('div');
|
||||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
const pane = new w.SplitTerminalPane(paneBId, mount, { mode: 'claude' });
|
const pane = new w.TerminalTile(paneBId, mount, { mode: 'claude' });
|
||||||
void pane.connect().catch(() => {});
|
void pane.connect().catch(() => {});
|
||||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||||
w.__paneBData = [] as string[];
|
w.__paneBData = [] as string[];
|
||||||
@@ -200,7 +200,7 @@ describe('Shift/Ctrl+Enter through the shipped key handlers (keypress must be sw
|
|||||||
expect(shift.sendKeys).toEqual([]);
|
expect(shift.sendKeys).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Pane B (terminal-split.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
it("Pane B (terminal-tile.js): Shift+Enter and Ctrl+Enter write nothing and POST send-key once for Pane B's own session", async () => {
|
||||||
const shift = await pressIn('paneB', 'Shift+Enter');
|
const shift = await pressIn('paneB', 'Shift+Enter');
|
||||||
expect(shift.focused).toBe(true);
|
expect(shift.focused).toBe(true);
|
||||||
expect(shift.data).toEqual([]);
|
expect(shift.data).toEqual([]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** @fileoverview Real Chromium + real WebSocket coverage for SplitTerminalPane (Task 4 of the split-pane-sessions plan). */
|
/** @fileoverview Real Chromium + real WebSocket coverage for TerminalTile (Task 4 of the split-pane-sessions plan). */
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import { chromium, type Browser, type Page } from 'playwright';
|
import { chromium, type Browser, type Page } from 'playwright';
|
||||||
import { WebServer } from '../src/web/server.js';
|
import { WebServer } from '../src/web/server.js';
|
||||||
@@ -6,7 +6,7 @@ import { WebServer } from '../src/web/server.js';
|
|||||||
const PORT = 3175;
|
const PORT = 3175;
|
||||||
const BASE_URL = `http://localhost:${PORT}`;
|
const BASE_URL = `http://localhost:${PORT}`;
|
||||||
|
|
||||||
describe('SplitTerminalPane in a real browser', () => {
|
describe('TerminalTile in a real browser', () => {
|
||||||
let server: WebServer;
|
let server: WebServer;
|
||||||
let browser: Browser;
|
let browser: Browser;
|
||||||
let page: Page;
|
let page: Page;
|
||||||
@@ -37,7 +37,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
// Testing section) — the shell PTY only spawns once this is called, and
|
// Testing section) — the shell PTY only spawns once this is called, and
|
||||||
// without it the WS opens but no bytes ever flow, and the echo assertion
|
// without it the WS opens but no bytes ever flow, and the echo assertion
|
||||||
// below would hang until its own timeout for reasons unrelated to
|
// below would hang until its own timeout for reasons unrelated to
|
||||||
// SplitTerminalPane.
|
// TerminalTile.
|
||||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
pane.connect();
|
pane.connect();
|
||||||
|
|
||||||
// Wait for the WS to open, then send a real input frame — testMode's
|
// Wait for the WS to open, then send a real input frame — testMode's
|
||||||
@@ -107,7 +107,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
});
|
});
|
||||||
const id = (await res.json()).data.session.id;
|
const id = (await res.json()).data.session.id;
|
||||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||||
// Write directly to the session (not through SplitTerminalPane, which
|
// Write directly to the session (not through TerminalTile, which
|
||||||
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
// does not exist yet). Poll the real ?full=1 capture (same endpoint
|
||||||
// connect() below will use) rather than a fixed delay — the shell's
|
// connect() below will use) rather than a fixed delay — the shell's
|
||||||
// own startup can race an early write and, on this box, a startup
|
// own startup can race an early write and, on this box, a startup
|
||||||
@@ -135,7 +135,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
await pane.connect();
|
await pane.connect();
|
||||||
|
|
||||||
// xterm's write() parses asynchronously (it queues data and processes it
|
// xterm's write() parses asynchronously (it queues data and processes it
|
||||||
@@ -191,7 +191,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
|||||||
mount.style.height = '300px';
|
mount.style.height = '300px';
|
||||||
document.body.appendChild(mount);
|
document.body.appendChild(mount);
|
||||||
|
|
||||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
const pane = new (window as any).TerminalTile(id, mount);
|
||||||
await pane.connect();
|
await pane.connect();
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
const check = () => (pane._wsReady ? resolve(undefined) : setTimeout(check, 100));
|
||||||
|
|||||||
@@ -63,7 +63,13 @@ function fakeTerminal(options: Record<string, unknown> = {}) {
|
|||||||
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
return { options: { fontFamily: '"JetBrains Mono"', fontSize: 14, ...options } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTerminal> | null } = {}) {
|
function makeApp(
|
||||||
|
opts: {
|
||||||
|
teammates?: number;
|
||||||
|
terminal?: ReturnType<typeof fakeTerminal> | null;
|
||||||
|
splitPane?: { terminal: ReturnType<typeof fakeTerminal>; fit: () => void } | null;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
const fit = vi.fn();
|
const fit = vi.fn();
|
||||||
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
const teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||||
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
const teammateTerminals = new Map<string, { terminal: ReturnType<typeof fakeTerminal>; fitAddon: unknown }>();
|
||||||
@@ -74,6 +80,10 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
|||||||
}
|
}
|
||||||
const app = {
|
const app = {
|
||||||
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
applyTerminalFontWeights: mixin.applyTerminalFontWeights,
|
||||||
|
// Secondary panes (the split pane's second terminal) are reached through
|
||||||
|
// this helper, so it is wired for real like the geometry chain below.
|
||||||
|
_forEachTile: mixin._forEachTile,
|
||||||
|
_splitPane: opts.splitPane ?? null,
|
||||||
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
// The REAL geometry chain, not stubs. A font change moves the cell size, so
|
||||||
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
||||||
// through the one function that floors the result and reports it (#464).
|
// through the one function that floors the result and reports it (#464).
|
||||||
@@ -98,6 +108,23 @@ function makeApp(opts: { teammates?: number; terminal?: ReturnType<typeof fakeTe
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('applyTerminalFontWeights', () => {
|
describe('applyTerminalFontWeights', () => {
|
||||||
|
it('reaches an open split pane: same weights, refit AND reported to its PTY', () => {
|
||||||
|
const splitTerminal = fakeTerminal();
|
||||||
|
// fit(), not localFit(): a weight change can move the cell size, and the
|
||||||
|
// split pane's PTY must hear about the new geometry like the primary's does.
|
||||||
|
const fit = vi.fn();
|
||||||
|
const { app } = makeApp({ splitPane: { terminal: splitTerminal, fit } });
|
||||||
|
|
||||||
|
(app as unknown as { applyTerminalFontWeights: (s: unknown) => void }).applyTerminalFontWeights({
|
||||||
|
terminalFontWeight: '300',
|
||||||
|
terminalFontWeightBold: '700',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(splitTerminal.options.fontWeight).toBe(300);
|
||||||
|
expect(splitTerminal.options.fontWeightBold).toBe(700);
|
||||||
|
expect(fit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('writes both slots to the live terminal', () => {
|
it('writes both slots to the live terminal', () => {
|
||||||
const { app, fit } = makeApp();
|
const { app, fit } = makeApp();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview The file-path / URL link provider can serve a second terminal.
|
||||||
|
*
|
||||||
|
* `registerFilePathLinkProvider()` (terminal-ui.js) was hardwired to the
|
||||||
|
* primary terminal and the ACTIVE session: a path clicked anywhere opened
|
||||||
|
* against `activeSessionId`. It now takes an optional target `{ terminal,
|
||||||
|
* getSessionId, setHovered }`, so the split pane's second terminal (and later a
|
||||||
|
* grid tile) gets clickable paths that open against ITS session. With no target
|
||||||
|
* it behaves exactly as before, and only the primary registration is kept on
|
||||||
|
* `_terminalLinkProvider`, which the touch-tap path reads.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + terminal-ui.js in a `vm` context.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type Link = { text: string; activate: (ev: unknown, text: string) => void; hover: () => void; leave: () => void };
|
||||||
|
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
|
||||||
|
|
||||||
|
function loadHarness() {
|
||||||
|
const CodemanApp = function CodemanApp(this: unknown) {};
|
||||||
|
const windowRef: Record<string, unknown> = {};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: windowRef,
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
getElementById: () => null,
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
performance: { now: () => 0 },
|
||||||
|
requestAnimationFrame: () => 1,
|
||||||
|
setTimeout: () => 1,
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
return new (CodemanApp as unknown as new () => Record<string, any>)();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A one-screen xterm stand-in holding `lines`, recording every provider registered on it. */
|
||||||
|
function fakeTerminal(lines: string[]) {
|
||||||
|
const providers: Provider[] = [];
|
||||||
|
return {
|
||||||
|
cols: 80,
|
||||||
|
providers,
|
||||||
|
registerLinkProvider: (p: Provider) => providers.push(p),
|
||||||
|
buffer: {
|
||||||
|
active: {
|
||||||
|
length: lines.length,
|
||||||
|
getLine: (row: number) =>
|
||||||
|
row >= 0 && row < lines.length
|
||||||
|
? {
|
||||||
|
isWrapped: false,
|
||||||
|
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function linksOn(provider: Provider, line: number): Link[] {
|
||||||
|
let out: Link[] = [];
|
||||||
|
provider.provideLinks(line, (links) => {
|
||||||
|
out = links || [];
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeApp() {
|
||||||
|
const app = loadHarness();
|
||||||
|
app.activeSessionId = 'primary-session';
|
||||||
|
app.openFilePreview = vi.fn();
|
||||||
|
app.openLogViewerWindow = vi.fn();
|
||||||
|
app._isExternalPreviewPath = () => false;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('registerFilePathLinkProvider with no target (the primary pane)', () => {
|
||||||
|
it('registers on the primary terminal, keeps the provider for the tap path, opens against the active session', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.terminal = fakeTerminal(['wrote /tmp/shot.png']);
|
||||||
|
|
||||||
|
const provider = app.registerFilePathLinkProvider();
|
||||||
|
|
||||||
|
expect(app.terminal.providers).toEqual([provider]);
|
||||||
|
expect(app._terminalLinkProvider).toBe(provider);
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/shot.png', 'primary-session');
|
||||||
|
link.hover();
|
||||||
|
expect(app._linkHovered).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerFilePathLinkProvider with a target (a second pane)', () => {
|
||||||
|
it("registers on the target terminal and opens against the target's session, not the active one", () => {
|
||||||
|
const app = makeApp();
|
||||||
|
app.terminal = fakeTerminal([]);
|
||||||
|
const primaryProvider = app.registerFilePathLinkProvider();
|
||||||
|
const paneTerminal = fakeTerminal(['tail -f /var/log/app.log']);
|
||||||
|
const setHovered = vi.fn();
|
||||||
|
|
||||||
|
const provider = app.registerFilePathLinkProvider({
|
||||||
|
terminal: paneTerminal,
|
||||||
|
getSessionId: () => 'pane-session',
|
||||||
|
setHovered,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(paneTerminal.providers).toEqual([provider]);
|
||||||
|
expect(app.terminal.providers).toEqual([primaryProvider]);
|
||||||
|
// The tap path's provider is still the primary one.
|
||||||
|
expect(app._terminalLinkProvider).toBe(primaryProvider);
|
||||||
|
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'pane-session');
|
||||||
|
|
||||||
|
link.hover();
|
||||||
|
link.leave();
|
||||||
|
expect(setHovered.mock.calls).toEqual([[true], [false]]);
|
||||||
|
expect(app._linkHovered).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the session at click time, so a pane rebound to another session follows it', () => {
|
||||||
|
const app = makeApp();
|
||||||
|
let sessionId = 'first';
|
||||||
|
const paneTerminal = fakeTerminal(['see /tmp/a.pdf']);
|
||||||
|
const provider = app.registerFilePathLinkProvider({ terminal: paneTerminal, getSessionId: () => sessionId });
|
||||||
|
|
||||||
|
sessionId = 'second';
|
||||||
|
const [link] = linksOn(provider, 1);
|
||||||
|
link.activate({}, link.text);
|
||||||
|
|
||||||
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/a.pdf', 'second');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,691 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview TerminalTile input rides the app's exactly-once queue, and only
|
||||||
|
* what a human typed is queued.
|
||||||
|
*
|
||||||
|
* Before, the split pane's second terminal sent every xterm `onData` chunk as a
|
||||||
|
* bare `{t:'i', d}` frame: no seq, no ACK, dropped while the socket was down,
|
||||||
|
* never acknowledged an idle alert. It now goes through `app._sendInputAsync`,
|
||||||
|
* over the pane's own socket registered in the app's input-socket map. That
|
||||||
|
* queue PERSISTS and REDELIVERS, so it must never hold what xterm generates on
|
||||||
|
* its own: a query reply (DA/CPR/OSC) is dropped, exactly as the primary pane
|
||||||
|
* drops it, and a focus or mouse report goes out once, ephemeral.
|
||||||
|
*
|
||||||
|
* The socket's lifecycle is pinned here too: a transient drop reconnects on the
|
||||||
|
* primary pane's backoff and refreshes the buffer without leaving a stale
|
||||||
|
* "disconnected" marker under a healthy pane; codes that cannot get better stop
|
||||||
|
* the pane once (`onExit`); a late close from a REPLACED socket is ignored; and
|
||||||
|
* destroy() cancels a pending reconnect.
|
||||||
|
*
|
||||||
|
* Real code under test: constants.js + app.js (the queue) + terminal-ui.js (the
|
||||||
|
* shared input predicates) + terminal-tile.js, in one `vm` context. xterm, the
|
||||||
|
* fit addon and WebSocket are fakes; `connect()` runs for real.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
type Frame = { t: string; d?: string; seq?: number; cid?: string; c?: number; r?: number };
|
||||||
|
|
||||||
|
class FakeSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static instances: FakeSocket[] = [];
|
||||||
|
readyState = 0;
|
||||||
|
sent: Frame[] = [];
|
||||||
|
onopen: (() => void) | null = null;
|
||||||
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||||
|
onclose: ((ev?: { code: number }) => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
constructor(public url: string) {
|
||||||
|
FakeSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
send(data: string) {
|
||||||
|
this.sent.push(JSON.parse(data) as Frame);
|
||||||
|
}
|
||||||
|
close = vi.fn(() => {
|
||||||
|
this.readyState = 3;
|
||||||
|
});
|
||||||
|
open() {
|
||||||
|
this.readyState = 1;
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
receive(msg: object) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(msg) });
|
||||||
|
}
|
||||||
|
inputFrames() {
|
||||||
|
return this.sent.filter((f) => f.t === 'i');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fit addon: proposes `FakeFit.proposed` and, like the real one, resizes to it (NaN = hidden pane). */
|
||||||
|
class FakeFit {
|
||||||
|
static proposed = { cols: 80, rows: 24 };
|
||||||
|
term: FakeTerminal | null = null;
|
||||||
|
fit() {
|
||||||
|
const { cols, rows } = FakeFit.proposed;
|
||||||
|
if (!Number.isFinite(cols) || !Number.isFinite(rows)) return;
|
||||||
|
this.term?.resize(cols, rows);
|
||||||
|
}
|
||||||
|
proposeDimensions() {
|
||||||
|
return { ...FakeFit.proposed };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeTerminal {
|
||||||
|
static last: FakeTerminal | null = null;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
cols = 80;
|
||||||
|
rows = 24;
|
||||||
|
dataCb: ((data: string) => void) | null = null;
|
||||||
|
buffer = { active: { type: 'normal', viewportY: 0, length: 24 } };
|
||||||
|
constructor(options: Record<string, unknown>) {
|
||||||
|
this.options = { ...options };
|
||||||
|
FakeTerminal.last = this;
|
||||||
|
}
|
||||||
|
loadAddon(addon: FakeFit) {
|
||||||
|
addon.term = this;
|
||||||
|
}
|
||||||
|
open() {}
|
||||||
|
onData(cb: (data: string) => void) {
|
||||||
|
this.dataCb = cb;
|
||||||
|
}
|
||||||
|
keyHandler: ((ev: Record<string, unknown>) => boolean) | null = null;
|
||||||
|
focusListeners: Array<() => void> = [];
|
||||||
|
textarea = {
|
||||||
|
addEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners.push(fn);
|
||||||
|
},
|
||||||
|
removeEventListener: (type: string, fn: () => void) => {
|
||||||
|
if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
focusTextarea() {
|
||||||
|
for (const fn of this.focusListeners) fn();
|
||||||
|
}
|
||||||
|
attachCustomKeyEventHandler(fn: (ev: Record<string, unknown>) => boolean) {
|
||||||
|
this.keyHandler = fn;
|
||||||
|
}
|
||||||
|
registerLinkProvider() {}
|
||||||
|
writes: string[] = [];
|
||||||
|
write(data: string, cb?: () => void) {
|
||||||
|
this.writes.push(data);
|
||||||
|
cb?.();
|
||||||
|
}
|
||||||
|
clear() {
|
||||||
|
this.writes.push('<CLEAR>');
|
||||||
|
}
|
||||||
|
resizes: Array<[number, number]> = [];
|
||||||
|
resize(cols: number, rows: number) {
|
||||||
|
this.resizes.push([cols, rows]);
|
||||||
|
this.cols = cols;
|
||||||
|
this.rows = rows;
|
||||||
|
}
|
||||||
|
dispose() {}
|
||||||
|
type(data: string) {
|
||||||
|
this.dataCb?.(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
function loadContext() {
|
||||||
|
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
|
||||||
|
const windowStub: Record<string, unknown> = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
CodemanBase: { base: '' },
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
console: { ...console, log: vi.fn(), debug: vi.fn() },
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
// Late-bound, so vi.useFakeTimers() (which swaps the globals) reaches code
|
||||||
|
// running inside this context.
|
||||||
|
setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms),
|
||||||
|
clearTimeout: (id: ReturnType<typeof setTimeout>) => globalThis.clearTimeout(id),
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
WebSocket: FakeSocket,
|
||||||
|
Terminal: FakeTerminal,
|
||||||
|
FitAddon: { FitAddon: FakeFit },
|
||||||
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
|
location: { protocol: 'http:', host: 'codeman.test' },
|
||||||
|
document: { addEventListener: vi.fn(), documentElement: { dataset: {} } },
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
window: windowStub,
|
||||||
|
MobileDetection: {
|
||||||
|
isTouchDevice: () => false,
|
||||||
|
isHandheldDevice: () => false,
|
||||||
|
getDeviceType: () => 'desktop',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` +
|
||||||
|
'globalThis.__CodemanApp = CodemanApp;',
|
||||||
|
context
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
CodemanApp: (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp,
|
||||||
|
windowStub,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { CodemanApp, windowStub } = loadContext();
|
||||||
|
|
||||||
|
type App = Record<string, unknown> & {
|
||||||
|
_pendingDeliveries: Map<string, Array<{ seq: number; data: string }>>;
|
||||||
|
markIdleAlertSeen: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeApp(): App {
|
||||||
|
const app = Object.create(CodemanApp.prototype) as App;
|
||||||
|
app._clientId = 'c-test';
|
||||||
|
app._wsTabNonce = 'nonce-1';
|
||||||
|
app._seqCounters = new Map();
|
||||||
|
app._pendingDeliveries = new Map();
|
||||||
|
app._postDraining = new Set();
|
||||||
|
app._extraInputSockets = new Map();
|
||||||
|
app._persistReliableState = vi.fn();
|
||||||
|
app._persistReliableNow = vi.fn();
|
||||||
|
app._updateConnectionIndicator = vi.fn();
|
||||||
|
app.markIdleAlertSeen = vi.fn();
|
||||||
|
app.showToast = vi.fn();
|
||||||
|
// The key handler's chord gates read the shortcut registry, which reads these.
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app._ws = null;
|
||||||
|
app._wsSessionId = null;
|
||||||
|
app._estimateReplayRows = (text: string) => text.split('\n').length;
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tile = {
|
||||||
|
connect(): Promise<void>;
|
||||||
|
destroy(): void;
|
||||||
|
reconnectNow(): void;
|
||||||
|
fit(opts?: { force?: boolean }): void;
|
||||||
|
detachedSessions?: Set<string>;
|
||||||
|
ws: FakeSocket | null;
|
||||||
|
_reconnectAttempts: number;
|
||||||
|
};
|
||||||
|
const TerminalTile = windowStub.TerminalTile as new (id: string, mount: unknown, opts?: object) => Tile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every tile a test creates, destroyed after it. A tile closed with real timers
|
||||||
|
* schedules a real reconnect, and one firing during a LATER test opens a socket
|
||||||
|
* there (FakeSocket.instances is shared), which flaked under full-suite load.
|
||||||
|
*/
|
||||||
|
const liveTiles: Tile[] = [];
|
||||||
|
|
||||||
|
async function connectTile(app: App, opts: Record<string, unknown> = {}) {
|
||||||
|
windowStub.app = app;
|
||||||
|
const tile = new TerminalTile(
|
||||||
|
's-tile',
|
||||||
|
{ addEventListener: vi.fn(), removeEventListener: vi.fn() },
|
||||||
|
{ mode: 'claude', ...opts }
|
||||||
|
);
|
||||||
|
liveTiles.push(tile);
|
||||||
|
await tile.connect();
|
||||||
|
const ws = FakeSocket.instances.at(-1)!;
|
||||||
|
return { tile, ws, term: FakeTerminal.last! };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const tile of liveTiles.splice(0)) tile.destroy();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
FakeFit.proposed = { cols: 80, rows: 24 };
|
||||||
|
FakeSocket.instances = [];
|
||||||
|
fetchMock.mockReset();
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { terminalBuffer: '' } }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile socket identity', () => {
|
||||||
|
it('connects with the tab identity plus a :tile suffix, never the primary pane cid', async () => {
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
|
||||||
|
const cid = new URL(ws.url).searchParams.get('cid');
|
||||||
|
expect(cid).toBe('c-test:nonce-1:tile');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile input through the exactly-once queue', () => {
|
||||||
|
it('sends typed input as seq-tagged frames with the bare clientId once the socket opens', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('h');
|
||||||
|
term.type('i');
|
||||||
|
|
||||||
|
expect(ws.inputFrames().map((f) => [f.d, f.seq, f.cid])).toEqual([
|
||||||
|
['h', 1, 'c-test'],
|
||||||
|
['i', 2, 'c-test'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a record on its ACK and acknowledges the session idle alert', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
term.type('x');
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toHaveLength(1);
|
||||||
|
|
||||||
|
ws.receive({ t: 'ia', seq: 1 });
|
||||||
|
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
expect(app.markIdleAlertSeen).toHaveBeenCalledWith('s-tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes input typed before the socket opened, in order, once it does', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
// POSTs fail, as they would while the server restarts, so the input waits.
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
fetchMock.mockImplementation(async () => ({ ok: false, status: 503, json: async () => ({}) }));
|
||||||
|
term.type('a');
|
||||||
|
term.type('b');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
expect(ws.inputFrames().map((f) => [f.d, f.seq])).toEqual([
|
||||||
|
['a', 1],
|
||||||
|
['b', 2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps queuing after the socket closes (HTTP fallback), instead of dropping keystrokes', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
const posts: Array<{ input: string; seq: number }> = [];
|
||||||
|
fetchMock.mockImplementation(async (_url: string, init?: { body?: string }) => {
|
||||||
|
if (init?.body) posts.push(JSON.parse(init.body));
|
||||||
|
return { ok: true, status: 200, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
term.type('z');
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
expect(posts.map((p) => [p.input, p.seq])).toEqual([['z', 1]]);
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('what xterm generates never enters the durable queue', () => {
|
||||||
|
it('drops a DA query reply entirely', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[?1;2c');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a mouse report once, without a seq, and never persists it', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[<0;10;5M');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[<0;10;5M' }]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a focus report once, without a seq', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
term.type('\x1b[I');
|
||||||
|
|
||||||
|
expect(ws.inputFrames()).toEqual([{ t: 'i', d: '\x1b[I' }]);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile leaves the input-socket map', () => {
|
||||||
|
it('on close, so a later keystroke cannot be written into a dead socket', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(true);
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on destroy, while pending input stays queued for the HTTP sweep', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
term.type('q');
|
||||||
|
|
||||||
|
tile.destroy();
|
||||||
|
|
||||||
|
expect((app._extraInputSockets as Map<string, unknown>).has('s-tile')).toBe(false);
|
||||||
|
expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['q']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMarker = (data: string) => data.includes('[disconnected');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lets the async buffer refresh settle: the fetch, the body read, the chunked
|
||||||
|
* write AND the load's finally block, which is where a stale marker would be
|
||||||
|
* stamped. Too few turns here and that assertion passes vacuously.
|
||||||
|
*/
|
||||||
|
async function settle() {
|
||||||
|
for (let i = 0; i < 50; i++) await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TerminalTile reconnects after a transient drop', () => {
|
||||||
|
it('reopens on the backoff, refreshes the buffer, and leaves no stale marker', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, ws, term } = await connectTile(app);
|
||||||
|
ws.open();
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { terminalBuffer: 'fresh screen' } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining('[disconnected, reconnecting')]);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
expect(ws2.url).toBe(ws.url);
|
||||||
|
|
||||||
|
ws2.open();
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The refresh cleared the pane and replayed the current screen, and nothing
|
||||||
|
// after that clear is a marker: the pane is healthy again.
|
||||||
|
const lastClear = term.writes.lastIndexOf('<CLEAR>');
|
||||||
|
expect(lastClear).toBeGreaterThan(-1);
|
||||||
|
expect(term.writes.slice(lastClear)).toContain('fresh screen');
|
||||||
|
expect(term.writes.slice(lastClear).some(isMarker)).toBe(false);
|
||||||
|
expect(tile._reconnectAttempts).toBe(0);
|
||||||
|
expect(tile.ws).toBe(ws2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts failed attempts and resets the count only on a successful open', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
FakeSocket.instances[1].onclose?.({ code: 1006 }); // the retry fails too
|
||||||
|
expect(tile._reconnectAttempts).toBe(2);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
FakeSocket.instances[2].open();
|
||||||
|
expect(tile._reconnectAttempts).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnects after the redelivery sweep force-closes a silent socket (1005)', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1005 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnectNow() skips the backoff, but never replaces an open socket', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
tile.reconnectNow();
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
|
||||||
|
ws.readyState = 3;
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
tile.reconnectNow();
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
// The backoff timer it pre-empted must not open a third socket later.
|
||||||
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
|
expect(FakeSocket.instances).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile stops for good on codes that cannot get better', () => {
|
||||||
|
it.each([
|
||||||
|
[4004, 'the session ended'],
|
||||||
|
[4009, 'the session ended'],
|
||||||
|
[4003, 'the server refused this connection'],
|
||||||
|
[4010, 'another connection took over this pane'],
|
||||||
|
])('close %i: no reconnect, onExit once, marker says why', async (code, reason) => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp(), { onExit });
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code });
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
tile.reconnectNow();
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
expect(onExit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onExit).toHaveBeenCalledWith(code);
|
||||||
|
expect(term.writes.filter(isMarker)).toEqual([expect.stringContaining(reason)]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile ignores a socket it already replaced', () => {
|
||||||
|
it('a late close (4010) from the old socket neither stops the pane nor unregisters its successor', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const app = makeApp();
|
||||||
|
const { ws, term } = await connectTile(app, { onExit });
|
||||||
|
ws.open();
|
||||||
|
const lateClose = ws.onclose!;
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
ws2.open();
|
||||||
|
|
||||||
|
// The server supersedes the old socket by cid; its close arrives late.
|
||||||
|
lateClose({ code: 4010 });
|
||||||
|
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
term.type('still typing');
|
||||||
|
expect(ws2.inputFrames().map((f) => f.d)).toEqual(['still typing']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile destroy()', () => {
|
||||||
|
it('cancels a pending reconnect and never reports an exit afterwards', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const onExit = vi.fn();
|
||||||
|
const { tile, ws } = await connectTile(makeApp(), { onExit });
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
tile.destroy();
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
|
||||||
|
expect(FakeSocket.instances).toHaveLength(1);
|
||||||
|
expect(onExit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile geometry (#464: the pane and its PTY never disagree)', () => {
|
||||||
|
const resizeFrames = (ws: FakeSocket) => ws.sent.filter((f) => f.t === 'z');
|
||||||
|
|
||||||
|
it('announces its size as a desktop viewer when the socket opens', async () => {
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not resend an unchanged size, sends a changed one, and force resends', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(1);
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: 100, rows: 30 };
|
||||||
|
tile.fit();
|
||||||
|
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 100, r: 30, v: 'desktop' });
|
||||||
|
|
||||||
|
tile.fit({ force: true });
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-announces an unchanged size on a reconnected socket', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { ws } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
ws.onclose?.({ code: 1006 });
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
const ws2 = FakeSocket.instances[1];
|
||||||
|
|
||||||
|
ws2.open();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws2)).toEqual([{ t: 'z', c: 80, r: 24, v: 'desktop' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies no 40-column floor: a pane at the divider clamp gets its real width on both sides', async () => {
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: 28, rows: 30 };
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(term.cols).toBe(28);
|
||||||
|
expect(resizeFrames(ws).at(-1)).toEqual({ t: 'z', c: 28, r: 30, v: 'desktop' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports nothing while hidden (the fit addon measures NaN)', async () => {
|
||||||
|
const { tile, ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
FakeFit.proposed = { cols: NaN, rows: NaN };
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toHaveLength(1);
|
||||||
|
expect([term.cols, term.rows]).toEqual([80, 24]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stands aside for a session detached into its own window', async () => {
|
||||||
|
const { tile, ws } = await connectTile(makeApp(), { detachedSessions: new Set(['s-tile']) });
|
||||||
|
ws.open();
|
||||||
|
FakeFit.proposed = { cols: 120, rows: 40 };
|
||||||
|
|
||||||
|
tile.fit();
|
||||||
|
|
||||||
|
expect(resizeFrames(ws)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts the column count the PTY reports, keeping its own rows', async () => {
|
||||||
|
const { ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
|
||||||
|
ws.receive({ t: 'zc', c: 132, r: 50 });
|
||||||
|
|
||||||
|
expect([term.cols, term.rows]).toEqual([132, 24]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the pane alone when the PTY agrees on width', async () => {
|
||||||
|
const { ws, term } = await connectTile(makeApp());
|
||||||
|
ws.open();
|
||||||
|
const before = term.resizes.length;
|
||||||
|
|
||||||
|
ws.receive({ t: 'zc', c: 80, r: 60 });
|
||||||
|
|
||||||
|
expect(term.resizes.length).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile links and paste follow THIS pane', () => {
|
||||||
|
it('registers the shared link provider on its own terminal, resolving its own session', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const register = vi.fn();
|
||||||
|
app.registerFilePathLinkProvider = register;
|
||||||
|
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
expect(register).toHaveBeenCalledTimes(1);
|
||||||
|
const target = register.mock.calls[0][0] as { terminal: unknown; getSessionId: () => string };
|
||||||
|
expect(target.terminal).toBe(term);
|
||||||
|
expect(target.getSessionId()).toBe('s-tile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes Ctrl+V into the paste trap with this pane's terminal and session", async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const paste = vi.fn();
|
||||||
|
app._handleImagePaste = paste;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
const handled = term.keyHandler!({ type: 'keydown', key: 'v', ctrlKey: true, code: 'KeyV' });
|
||||||
|
|
||||||
|
expect(handled).toBe(false);
|
||||||
|
expect(paste).toHaveBeenCalledWith({ terminal: term, sessionId: 's-tile' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves Ctrl+Shift+V (voice input) out of the paste trap', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const paste = vi.fn();
|
||||||
|
app._handleImagePaste = paste;
|
||||||
|
const { term } = await connectTile(app);
|
||||||
|
|
||||||
|
term.keyHandler!({ type: 'keydown', key: 'V', ctrlKey: true, shiftKey: true, code: 'KeyV' });
|
||||||
|
|
||||||
|
expect(paste).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TerminalTile claims the keyboard for the app-level shortcuts', () => {
|
||||||
|
it('focusing its terminal makes it the focused pane; destroy() hands the keyboard back', async () => {
|
||||||
|
const app = makeApp();
|
||||||
|
const { tile, term } = await connectTile(app);
|
||||||
|
expect(app._focusedTile ?? null).toBeNull();
|
||||||
|
|
||||||
|
term.focusTextarea();
|
||||||
|
expect(app._focusedTile).toBe(tile);
|
||||||
|
|
||||||
|
tile.destroy();
|
||||||
|
expect(app._focusedTile).toBeNull();
|
||||||
|
expect(term.focusListeners).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the server coming back kicks Pane B', () => {
|
||||||
|
it("handleInit's reconnect branch asks the split pane's tile to reconnect without waiting out its backoff", () => {
|
||||||
|
// handleInit needs a whole app to run, so the wiring is pinned by source;
|
||||||
|
// reconnectNow() itself is exercised above.
|
||||||
|
const appSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
|
||||||
|
const start = appSource.indexOf('if (keepTerminal && restoreId === previousActiveId');
|
||||||
|
const branch = appSource.slice(start, appSource.indexOf('} else {', start));
|
||||||
|
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(branch).toContain('this._splitPane?.reconnectNow?.();');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// test/split-pane-terminal-unit.test.ts
|
// test/terminal-tile-unit.test.ts
|
||||||
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like
|
// Port: N/A (no server/browser; TerminalTile is loaded via `vm`, like
|
||||||
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
// split-pane-auto-collapse-unit.test.ts loads the CodemanApp patches).
|
||||||
//
|
//
|
||||||
// Unit coverage for the two SplitTerminalPane (terminal-split.js) fixes from
|
// Unit coverage for the two TerminalTile (terminal-tile.js, the split pane's
|
||||||
|
// Pane B until it moved out of terminal-split.js) fixes from
|
||||||
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
// the final review of #453 that need no browser: destroy() nulling EVERY socket
|
||||||
// handler (onclose used to survive it and fire its "disconnected" write into a
|
// handler (onclose used to survive it and fire its "disconnected" write into a
|
||||||
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
// pane already torn down), and the `{t:'r'}` server-refresh path being
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
// The last block covers the scroll-to-top history pull: a burst of output leaves
|
||||||
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
// a shell pane's xterm with about one screen of scrollback while tmux holds every
|
||||||
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
// line, and Pane B (a separate xterm from the primary pane) never went back to
|
||||||
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-split.js.
|
// ask. See _maybeLoadMoreHistory / _pullHistory in terminal-tile.js.
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import vm from 'node:vm';
|
import vm from 'node:vm';
|
||||||
@@ -73,9 +74,9 @@ const fetchMock = vi.fn();
|
|||||||
const rafQueue: Array<() => void> = [];
|
const rafQueue: Array<() => void> = [];
|
||||||
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
||||||
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
const deadlines: Array<{ fn: () => void; ms: number; cleared: boolean }> = [];
|
||||||
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-split.js'), 'utf8');
|
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8');
|
||||||
|
|
||||||
function loadSplitTerminalPane() {
|
function loadTerminalTile() {
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
console: { ...console, log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||||
// The primary pane's row estimator, reduced to a line count: the pull only
|
// The primary pane's row estimator, reduced to a line count: the pull only
|
||||||
@@ -111,18 +112,18 @@ function loadSplitTerminalPane() {
|
|||||||
});
|
});
|
||||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
||||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
return (context.window as { TerminalTile: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||||
.SplitTerminalPane;
|
.TerminalTile;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SplitTerminalPane = loadSplitTerminalPane();
|
const TerminalTile = loadTerminalTile();
|
||||||
|
|
||||||
function makePane(
|
function makePane(
|
||||||
mode = 'claude',
|
mode = 'claude',
|
||||||
mount: unknown = {},
|
mount: unknown = {},
|
||||||
opts: { detachedSessions?: Set<string> } = {}
|
opts: { detachedSessions?: Set<string> } = {}
|
||||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
): PaneUnderTest & { terminal: FakeTerminal } {
|
||||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
const pane = new TerminalTile('s1', mount, { mode, ...opts });
|
||||||
pane.terminal = {
|
pane.terminal = {
|
||||||
// xterm invokes a write's callback once everything before it is parsed.
|
// xterm invokes a write's callback once everything before it is parsed.
|
||||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
||||||
@@ -175,7 +176,8 @@ function deferred<T>() {
|
|||||||
return { promise, resolve };
|
return { promise, resolve };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('Pane B disconnected');
|
// Every marker variant (reconnecting, session ended, refused, taken over) starts the same way.
|
||||||
|
const isMarker = (data: unknown) => typeof data === 'string' && data.includes('[disconnected');
|
||||||
|
|
||||||
/** Lets every microtask the vm-side promise chain queued run. */
|
/** Lets every microtask the vm-side promise chain queued run. */
|
||||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||||
@@ -187,7 +189,7 @@ beforeEach(() => {
|
|||||||
clock = 0;
|
clock = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane.destroy()', () => {
|
describe('TerminalTile.destroy()', () => {
|
||||||
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
||||||
const pane = makePane();
|
const pane = makePane();
|
||||||
const terminal = pane.terminal;
|
const terminal = pane.terminal;
|
||||||
@@ -210,7 +212,7 @@ describe('SplitTerminalPane.destroy()', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane server-refresh single-flight', () => {
|
describe('TerminalTile server-refresh single-flight', () => {
|
||||||
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
it('a refresh with nothing in flight clears and fetches straight away', async () => {
|
||||||
const pane = makePane();
|
const pane = makePane();
|
||||||
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
fetchMock.mockResolvedValueOnce(jsonResponse('one'));
|
||||||
@@ -329,7 +331,7 @@ describe('SplitTerminalPane server-refresh single-flight', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SplitTerminalPane scroll-to-top history pull', () => {
|
describe('TerminalTile scroll-to-top history pull', () => {
|
||||||
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
it('a shell pane at the top pulls a bounded window of full history and replays it', async () => {
|
||||||
const pane = makePane('shell');
|
const pane = makePane('shell');
|
||||||
const term = pane.terminal;
|
const term = pane.terminal;
|
||||||
@@ -721,8 +723,11 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
expect(connect).toContain('this._installWheelListener();');
|
expect(connect).toContain('this._installWheelListener();');
|
||||||
expect(connect).toContain('this._onLiveClear();');
|
expect(connect).toContain('this._onLiveClear();');
|
||||||
expect(connect).not.toContain('this.terminal.clear();');
|
expect(connect).not.toContain('this.terminal.clear();');
|
||||||
// The tests below drive the close through _onSocketClosed() directly.
|
// The tests below drive the close through _onSocketClosed() directly; the
|
||||||
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
|
// socket's own handler forwards the close event (and its code) there, and
|
||||||
|
// only for the current socket (_openSocket).
|
||||||
|
expect(connect).toContain('this._onSocketClosed(event);');
|
||||||
|
expect(connect).toMatch(/ws\.onclose = \(event\) => \{\s*if \(ws !== this\.ws\) return;/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a close with no pull running writes the marker straight away', () => {
|
it('a close with no pull running writes the marker straight away', () => {
|
||||||
@@ -746,9 +751,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
void pane._pullHistory();
|
void pane._pullHistory();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
const marker = expect.stringContaining('Pane B disconnected');
|
const marker = expect.stringContaining('[disconnected');
|
||||||
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
const writes = pane.terminal.write.mock.calls.map((c) => c[0]);
|
||||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
expect(writes.at(-1)).toEqual(expect.stringMatching(/\[disconnected/));
|
||||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1003,7 +1008,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
|||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
for (const call of pane.terminal.write.mock.calls) {
|
for (const call of pane.terminal.write.mock.calls) {
|
||||||
expect(call[0]).toEqual(expect.not.stringMatching(/Pane B disconnected/));
|
expect(call[0]).toEqual(expect.not.stringMatching(/\[disconnected/));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Dictation lands in the session it was started for.
|
||||||
|
*
|
||||||
|
* `VoiceInput` (voice-input.js) used to read `app.activeSessionId` when the
|
||||||
|
* transcript ARRIVED, and again when the green send button or the compose
|
||||||
|
* overlay's Send was pressed. Both happen seconds after recording started, so a
|
||||||
|
* user who switched tabs in between had their dictation typed into the other
|
||||||
|
* session. The target is now captured in `start()` (through `_focusedPane()`,
|
||||||
|
* so a second terminal pane can claim it later) and every send path uses it.
|
||||||
|
*
|
||||||
|
* Loaded via `vm` with a stubbed `app` (no jsdom).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const voiceSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/voice-input.js'), 'utf8');
|
||||||
|
|
||||||
|
type Voice = {
|
||||||
|
start: () => void;
|
||||||
|
_insertText: (text: string) => void;
|
||||||
|
_resolveProvider: () => string;
|
||||||
|
_startWebSpeech: () => void;
|
||||||
|
_showComposeOverlay: (text: string) => void;
|
||||||
|
_voiceSendHandler: (() => void) | null;
|
||||||
|
_targetSessionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function load(opts: { insertMode?: string; localEcho?: boolean; focused?: string } = {}) {
|
||||||
|
const sendInput = vi.fn(async () => {});
|
||||||
|
const sendInputAsync = vi.fn();
|
||||||
|
const appendText = vi.fn();
|
||||||
|
const showToast = vi.fn();
|
||||||
|
const gear = {
|
||||||
|
classList: { contains: () => false, add: vi.fn(), remove: vi.fn() },
|
||||||
|
innerHTML: '',
|
||||||
|
title: '',
|
||||||
|
getAttribute: () => null,
|
||||||
|
setAttribute: vi.fn(),
|
||||||
|
removeAttribute: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
};
|
||||||
|
const app: Record<string, unknown> = {
|
||||||
|
activeSessionId: 'session-a',
|
||||||
|
sessions: new Map([
|
||||||
|
['session-a', {}],
|
||||||
|
['session-b', {}],
|
||||||
|
]),
|
||||||
|
sendInput,
|
||||||
|
_sendInputAsync: sendInputAsync,
|
||||||
|
showToast,
|
||||||
|
terminal: { focus: vi.fn() },
|
||||||
|
_localEchoEnabled: !!opts.localEcho,
|
||||||
|
_localEchoOverlay: opts.localEcho ? { appendText, pendingText: '', clear: vi.fn() } : null,
|
||||||
|
};
|
||||||
|
if (opts.focused) app._focusedPane = () => ({ sessionId: opts.focused });
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
setTimeout: (fn: () => void) => fn(),
|
||||||
|
clearTimeout: () => {},
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval: () => {},
|
||||||
|
app,
|
||||||
|
localStorage: {
|
||||||
|
getItem: (key: string) =>
|
||||||
|
key === 'codeman-voice-settings' ? JSON.stringify({ insertMode: opts.insertMode || 'direct' }) : null,
|
||||||
|
setItem: () => {},
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
querySelector: (sel: string) => (sel === '.btn-settings' ? gear : null),
|
||||||
|
createElement: () => ({}),
|
||||||
|
body: { appendChild: () => {} },
|
||||||
|
},
|
||||||
|
window: {},
|
||||||
|
navigator: {},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${voiceSource}\nglobalThis.__VoiceInput = VoiceInput;`, context);
|
||||||
|
const voice = (context as unknown as { __VoiceInput: Voice }).__VoiceInput;
|
||||||
|
// Recording itself is out of scope: start() only has to pick the target.
|
||||||
|
voice._resolveProvider = () => 'webspeech';
|
||||||
|
voice._startWebSpeech = vi.fn();
|
||||||
|
return { voice, app, sendInput, sendInputAsync, appendText, showToast, gear };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('dictation target', () => {
|
||||||
|
it('sends to the session recording started in, even after a tab switch', () => {
|
||||||
|
const { voice, app, sendInput, sendInputAsync } = load();
|
||||||
|
voice.start();
|
||||||
|
app.activeSessionId = 'session-b'; // user switched tabs while speaking
|
||||||
|
|
||||||
|
voice._insertText('fix the login bug');
|
||||||
|
|
||||||
|
expect(sendInputAsync).toHaveBeenCalledWith('session-a', 'fix the login bug', { useMux: true });
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing path when the target is still the active session', () => {
|
||||||
|
const { voice, sendInput, sendInputAsync } = load();
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
voice._insertText('hello');
|
||||||
|
|
||||||
|
expect(sendInput).toHaveBeenCalledWith('hello');
|
||||||
|
expect(sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never types another session dictation into the active local-echo overlay', () => {
|
||||||
|
const { voice, app, appendText, sendInputAsync } = load({ localEcho: true });
|
||||||
|
voice.start();
|
||||||
|
app.activeSessionId = 'session-b';
|
||||||
|
|
||||||
|
voice._insertText('for session a');
|
||||||
|
|
||||||
|
expect(appendText).not.toHaveBeenCalled();
|
||||||
|
expect(sendInputAsync).toHaveBeenCalledWith('session-a', 'for session a', { useMux: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still uses the local-echo overlay for the active session', () => {
|
||||||
|
const { voice, appendText, sendInput } = load({ localEcho: true });
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
voice._insertText('typed locally');
|
||||||
|
|
||||||
|
expect(appendText).toHaveBeenCalledWith('typed locally');
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes its target from the focused pane when one is reported', () => {
|
||||||
|
const { voice, sendInputAsync } = load({ focused: 'session-b' });
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
voice._insertText('into pane b');
|
||||||
|
|
||||||
|
expect(sendInputAsync).toHaveBeenCalledWith('session-b', 'into pane b', { useMux: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the green send button sends Enter to the dictation target', () => {
|
||||||
|
const { voice, app, gear, sendInput, sendInputAsync } = load();
|
||||||
|
voice.start();
|
||||||
|
voice._insertText('ship it');
|
||||||
|
app.activeSessionId = 'session-b';
|
||||||
|
const handler = gear.addEventListener.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as () => void;
|
||||||
|
|
||||||
|
handler();
|
||||||
|
|
||||||
|
expect(sendInputAsync).toHaveBeenLastCalledWith('session-a', '\r', { useMux: true });
|
||||||
|
// Only the original insert went through sendInput (target was active then).
|
||||||
|
expect(sendInput).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops dictation for a session that closed meanwhile, with a toast', () => {
|
||||||
|
const { voice, app, sendInput, sendInputAsync, showToast } = load();
|
||||||
|
voice.start();
|
||||||
|
app.activeSessionId = 'session-b';
|
||||||
|
(app.sessions as Map<string, unknown>).delete('session-a');
|
||||||
|
|
||||||
|
voice._insertText('too late');
|
||||||
|
|
||||||
|
expect(sendInput).not.toHaveBeenCalled();
|
||||||
|
expect(sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
expect(showToast).toHaveBeenCalledWith('That session has closed; dictation not sent', 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to start with no session at all', () => {
|
||||||
|
const { voice, app, showToast } = load();
|
||||||
|
app.activeSessionId = null;
|
||||||
|
|
||||||
|
voice.start();
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith('No active session', 'warning');
|
||||||
|
expect(voice._targetSessionId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user