mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 00:49:41 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcdccd14c4 | ||
|
|
9e032bdc3e | ||
|
|
497711a05e | ||
|
|
fe9b209f67 | ||
|
|
fbd69e62aa | ||
|
|
a54ad81684 | ||
|
|
0ec17633ba | ||
|
|
5e3dbf2057 | ||
|
|
d1bbb4cc26 | ||
|
|
e2f56dc077 | ||
|
|
ead3d34411 | ||
|
|
f1537a7887 |
@@ -250,7 +250,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Run launch synchronization**: the Run entrypoint holds an in-flight lock and disables `#runBtn` for the whole launch (≥500ms) so a double click cannot create duplicate `w<n>-<case>` sessions; `_ensureCreatedSessionVisible()` runs before `selectSession()` and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first both render exactly one tab. ⚠️ **Closing has the mirror-image race**: `closeSession()` must read `wasActive` BEFORE its `await` and announce the delete via `_closingSessions`, and `_onSessionDeleted` skips the active-session handoff for ids in that set; never read `activeSessionId` after the fact. The fallback picks the first `sessionOrder` entry still in `sessions`. Tests: `test/session-close-fallback.test.ts`. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization)
|
||||
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`) and draws EVERY family, emphasizing the selected tab's (what it spawned, and the family it was spawned into: `lineage-family--focus`, thicker, opaque, drawn last; lanes stay in strip order), so a selection change must redraw (`_updateActiveTabImmediate`, gated on `_lineageTotalEdges`), and again on the strip's size `transitionend` (the active tab widens for ~150ms after the redraw). Geometry is pure in `computeLineageTree()`: one rounded orthogonal tree per spawning tab, every route starting at the PARENT (siblings share the trunk exactly), routed ONLY through the gaps between tab rows plus a spine left of every row, so it never crosses a tab or the terminal. ⚠️ That room is reserved in CSS by `.session-tabs.lineage-tree` (`_syncLineageGutter()`, called before the wrap is measured), keyed on whether ANY family exists, never on the selection, or every tab switch would resize the header and the PTY. Colors are keyed on the SPAWNING tab, claimed in strip order for every family, and memoized (never by draw index or selection order). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip endpoints scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
**Session lineage lines** (tab → tab it spawned, `sessionLineageLines`, per-device, desktop default ON): a create request may name its spawner via a `parentSessionId` body field or the `X-Codeman-Parent-Session` header; `resolveParentSessionId()` (route-helpers.ts) resolves it (exact id or unique ≥8-char prefix, live, visible, same owner) and ⚠️ anything unresolvable is DROPPED, never a 400. Rides `toState()`, no new SSE event. ⚠️ Rendering is a LAYER on the existing SVG pass (`_appendLineageConnectionLines` at the tail of `_updateConnectionLinesImmediate()`), geometry pure in `computeLineagePath()`: one U-bridge shape hanging from the strip bottom, colors keyed on the SPAWNING tab and memoized (never by draw index). ⚠️ Desktop only (z-index vs the fixed mobile header). ⚠️ Paths must keep `data-agent-id="lineage:<childId>"` (the entrance animation queries it); skip edges whose endpoint is scrolled out of the strip. → [architecture-invariants#session-lineage-lines-tab--tab-it-spawned](docs/architecture-invariants.md#session-lineage-lines-tab--tab-it-spawned)
|
||||
|
||||
**Auto-named sessions** (`autoNameSessions`, SYNCED, default OFF): a placeholder tab (`w3-myapp`) takes its first real prompt as a title in the `<prefix>: <title>` form, so the case identity and `w<n>` counter survive. Ownership is `SessionState.nameSource` (`placeholder` | `auto` | `manual`; the `name` setter / `PUT /api/sessions/:id/name` makes it `manual`, never touched again). ⚠️ `applyAutoName()` flips to `auto` even if the string is unchanged, so only the FIRST titled prompt names the tab. ⚠️ Only user input counts: `SessionWriteOptions.fromUser` is set by the browser WS path and `POST /api/sessions/:id/input` ONLY; any new user-input path must set it (and the send-key Shift+Enter path must call `trackUserInput()`). ⚠️ The pure tracker (`session-auto-name.ts`) sits on the raw keystroke stream with an explicit rule per key; add a rule for any new key class. ⚠️ `nameSource` also decides `--name`: only a `manual` name is pinned on the claude CLI (`Session.cliPinnedName`), since `--name` is also the `/resume` title; a rename appends a `custom-title` row to a LOCAL, non-docker transcript, and a same-name PUT is a no-op (never flips to `manual`). Tests: `test/session-auto-name.test.ts`. → [architecture-invariants#auto-named-sessions-first-prompt--tab-title](docs/architecture-invariants.md#auto-named-sessions-first-prompt--tab-title)
|
||||
|
||||
@@ -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)
|
||||
|
||||
**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)
|
||||
|
||||
@@ -325,7 +325,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -389,7 +389,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
|
||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry.
|
||||
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+C (copy selection, else interrupt) / Ctrl+Shift+C (copy, never interrupts), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active), Shift+drag (start a selection in a stripped-DECSET pane, where xterm's own Shift branch is unreachable and a Shift+drag used to select nothing; `_installShiftDragSelection`), right-click (copy the selection, the mintty/PuTTY convention, since xterm paints into a canvas and the native menu has no Copy for it; with nothing selected the native menu is left alone). Rebindable via the registry. ⚠️ **Ctrl+W is deliberately NOT bound** (owner decision): it is delete-word in every shell and agent CLI, and as Close Session it killed sessions with no confirm; `close-session` keeps `bindings: []` and stays bindable (`test/ctrl-w-never-closes.test.ts`).
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,14 @@
|
||||
**Author**: Claude (session with Tim), 2026-09-15
|
||||
**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
|
||||
|
||||
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 |
|
||||
@@ -189,7 +189,7 @@ followed by a wait races, and reports the previous turn's state.
|
||||
## Lineage
|
||||
|
||||
A create request can name the session that spawned it, through a body field or a header, and
|
||||
the dashboard then draws a lineage line from parent to child. The skill sets it automatically.
|
||||
the dashboard then draws a lineage arc from parent to child. The skill sets it automatically.
|
||||
|
||||
It is resolved rather than trusted: an unresolvable parent is dropped silently rather than
|
||||
failing the spawn, because a cosmetic field must never break a worker.
|
||||
|
||||
@@ -9,13 +9,16 @@ Press `Ctrl+?` in the app for the same list in a floating overlay.
|
||||
| Shortcut | Action |
|
||||
| ------------------------------- | --------------------------------------------------------------- |
|
||||
| `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. |
|
||||
| `Ctrl+W` | Kill the active session. |
|
||||
| `Ctrl+Tab` | Next session. |
|
||||
| `Alt+[` / `Alt+]` | Previous / next tab. |
|
||||
| `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. |
|
||||
| `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. |
|
||||
| `Alt+B` | Collapse / expand the session sidebar, when that layout is on. |
|
||||
|
||||
`Ctrl+W` is not a Codeman shortcut: it goes to the terminal, where shells and agent CLIs
|
||||
use it to delete the previous word. **Close Session** has no key by default; close a session
|
||||
from its tab, or bind a key to it in App Settings → Shortcuts.
|
||||
|
||||
## Terminal
|
||||
|
||||
| Shortcut | Action |
|
||||
|
||||
@@ -158,7 +158,7 @@ enough to fix a typo an agent introduced while you are away from your desk.
|
||||
enforces it.
|
||||
- The Approvals bell. Phones get the NEEDS YOU strips on the home screen instead.
|
||||
- The desktop home tab rail, which needs a wide window.
|
||||
- Lineage lines, which are a desktop overlay.
|
||||
- Lineage arcs, which are a desktop overlay.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ the tmux server or rebooting the machine.
|
||||
| To do this | Do that |
|
||||
| ------------------------- | ------------------------------------------------------------------- |
|
||||
| Interrupt the current turn | `Ctrl+C` with nothing selected, or the **Stop** button. |
|
||||
| Close one session | `Ctrl+W`, or the tab's close control. |
|
||||
| Close one session | The tab's close control (`Ctrl+W` is delete-word in the terminal). |
|
||||
| Stop the server, keep agents | `codeman web --stop`. The tmux sessions stay alive. |
|
||||
| Stop everything | `tmux -L codeman kill-server`. |
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ every session or only the active tab.
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. |
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Spawn Lineage Lines | Arcs from a parent tab to sessions it spawned. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
| Overview Home Screen | The phone home screen. On by default. |
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ reloading while a permission prompt is blocking does not lose the red tab.
|
||||
| Jump to tab N | `Alt+1` to `Alt+9` (the number on the tab) |
|
||||
| Next / previous | `Ctrl+Tab`, `Alt+[`, `Alt+]` |
|
||||
| Move the active tab | `Ctrl+Shift+{`, `Ctrl+Shift+}` |
|
||||
| Close | `Ctrl+W` |
|
||||
| Close | The tab's close control (no key by default) |
|
||||
| Find any session, open or past | `Ctrl+K` (also `Cmd+K` and `Alt+K`) |
|
||||
|
||||
Tabs can also be dragged to reorder.
|
||||
@@ -84,18 +84,13 @@ title is derived locally from the prompt's first sentence; no text leaves the ma
|
||||
On phones the strip scrolls horizontally instead of wrapping, and the active tab is always
|
||||
scrolled into view. It is not reordered to the front, so the `Alt+N` numbering stays stable.
|
||||
|
||||
### Lineage lines
|
||||
### Lineage arcs
|
||||
|
||||
When one session spawns another (an agent starting a worker through the API), Codeman draws
|
||||
lines from the parent to each child, in the parent's colour, routed through the gaps between
|
||||
tab rows so they never cover a tab or the terminal. Every family is always shown; selecting
|
||||
a tab draws its own family thicker and brighter. A dashed branch means that child is
|
||||
working. It is how a fan-out of eight workers stays readable.
|
||||
a coloured arc under the strip connecting parent to child, with one colour per child. It is
|
||||
how a fan-out of eight workers stays readable.
|
||||
|
||||
While any tab has spawned another, the strip keeps a little extra room between rows for the
|
||||
lines, so switching tabs never changes the header height.
|
||||
|
||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Lines are
|
||||
Desktop only, and on by default. Turn it off in **App Settings → Appearance**. Arcs are
|
||||
skipped for tabs scrolled out of the strip.
|
||||
|
||||
## Header controls
|
||||
|
||||
@@ -22,18 +22,13 @@ transcript: what it was asked to do, what it is doing, and what it returned.
|
||||
This is the feature that makes a fan-out legible. Without it, a lead session that spawned
|
||||
eight workers looks like a stalled terminal for several minutes.
|
||||
|
||||
## Session lineage lines
|
||||
## Session lineage arcs
|
||||
|
||||
The tab strip draws lines from every tab to the tabs it spawned. That covers the other
|
||||
direction of fan-out: not subagents inside one session, but whole sessions started by an
|
||||
agent through the API.
|
||||
The tab strip draws a coloured arc from a parent tab to any tab it spawned, one colour per
|
||||
child. That covers the other direction of fan-out: not subagents inside one session, but
|
||||
whole sessions started by an agent through the API.
|
||||
|
||||
The lines form one tree per spawning tab, in that tab's colour, and run only through the
|
||||
gaps between tab rows, so they never cover a tab name or the terminal. Select a tab and its
|
||||
family (the tabs it spawned, or its parent and siblings) is drawn thicker and brighter. A
|
||||
dashed branch means that child is working.
|
||||
|
||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Lines are skipped
|
||||
Desktop only, on by default, and toggled in **App Settings → Appearance**. Arcs are skipped
|
||||
for tabs scrolled out of view.
|
||||
|
||||
See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent) for the spawning side.
|
||||
|
||||
+88
-30
@@ -419,7 +419,14 @@ const DEFAULT_SHORTCUTS = [
|
||||
id: 'close-session',
|
||||
group: 'Session',
|
||||
label: 'Close Session',
|
||||
bindings: [{ modifiers: ['ctrl'], key: 'w' }],
|
||||
// ⚠️ No default key. This used to be Ctrl+W, which is "delete the previous
|
||||
// word" in every shell, readline prompt and agent CLI, so muscle memory
|
||||
// killed the session (tmux and the CLI, with no confirm) mid-sentence, and
|
||||
// with the split open it was not even the pane being typed in. Ctrl+W now
|
||||
// reaches the terminal like any other key. Closing stays on the tab's close
|
||||
// control and menu (with their confirm), and anyone who wants a key binds
|
||||
// one in App Settings → Shortcuts.
|
||||
bindings: [],
|
||||
action: 'killActiveSession',
|
||||
},
|
||||
{
|
||||
@@ -867,6 +874,11 @@ class CodemanApp {
|
||||
// writes when the freshly computed descriptor is identical (COD-136).
|
||||
this._lastIndicatorDescriptor = null;
|
||||
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._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
|
||||
this._reliableMaxBytes = 256 * 1024; // cap on the persisted backlog
|
||||
@@ -3397,7 +3409,7 @@ class CodemanApp {
|
||||
} else if (msg.t === 'ia') {
|
||||
// Input ACK — the server applied (or deduped) this seq; drop it from
|
||||
// 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') {
|
||||
// Resize confirm — the geometry the PTY actually holds, which is not
|
||||
// always the one this client asked for (issue #464).
|
||||
@@ -3579,6 +3591,49 @@ class CodemanApp {
|
||||
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
|
||||
* reports). Unlike _sendInputAsync, this never enters the durable seq/ACK queue,
|
||||
@@ -3590,9 +3645,10 @@ class CodemanApp {
|
||||
*/
|
||||
_sendInputEphemeral(sessionId, input) {
|
||||
if (!sessionId || !input) return;
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
const sock = this._inputSocketFor(sessionId);
|
||||
if (sock) {
|
||||
try {
|
||||
this._ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||
sock.ws.send(JSON.stringify({ t: 'i', d: input }));
|
||||
return;
|
||||
} catch {
|
||||
// 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
|
||||
// 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).
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
const sock = this._inputSocketFor(sessionId);
|
||||
if (sock) {
|
||||
for (const rec of list) {
|
||||
if (rec.sentAt !== 0) continue;
|
||||
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.tries++;
|
||||
} catch {
|
||||
@@ -3671,7 +3728,7 @@ class CodemanApp {
|
||||
if (!cur || cur.length === 0) break;
|
||||
// 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.
|
||||
if (this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId) {
|
||||
if (this._inputSocketFor(sessionId)) {
|
||||
break;
|
||||
}
|
||||
const rec = cur[0];
|
||||
@@ -3759,8 +3816,10 @@ class CodemanApp {
|
||||
* called a duplicate is the mechanism working as designed — the original did
|
||||
* land — and re-sending it would type the same thing twice.
|
||||
*/
|
||||
_onWsInputAck(seq, msg) {
|
||||
const sessionId = this._wsSessionId;
|
||||
_onWsInputAck(seq, msg, 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 (msg && msg.err) {
|
||||
// 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()]) {
|
||||
const list = this._pendingDeliveries.get(sessionId);
|
||||
if (!list || list.length === 0) continue;
|
||||
const isActiveWs =
|
||||
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
|
||||
if (isActiveWs) {
|
||||
const sock = this._inputSocketFor(sessionId);
|
||||
if (sock) {
|
||||
const oldest = list[0];
|
||||
// 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
|
||||
// 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.
|
||||
// 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 silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs;
|
||||
const silent = Date.now() - (sock.lastRecvAt || 0) > this._reliableAckTimeoutMs;
|
||||
if (stale && silent) {
|
||||
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 {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -4528,6 +4588,10 @@ class CodemanApp {
|
||||
// a give-up) nothing else would re-establish it from here.
|
||||
if (this._wsSessionId !== restoreId) this._connectWs(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 {
|
||||
this.activeSessionId = null;
|
||||
// `auto`: the app is restoring a session on load, not a human opening
|
||||
@@ -5241,9 +5305,6 @@ class CodemanApp {
|
||||
// strip scrolls horizontally, so a tab selected from the palette, a swipe,
|
||||
// Alt+N or a push notification could stay parked off-screen.
|
||||
this._scrollActiveTabIntoView(sessionId);
|
||||
// Lineage lines draw only the SELECTED tab's family (session-lineage.js), so a
|
||||
// selection change is a redraw whenever any lineage exists at all.
|
||||
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5636,15 +5697,13 @@ class CodemanApp {
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
// The full-render path already redraws the connection SVG; this incremental
|
||||
// one does not, and a badge appearing widens a tab and shifts every tab after
|
||||
// it, sliding the lineage lines off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: any lineage at all (not just what is
|
||||
// drawn now: a parentSessionId or a child's working state can arrive on this
|
||||
// path and change the selected family's tree), or, in a VERTICAL list (the
|
||||
// sidebar, where lineage is skipped, or the rail, which can show connectors
|
||||
// with zero lineage edges too), the subagent/ultracode connectors, whose
|
||||
// rows a badge changes the HEIGHT of. updateTabOverflowMode() above has just
|
||||
// refreshed _lineageTotalEdges.
|
||||
if (this._lineageTotalEdges > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||
// it, sliding the lineage arcs off their anchors. Only pay for it when there
|
||||
// is something anchored to tab rects: lineage arcs, or — in a VERTICAL list
|
||||
// (sidebar, where lineage is skipped and the edge count stays 0, or the
|
||||
// rail, which can show connectors with zero lineage edges too) — the
|
||||
// subagent/ultracode connectors, whose rows a badge changes the HEIGHT of.
|
||||
// Same widening as the strip-scroll listener in session-lineage.js.
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList()) this.updateConnectionLines();
|
||||
|
||||
this.applySidebarFilter(this._sidebarFilter);
|
||||
}
|
||||
@@ -5656,10 +5715,6 @@ class CodemanApp {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
// Lineage routing room (session-lineage.js) changes the strip's padding and
|
||||
// row gap, so it is decided before the wrap is measured below.
|
||||
this._syncLineageGutter?.();
|
||||
|
||||
// The sidebar list is a single vertical column with its own scroller —
|
||||
// there is no row to overflow, and measuring it would fight the CSS.
|
||||
if (this.isSessionSidebarActive()) {
|
||||
@@ -9086,6 +9141,9 @@ class CodemanApp {
|
||||
const fmtBindings = (s) => {
|
||||
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
|
||||
if (!s.bindings) return '';
|
||||
// An action with no key (Close Session by default) is still listed, so the
|
||||
// overlay says so instead of showing an empty key column.
|
||||
if (s.bindings.length === 0) return '<span class="shortcut-overlay-unbound">not bound</span>';
|
||||
return s.bindings.map((b) => {
|
||||
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
|
||||
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
|
||||
|
||||
+117
-230
@@ -296,251 +296,139 @@ function computeTabScrollLeft(input) {
|
||||
return Math.min(Math.max(Math.round(target), 0), maxScroll);
|
||||
}
|
||||
|
||||
// Session lineage lines: geometry for the lines joining a tab to the tabs it
|
||||
// Session lineage lines — geometry for the arc drawn between a tab and a tab it
|
||||
// spawned (a worker started through the codeman agent skill, which passes its own
|
||||
// id as parentSessionId). Pure: the caller measures and appends, this decides.
|
||||
//
|
||||
// ONE TREE PER SPAWNING TAB. Every family is drawn, the selected tab's emphasized
|
||||
// (session-lineage.js). Every route starts at the PARENT and ends at one child, so
|
||||
// a parent's routes share their first stretch exactly: overlaid, they read as one
|
||||
// trunk with a branch per child, and a dashed (working) route stays in phase with
|
||||
// its siblings along the shared part.
|
||||
// ONE shape, because both endpoints live in the same horizontal strip and the subagent
|
||||
// shape (tab-bottom → window-top) has nothing to aim at: a U-bridge HANGING BELOW the
|
||||
// strip, from the parent's bottom edge to the child's bottom edge, so it reads as a
|
||||
// bracket joining two tabs rather than as a line crossing them. The dip grows with
|
||||
// horizontal distance and with `depth` (the child's index among its siblings), so
|
||||
// several children of one parent nest instead of overprinting.
|
||||
//
|
||||
// ⚠ ROUTES RUN IN THE GAPS, NEVER THROUGH A TAB. This replaced one bezier per
|
||||
// child hanging below the strip, which in a wrapped strip crossed every lower
|
||||
// row's labels and the terminal text (owner screenshot 2026-10-06: a parent on
|
||||
// row 3 with ten children, "too confusing"). A route now moves horizontally only
|
||||
// inside a row gap, and vertically only along a tab's own stem (its bottom edge to
|
||||
// the gap right under it) or along the SPINE, a channel left of every row that
|
||||
// joins the gaps of different rows. styles.css reserves that room
|
||||
// (`.session-tabs.lineage-tree`: a wider row gap, bottom padding for the last
|
||||
// row's gap, and the spine channel on the left of a wrapped strip).
|
||||
// ⚠ A WRAPPED STRIP USED TO GET ITS OWN SHAPE, AND THAT SHAPE WAS THE BUG. When the
|
||||
// desktop strip wraps (`tabs-two-rows` / `tabs-auto-wrap`) a parent on row 1 and its
|
||||
// child on row 2 are ~4px apart vertically, so the old parent-bottom → child-TOP bezier
|
||||
// had a 4px span to work with and drew a flat horizontal line inside the row gap
|
||||
// (reported as "they connect already, but the lines are straight and not easy visible"),
|
||||
// and three siblings drew three of them on top of each other. Aiming BOTH ends at the
|
||||
// tab BOTTOMS and putting the control points below the LOWER row gives the wrapped case
|
||||
// the same bracket as the flat case: it leaves the parent downward, crosses the lower
|
||||
// row once, and comes back up under the child. Same formula, no branch.
|
||||
//
|
||||
// Rows come from computeLineageRows() over EVERY tab in the strip, not only the
|
||||
// endpoints: a row's gap sits under its TALLEST tab (the active tab is 2px
|
||||
// taller), or siblings in one row would hang their bus at different heights.
|
||||
//
|
||||
// computeLineageTree() returns null when the parent cannot be drawn (missing or
|
||||
// degenerate rect, scrolled out of the strip); a child that cannot be drawn is
|
||||
// left out of `routes`. `.session-tabs` scrolls, so a scrolled-out tab still HAS a
|
||||
// rect, lying over the logo or the header buttons. Skipping is honest; clamping
|
||||
// would point at a tab that is not there.
|
||||
const LINEAGE_CORNER_RADIUS_PX = 10;
|
||||
// Families drawn together take separate lanes: gap lines this far apart, spines
|
||||
// LINEAGE_SPINE_STEP_PX apart.
|
||||
const LINEAGE_LANE_STEP_PX = 3.5;
|
||||
// Every family is drawn at once, and a 12px row gap only fits this many lanes at
|
||||
// LINEAGE_LANE_STEP_PX; session-lineage.js cycles families through them.
|
||||
const LINEAGE_MAX_LANES = 3;
|
||||
const LINEAGE_SPINE_INSET_PX = 6;
|
||||
const LINEAGE_SPINE_STEP_PX = 4;
|
||||
// The last row has no row below it; with no strip rect to measure, its gap is
|
||||
// taken to be this deep.
|
||||
const LINEAGE_LAST_GAP_PX = 12;
|
||||
const LINEAGE_ROW_TOLERANCE_PX = 6;
|
||||
// Returns null when the edge must not be drawn: a missing/degenerate rect, or an
|
||||
// endpoint scrolled outside the strip. `.session-tabs` is `overflow-x: auto`, so a
|
||||
// scrolled-out tab still HAS a rect — one lying over the logo or the header
|
||||
// buttons. Skipping is honest; clamping would point at a tab that isn't there.
|
||||
// ⚠ THE DIP IS WHAT MAKES THE ARC AN ARC, and it has now been mis-tuned in BOTH
|
||||
// directions, so treat these numbers as a corridor rather than a dial to crank:
|
||||
// - Too shallow (the first ship, 44px cap): a skill worker is appended to the END of
|
||||
// the strip, so a lead-to-worker span is 800-1500px, and a 44px cap over 1300px is
|
||||
// a 33px sag, a line that reads as STRAIGHT across the terminal (#285).
|
||||
// - Too deep (the 104px cap that replaced it): in the wrapped-strip case the cap and
|
||||
// the FULL row offset stacked, bowing the bracket ~106px into the terminal text
|
||||
// (owner screenshot 2026-08-15, "die Linien machen einen grossen Bogen nach unten").
|
||||
// The dip is measured from the STRIP'S BOTTOM EDGE (falling back to the lower tab
|
||||
// bottom when the strip rect is missing or shorter than its tabs), which buys two
|
||||
// things at once: the bow needs no per-row offsets stacked on top, and a same-row
|
||||
// arc between ROW-1 tabs of a wrapped strip clears row 2's labels instead of being
|
||||
// drawn through them (the retune's own first draft had exactly that regression).
|
||||
const LINEAGE_DIP_BASE_PX = 14;
|
||||
const LINEAGE_DIP_PER_PX = 0.06;
|
||||
const LINEAGE_DIP_MIN_PX = 22;
|
||||
const LINEAGE_DIP_MAX_PX = 64;
|
||||
// Siblings nest by this much. Widened with the stroke: at 2.5px plus its glow, arcs 6px
|
||||
// apart bled into one thick band instead of reading as three separate lines.
|
||||
const LINEAGE_SIBLING_STEP_PX = 8;
|
||||
const LINEAGE_STRIP_TOLERANCE_PX = 4;
|
||||
// How far the vertical rail's track sits in from the rail's left edge. It has to
|
||||
// clear the VIEWPORT edge, not just the tabs, or the line reads as a thread pinned
|
||||
// to the window frame. The rail reserves the channel itself
|
||||
// (`--lineage-vertical-gutter` on the rail's .session-tabs), and the track is
|
||||
// still clamped to stay left of every endpoint.
|
||||
// How far the vertical bracket sits in from the rail's left edge. It has to
|
||||
// clear the VIEWPORT edge, not just the tabs: the line carries an 11px outer
|
||||
// glow, so a track at 6px had half of that glow clipped away and the arc read
|
||||
// as a thin thread pinned to the window frame. The rail reserves the channel
|
||||
// itself (`--lineage-vertical-gutter` on the rail's .session-tabs), and
|
||||
// computeLineagePath still clamps the track to stay left of both tabs.
|
||||
const LINEAGE_VERTICAL_TRACK_INSET_PX = 10;
|
||||
const LINEAGE_VERTICAL_LANE_STEP_PX = 4;
|
||||
const LINEAGE_VERTICAL_SIBLING_STEP_PX = 3;
|
||||
const LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX = 4;
|
||||
// Lineage palette, assigned per SPAWNING TAB in first-seen order and cycled
|
||||
// (session-lineage.js). Every line leaving one tab shares its colour however many
|
||||
// workers it spawns; a child that spawns in turn gets its own for the lines below it.
|
||||
// (session-lineage.js). Every arc leaving one tab shares its colour however many
|
||||
// workers it spawns; a child that spawns in turn gets its own for the arcs below it.
|
||||
// The empty FIRST entry means "no override": the CSS then falls back to --session-blue,
|
||||
// which every skin block tunes for its own background, so a lone family keeps the
|
||||
// which every skin block tunes for its own background, so a lone arc keeps the
|
||||
// skin-aware blue that shipped in 1.18.2. The fixed entries are deliberately vivid
|
||||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on").
|
||||
// (owner call 2026-08-15: matrix green, pinkish, violet, red, turquoise "and so on");
|
||||
// they ride the same double glow as the blue, which is what keeps them legible over
|
||||
// terminal text on every skin.
|
||||
const LINEAGE_COLORS = ['', '#00ff66', '#ff5ea8', '#a78bfa', '#ff5252', '#2dd4bf', '#ffa940'];
|
||||
|
||||
/** A rect normalized to numbers with its edges and center, or null if unusable. */
|
||||
function lineageRect(rect) {
|
||||
if (!rect) return null;
|
||||
const left = Number(rect.left);
|
||||
const top = Number(rect.top);
|
||||
const width = Number(rect.width);
|
||||
const height = Number(rect.height);
|
||||
if (![left, top, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null;
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
cx: left + width / 2,
|
||||
cy: top + height / 2,
|
||||
};
|
||||
}
|
||||
function computeLineagePath(input) {
|
||||
const parent = input?.parent;
|
||||
const child = input?.child;
|
||||
if (!parent || !child) return null;
|
||||
|
||||
/**
|
||||
* Group tab rects into the strip's visual rows, top to bottom. A row spans from its
|
||||
* highest top to its LOWEST bottom, so a taller tab (the active one) sets the row's
|
||||
* gap for everyone in it.
|
||||
*/
|
||||
function computeLineageRows(rects) {
|
||||
const rows = [];
|
||||
for (const raw of rects || []) {
|
||||
const r = lineageRect(raw);
|
||||
if (!r) continue;
|
||||
const row = rows.find((candidate) => Math.abs(candidate.top - r.top) <= LINEAGE_ROW_TOLERANCE_PX);
|
||||
if (row) {
|
||||
row.top = Math.min(row.top, r.top);
|
||||
row.bottom = Math.max(row.bottom, r.bottom);
|
||||
} else {
|
||||
rows.push({ top: r.top, bottom: r.bottom });
|
||||
}
|
||||
}
|
||||
return rows.sort((a, b) => a.top - b.top);
|
||||
}
|
||||
const pw = Number(parent.width) || 0;
|
||||
const ph = Number(parent.height) || 0;
|
||||
const cw = Number(child.width) || 0;
|
||||
const ch = Number(child.height) || 0;
|
||||
if (pw <= 0 || ph <= 0 || cw <= 0 || ch <= 0) return null;
|
||||
|
||||
/**
|
||||
* An orthogonal polyline as an SVG path, each corner rounded by up to `radius`
|
||||
* (never more than half of either segment, so short stems stay short). Repeated and
|
||||
* collinear points are dropped first, so a degenerate corner draws nothing odd.
|
||||
*/
|
||||
function lineagePolylinePath(points, radius) {
|
||||
const pts = [];
|
||||
for (const p of points) {
|
||||
const prev = pts[pts.length - 1];
|
||||
if (prev && Math.abs(prev[0] - p[0]) < 0.5 && Math.abs(prev[1] - p[1]) < 0.5) continue;
|
||||
const before = pts[pts.length - 2];
|
||||
if (before && prev) {
|
||||
const cross = (prev[0] - before[0]) * (p[1] - prev[1]) - (prev[1] - before[1]) * (p[0] - prev[0]);
|
||||
if (Math.abs(cross) < 0.01) pts.pop();
|
||||
}
|
||||
pts.push(p);
|
||||
}
|
||||
if (pts.length < 2) return null;
|
||||
let d = `M ${r1(pts[0][0])} ${r1(pts[0][1])}`;
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const [x0, y0] = pts[i - 1];
|
||||
const [x1, y1] = pts[i];
|
||||
const [x2, y2] = pts[i + 1];
|
||||
const lenIn = Math.hypot(x1 - x0, y1 - y0);
|
||||
const lenOut = Math.hypot(x2 - x1, y2 - y1);
|
||||
const r = Math.min(radius, lenIn / 2, lenOut / 2);
|
||||
if (!(r > 0.5)) {
|
||||
d += ` L ${r1(x1)} ${r1(y1)}`;
|
||||
continue;
|
||||
}
|
||||
const ax = x1 - ((x1 - x0) / lenIn) * r;
|
||||
const ay = y1 - ((y1 - y0) / lenIn) * r;
|
||||
const bx = x1 + ((x2 - x1) / lenOut) * r;
|
||||
const by = y1 + ((y2 - y1) / lenOut) * r;
|
||||
d += ` L ${r1(ax)} ${r1(ay)} Q ${r1(x1)} ${r1(y1)} ${r1(bx)} ${r1(by)}`;
|
||||
}
|
||||
const last = pts[pts.length - 1];
|
||||
return d + ` L ${r1(last[0])} ${r1(last[1])}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes from one parent tab to each of its children.
|
||||
*
|
||||
* input: { parent, children: [{ id, rect }], strip?, tabs?, orientation?, lane?,
|
||||
* laneCount?, radius? }. `tabs` is every tab rect in the strip (rows are
|
||||
* derived from it); `lane`/`laneCount` separate families drawn together.
|
||||
* Returns { routes: [{ id, points, d, endX, endY }] } or null.
|
||||
*/
|
||||
function computeLineageTree(input) {
|
||||
const parent = lineageRect(input?.parent);
|
||||
if (!parent) return null;
|
||||
const strip = lineageRect(input?.strip);
|
||||
const orientation = input?.orientation === 'vertical' ? 'vertical' : 'horizontal';
|
||||
const laneCount = Math.max(1, Math.floor(Number(input?.laneCount)) || 1);
|
||||
const lane = Math.max(0, Math.min(laneCount - 1, Math.floor(Number(input?.lane)) || 0));
|
||||
const radius = Number.isFinite(Number(input?.radius)) ? Math.max(0, Number(input.radius)) : LINEAGE_CORNER_RADIUS_PX;
|
||||
const children = [];
|
||||
for (const child of input?.children || []) {
|
||||
const rect = lineageRect(child?.rect);
|
||||
if (rect) children.push({ id: child.id, rect });
|
||||
}
|
||||
|
||||
const tol = LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const inStripY = (r) => !strip || (r.cy >= strip.top - tol && r.cy <= strip.bottom + tol);
|
||||
const inStripX = (r) => !strip || (r.cx >= strip.left - tol && r.cx <= strip.right + tol);
|
||||
const routes = [];
|
||||
const strip = input?.strip;
|
||||
const depth = Math.max(0, Math.min(6, Number(input?.depth) || 0));
|
||||
const pLeft = Number(parent.left);
|
||||
const cLeft = Number(child.left);
|
||||
const pTop = Number(parent.top);
|
||||
const cTop = Number(child.top);
|
||||
if (![pLeft, cLeft, pTop, cTop].every(Number.isFinite)) return null;
|
||||
|
||||
if (orientation === 'vertical') {
|
||||
// The rail: one track down the empty left gutter, shared by every sibling.
|
||||
if (!inStripY(parent)) return null;
|
||||
const visible = children.filter((c) => inStripY(c.rect));
|
||||
if (visible.length === 0) return { routes };
|
||||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left));
|
||||
const base = strip ? strip.left : minLeft - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
||||
const trackX = Math.min(
|
||||
base + LINEAGE_VERTICAL_TRACK_INSET_PX + lane * LINEAGE_VERTICAL_LANE_STEP_PX,
|
||||
minLeft - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX
|
||||
);
|
||||
for (const { id, rect } of visible) {
|
||||
const points = [
|
||||
[parent.left, parent.cy],
|
||||
[trackX, parent.cy],
|
||||
[trackX, rect.cy],
|
||||
[rect.left, rect.cy],
|
||||
];
|
||||
const d = lineagePolylinePath(points, radius);
|
||||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.left), endY: r1(rect.cy) });
|
||||
const py = pTop + ph / 2;
|
||||
const cy = cTop + ch / 2;
|
||||
if (strip && Number(strip.height) > 0) {
|
||||
const min = Number(strip.top) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.top) + Number(strip.height) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (py < min || py > max || cy < min || cy > max) return null;
|
||||
}
|
||||
return { routes };
|
||||
|
||||
const stripLeft =
|
||||
strip && Number.isFinite(Number(strip.left))
|
||||
? Number(strip.left)
|
||||
: Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_TRACK_INSET_PX * 2;
|
||||
const requestedTrack =
|
||||
stripLeft + LINEAGE_VERTICAL_TRACK_INSET_PX + depth * LINEAGE_VERTICAL_SIBLING_STEP_PX;
|
||||
const trackX = Math.min(requestedTrack, Math.min(pLeft, cLeft) - LINEAGE_VERTICAL_ANCHOR_CLEARANCE_PX);
|
||||
const d = `M ${r1(pLeft)} ${r1(py)} H ${r1(trackX)} V ${r1(cy)} H ${r1(cLeft)}`;
|
||||
return { d, endX: cLeft, endY: cy, sameRow: false };
|
||||
}
|
||||
|
||||
if (!inStripX(parent) || !inStripY(parent)) return null;
|
||||
const visible = children.filter((c) => inStripX(c.rect) && inStripY(c.rect));
|
||||
if (visible.length === 0) return { routes };
|
||||
|
||||
const rows = computeLineageRows([...(input?.tabs || []), parent, ...visible.map((c) => c.rect)]);
|
||||
const rowOf = (r) => rows.findIndex((row) => r.cy >= row.top - tol && r.cy <= row.bottom + tol);
|
||||
const laneOffset = (lane - (laneCount - 1) / 2) * LINEAGE_LANE_STEP_PX;
|
||||
// Y of the gap under row i, this family's lane. The offset is clamped so a busy
|
||||
// gap never pushes a lane into the tabs on either side of it.
|
||||
const gapUnder = (i) => {
|
||||
const row = rows[i];
|
||||
const next = rows[i + 1];
|
||||
let bottom;
|
||||
if (next) bottom = next.top;
|
||||
else if (strip && strip.bottom > row.bottom + 1) bottom = strip.bottom;
|
||||
else bottom = row.bottom + LINEAGE_LAST_GAP_PX;
|
||||
const half = Math.max(0, (bottom - row.bottom) / 2 - 1);
|
||||
return (row.bottom + bottom) / 2 + Math.max(-half, Math.min(half, laneOffset));
|
||||
};
|
||||
const pRow = rowOf(parent);
|
||||
const gp = gapUnder(pRow);
|
||||
const tabLefts = (input?.tabs || []).map(lineageRect).filter(Boolean).map((r) => r.left);
|
||||
const minLeft = Math.min(parent.left, ...visible.map((c) => c.rect.left), ...tabLefts);
|
||||
const spineBase = strip ? strip.left : minLeft - LINEAGE_SPINE_INSET_PX * 2;
|
||||
const spineX = Math.min(spineBase + LINEAGE_SPINE_INSET_PX + lane * LINEAGE_SPINE_STEP_PX, minLeft - 2);
|
||||
|
||||
for (const { id, rect } of visible) {
|
||||
const cRow = rowOf(rect);
|
||||
const gc = gapUnder(cRow);
|
||||
const points =
|
||||
cRow === pRow
|
||||
? [
|
||||
[parent.cx, parent.bottom],
|
||||
[parent.cx, gp],
|
||||
[rect.cx, gp],
|
||||
[rect.cx, rect.bottom],
|
||||
]
|
||||
: [
|
||||
[parent.cx, parent.bottom],
|
||||
[parent.cx, gp],
|
||||
[spineX, gp],
|
||||
[spineX, gc],
|
||||
[rect.cx, gc],
|
||||
[rect.cx, rect.bottom],
|
||||
];
|
||||
const d = lineagePolylinePath(points, radius);
|
||||
if (d) routes.push({ id, points: roundPoints(points), d, endX: r1(rect.cx), endY: r1(rect.bottom) });
|
||||
const px = pLeft + pw / 2;
|
||||
const cx = cLeft + cw / 2;
|
||||
if (strip && Number(strip.width) > 0) {
|
||||
const min = Number(strip.left) - LINEAGE_STRIP_TOLERANCE_PX;
|
||||
const max = Number(strip.left) + Number(strip.width) + LINEAGE_STRIP_TOLERANCE_PX;
|
||||
if (px < min || px > max || cx < min || cx > max) return null;
|
||||
}
|
||||
return { routes };
|
||||
}
|
||||
|
||||
function roundPoints(points) {
|
||||
return points.map(([x, y]) => [r1(x), r1(y)]);
|
||||
const pBottom = pTop + ph;
|
||||
const cBottom = cTop + ch;
|
||||
const sameRow = Math.abs(pTop + ph / 2 - (cTop + ch / 2)) <= Math.min(ph, ch) / 2;
|
||||
|
||||
// Both ends anchor on the tab BOTTOM, and the control points hang below the WHOLE
|
||||
// strip, so one formula covers a flat strip, a wrapped pair, and a same-row pair
|
||||
// sitting above further rows (see the corridor note above the constants).
|
||||
const span = Math.abs(cx - px);
|
||||
const stripBottom =
|
||||
strip && Number(strip.height) > 0 && Number.isFinite(Number(strip.top))
|
||||
? Number(strip.top) + Number(strip.height)
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
const baseline = Math.max(pBottom, cBottom, stripBottom);
|
||||
const dip =
|
||||
Math.min(LINEAGE_DIP_MAX_PX, Math.max(LINEAGE_DIP_MIN_PX, LINEAGE_DIP_BASE_PX + span * LINEAGE_DIP_PER_PX)) +
|
||||
depth * LINEAGE_SIBLING_STEP_PX;
|
||||
const yc = baseline + dip;
|
||||
const d = `M ${r1(px)} ${r1(pBottom)} C ${r1(px)} ${r1(yc)}, ${r1(cx)} ${r1(yc)}, ${r1(cx)} ${r1(cBottom)}`;
|
||||
return { d, endX: cx, endY: cBottom, sameRow };
|
||||
}
|
||||
|
||||
// One decimal is plenty for a screen-space path and keeps the `d` string short.
|
||||
@@ -1073,13 +961,12 @@ if (typeof window !== 'undefined') {
|
||||
plan: planWsReconnect,
|
||||
};
|
||||
window.CodemanLineage = {
|
||||
computeTree: computeLineageTree,
|
||||
computeRows: computeLineageRows,
|
||||
CORNER_RADIUS_PX: LINEAGE_CORNER_RADIUS_PX,
|
||||
LANE_STEP_PX: LINEAGE_LANE_STEP_PX,
|
||||
MAX_LANES: LINEAGE_MAX_LANES,
|
||||
SPINE_INSET_PX: LINEAGE_SPINE_INSET_PX,
|
||||
computePath: computeLineagePath,
|
||||
DIP_MIN_PX: LINEAGE_DIP_MIN_PX,
|
||||
DIP_MAX_PX: LINEAGE_DIP_MAX_PX,
|
||||
SIBLING_STEP_PX: LINEAGE_SIBLING_STEP_PX,
|
||||
VERTICAL_TRACK_INSET_PX: LINEAGE_VERTICAL_TRACK_INSET_PX,
|
||||
VERTICAL_SIBLING_STEP_PX: LINEAGE_VERTICAL_SIBLING_STEP_PX,
|
||||
COLORS: LINEAGE_COLORS,
|
||||
};
|
||||
window.CodemanConnectionLoss = {
|
||||
@@ -1727,7 +1614,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
||||
if (id === excludeId) continue;
|
||||
// A detached (popped-out) session's own window already yields its PTY
|
||||
// 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
|
||||
// the same PTY's dimensions.
|
||||
if (detachedIds?.has?.(id)) continue;
|
||||
@@ -1735,7 +1622,7 @@ function buildSplitPickerSessions(sessions, sessionOrder, excludeId, detachedIds
|
||||
if (!session) continue;
|
||||
// A session with no PTY attached (exited CLI, a crash-looped session
|
||||
// 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:
|
||||
// no terminal events, and Session.write() silently drops every keystroke
|
||||
// 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.
|
||||
// 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).
|
||||
_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 terminal = target.terminal || this.terminal;
|
||||
const sessionId = target.sessionId || this.activeSessionId;
|
||||
|
||||
// Create a hidden contenteditable div to receive the paste
|
||||
const trap = document.createElement('div');
|
||||
@@ -93,11 +98,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(function() {
|
||||
if (trap.parentNode) trap.parentNode.removeChild(trap);
|
||||
// Refocus the terminal
|
||||
if (self.terminal) self.terminal.focus();
|
||||
if (terminal) terminal.focus();
|
||||
}, 0);
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
self._uploadAndInsertImages(imageFiles);
|
||||
self._uploadAndInsertImages(imageFiles, { sessionId: sessionId });
|
||||
} else {
|
||||
// No image -- route text through xterm's paste() so bracketed-paste
|
||||
// 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
|
||||
// prompt-injection defenses.
|
||||
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.
|
||||
* 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 = {}) {
|
||||
const sessionId = this.activeSessionId;
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
@@ -179,8 +185,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const paths = results.filter(Boolean);
|
||||
if (paths.length > 0 && options.insert !== false) {
|
||||
// Insert all paths in one shot, space-separated, in selection order.
|
||||
await this.sendInput(paths.join(' '));
|
||||
// Insert all paths in one shot, space-separated, in selection order, into
|
||||
// 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.
|
||||
|
||||
@@ -807,7 +807,6 @@
|
||||
<section class="shortcut-section">
|
||||
<h4>Session</h4>
|
||||
<div class="shortcuts-grid">
|
||||
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
|
||||
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
|
||||
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
|
||||
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
|
||||
@@ -2153,7 +2152,7 @@
|
||||
<div class="set-row" id="appSettingsLineageLinesItem" data-search="lineage lines spawned worker parent connection">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Spawn Lineage Lines <span class="set-tag">desktop</span></span>
|
||||
<span class="set-row-desc">Draw lines from each session to the sessions it spawned. The selected tab's family is drawn thicker.</span>
|
||||
<span class="set-row-desc">Draw a line under the tab strip from a session to the sessions it spawned.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsLineageLines" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
@@ -3919,6 +3918,7 @@
|
||||
<script defer src="app.js"></script>
|
||||
<script defer src="tab-rail-resize.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="respawn-ui.js"></script>
|
||||
<script defer src="ralph-panel.js"></script>
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
/**
|
||||
* @fileoverview Session lineage lines: the tree joining a tab to the tabs it spawned.
|
||||
* @fileoverview Session lineage lines — the arcs joining a tab to the tabs it spawned.
|
||||
*
|
||||
* A session that starts another session (the `codeman` agent skill spawning a worker,
|
||||
* which passes its own `$CODEMAN_SESSION_ID`) gets `parentSessionId` stamped on its
|
||||
* state server-side. This module turns that field into a quiet orthogonal tree, one per
|
||||
* spawning tab, routed through the gaps between tab rows so it never crosses a label or
|
||||
* the terminal (geometry: `CodemanLineage.computeTree` in constants.js).
|
||||
*
|
||||
* EVERY FAMILY IS ALWAYS DRAWN, AND THE SELECTED TAB'S IS EMPHASIZED: the family the
|
||||
* active tab spawned, and the family it belongs to as a child, get the
|
||||
* `lineage-family--focus` group (thicker, full opacity, drawn last so nothing covers
|
||||
* it). Drawing ONLY the selected family was tried first and rejected by the owner
|
||||
* (2026-10-07: "I wanna see all the connections always"). Selection still has to
|
||||
* redraw to move the emphasis, which `_updateActiveTabImmediate()` does whenever any
|
||||
* lineage exists (`_lineageTotalEdges`).
|
||||
* state server-side. This module turns that field into the same kind of glowing
|
||||
* connection line the subagent windows use, but tab → tab, so the strip shows at a
|
||||
* glance which tab spawned which.
|
||||
*
|
||||
* It is an ADDITIONAL LAYER on the existing SVG pass, not a second pass: the core
|
||||
* `_updateConnectionLinesImmediate()` (subagent-windows.js) calls
|
||||
* `_appendLineageConnectionLines(svg, rects)` at its tail, exactly like ultracode does,
|
||||
* so every layer shares ONE batched read → write reflow and one tab-rect cache.
|
||||
*
|
||||
* Constraints that are not obvious from the code:
|
||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (lines paint
|
||||
* Two constraints that are not obvious from the code:
|
||||
* - DESKTOP ONLY. The overlay is `z-index: 999`; the desktop header is 100 (arcs paint
|
||||
* over it, which is what lets them touch tab bottoms), but under 1024px mobile.css
|
||||
* makes the header `position: fixed; z-index: 1200` and would bury them. The phone
|
||||
* strip is also a scroller where both endpoints are rarely on screen at once.
|
||||
* - The routing room is RESERVED in CSS (`.session-tabs.lineage-tree`), toggled by
|
||||
* `_syncLineageGutter()` from `updateTabOverflowMode()`. It keys on whether ANY
|
||||
* family exists, never on the selection, so switching tabs never resizes the header
|
||||
* (and with it the terminal and the PTY).
|
||||
* - Paths carry `data-agent-id="lineage:<childId>"` because that is the attribute
|
||||
* `_applyLineEntrances()` queries, so the draw-in animation and its
|
||||
* negative-`animation-delay` resume across `svg.innerHTML = ''` come for free.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency subagent-windows.js (_updateConnectionLinesImmediate, #connectionLines)
|
||||
* @dependency constants.js (window.CodemanLineage.computeTree + .COLORS)
|
||||
* @dependency constants.js (window.CodemanLineage.computePath + .COLORS)
|
||||
* @dependency settings-ui.js (loadAppSettingsFromStorage, getDefaultSettings)
|
||||
* @loadorder 15.6 (after ultracode-windows.js — appended to the same SVG pass)
|
||||
*/
|
||||
@@ -74,99 +62,49 @@ Object.assign(CodemanApp.prototype, {
|
||||
applyLineageLineSettings() {
|
||||
const prev = this._lineageLinesOn;
|
||||
const next = this._syncLineageLinesEnabled();
|
||||
if (prev !== next) {
|
||||
// The reserved routing room follows the setting; updateTabOverflowMode()
|
||||
// re-syncs it and re-measures the wrap with the new padding.
|
||||
this.updateTabOverflowMode?.();
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
if (prev !== next) this.updateConnectionLines();
|
||||
},
|
||||
|
||||
/**
|
||||
* Reserve (or release) the strip's routing room: `.lineage-tree` on #sessionTabs
|
||||
* widens the row gap, pads the bottom for the last row's gap, and opens the spine
|
||||
* channel on the left of a wrapped strip (styles.css). Called at the top of
|
||||
* `updateTabOverflowMode()`, so it runs on every tab render, BEFORE the wrap is
|
||||
* measured.
|
||||
* Every parent → child pair worth drawing, with the child's index among its siblings
|
||||
* (that index is what nests sibling arcs instead of overprinting them).
|
||||
*
|
||||
* Keyed on whether any family exists at all, never on which one is selected: a
|
||||
* class that followed the selection would grow and shrink the header on every tab
|
||||
* switch, and the header's height is the terminal's height.
|
||||
*
|
||||
* Only the header strip routes through reserved gaps. The vertical rail keeps its
|
||||
* own `--lineage-vertical-gutter`, and the sidebar draws no lineage.
|
||||
*/
|
||||
_syncLineageGutter() {
|
||||
const edges = this._lineageLinesEnabled() ? this._collectLineageEdges() : [];
|
||||
this._lineageTotalEdges = edges.length;
|
||||
const strip = document.getElementById('sessionTabs');
|
||||
if (!strip) return;
|
||||
const want = edges.length > 0 && !this._isVerticalTabList?.();
|
||||
if (strip.classList.contains('lineage-tree') !== want) strip.classList.toggle('lineage-tree', want);
|
||||
},
|
||||
|
||||
/**
|
||||
* Every parent → child pair, in strip order. Walks `sessionOrder` rather than the
|
||||
* sessions Map so sibling order follows the strip's own left-to-right order, which
|
||||
* is what the user sees.
|
||||
* Walks `sessionOrder` rather than the sessions Map so sibling depth follows the
|
||||
* strip's own left-to-right order, which is what the user sees.
|
||||
*/
|
||||
_collectLineageEdges() {
|
||||
const edges = [];
|
||||
if (!this.sessions || this.sessions.size < 2) return edges;
|
||||
const order = this.sessionOrder && this.sessionOrder.length ? this.sessionOrder : [...this.sessions.keys()];
|
||||
const seenPerParent = new Map();
|
||||
for (const id of order) {
|
||||
const session = this.sessions.get(id);
|
||||
const parentId = session && session.parentSessionId;
|
||||
// A parent that is gone (closed, or never came back after a restart) draws
|
||||
// nothing: the field is decoration, so a dangling one is simply not rendered.
|
||||
if (!parentId || parentId === id || !this.sessions.has(parentId)) continue;
|
||||
edges.push({ parentId, childId: id, status: session.status || 'idle' });
|
||||
const depth = seenPerParent.get(parentId) || 0;
|
||||
seenPerParent.set(parentId, depth + 1);
|
||||
edges.push({ parentId, childId: id, depth, status: session.status || 'idle' });
|
||||
}
|
||||
return edges;
|
||||
},
|
||||
|
||||
/** Every family as `{ parentId, edges }`, in strip order of first appearance. */
|
||||
_lineageFamilies(edges) {
|
||||
const families = new Map();
|
||||
for (const edge of edges) {
|
||||
if (!families.has(edge.parentId)) families.set(edge.parentId, []);
|
||||
families.get(edge.parentId).push(edge);
|
||||
}
|
||||
return [...families].map(([parentId, familyEdges]) => ({ parentId, edges: familyEdges }));
|
||||
},
|
||||
|
||||
/**
|
||||
* Parent ids of the families the selection emphasizes: the family the selected tab
|
||||
* spawned, and the family it was spawned into (its parent plus its siblings). Empty
|
||||
* when a web tab holds the stage, or the selected tab has no lineage at all.
|
||||
*/
|
||||
_lineageFocusParents(edges) {
|
||||
const parents = new Set();
|
||||
const focus = this.activeWebviewId ? null : this.activeSessionId;
|
||||
if (!focus) return parents;
|
||||
for (const edge of edges) {
|
||||
if (edge.parentId === focus || edge.childId === focus) parents.add(edge.parentId);
|
||||
}
|
||||
return parents;
|
||||
},
|
||||
|
||||
/**
|
||||
* Colour for one family, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
||||
* Colour for one arc, from CodemanLineage.COLORS, keyed on the SPAWNING tab.
|
||||
*
|
||||
* ⚠ Per PARENT, not per child: every line leaving one tab is the same colour, no
|
||||
* ⚠️ Per PARENT, not per child: every arc leaving one tab is the same colour, no
|
||||
* matter how many workers it spawns, so the strip reads as "these five came from
|
||||
* w1, those two came from w2". Keying it per child instead gave one tab's own
|
||||
* children a different colour each, which is the thing the colours exist to tell
|
||||
* apart. A child that goes on to spawn its own workers is a parent in its turn and
|
||||
* gets its own colour for the lines BELOW it, so a chain changes colour at each
|
||||
* gets its own colour for the arcs BELOW it, so a chain changes colour at each
|
||||
* generation while each generation's fan-out stays uniform.
|
||||
*
|
||||
* Assigned in FIRST-SEEN order and remembered per parent id. First-seen rather than
|
||||
* draw-index keeps a colour stable across re-renders, tab reorders and sibling
|
||||
* closes (the SVG is wiped and rebuilt constantly, so an index-based colour would
|
||||
* flicker). The draw pass claims a colour for EVERY family in strip order before it
|
||||
* draws anything, so neither the draw order (the selected family goes last) nor
|
||||
* which family was selected first ever decides who gets which colour. An empty string means "no override": the CSS falls back to
|
||||
* flicker). An empty string means "no override": the CSS falls back to
|
||||
* --session-blue, so the first spawning tab keeps the skin-aware blue.
|
||||
*/
|
||||
_lineageColorFor(parentId) {
|
||||
@@ -202,20 +140,19 @@ Object.assign(CodemanApp.prototype, {
|
||||
_appendLineageConnectionLines(svg, rects) {
|
||||
this._lineageEdgeCount = 0;
|
||||
if (!svg || !this._lineageLinesEnabled()) return;
|
||||
// Sidebar layout: the tree is routed for a horizontal strip or the vertical
|
||||
// rail. The sidebar is a vertical list with its own scroller and no reserved
|
||||
// channel; parent/child adjacency reads fine there without lines.
|
||||
// Sidebar layout: computeLineagePath()'s whole geometry — the U-bridge hung
|
||||
// from the STRIP's bottom edge, the 64px dip corridor — assumes a horizontal
|
||||
// tab row. Against a vertical list the "strip bottom" is the bottom of the
|
||||
// sidebar, so every arc would draw a giant loop to the foot of the list.
|
||||
// Parent/child adjacency reads fine in a vertical list without arcs; a
|
||||
// sideways lineage shape is a follow-up with its own visual tuning, not a
|
||||
// by-product of a layout port.
|
||||
if (this.isSessionSidebarActive?.()) return;
|
||||
const computeTree = window.CodemanLineage && window.CodemanLineage.computeTree;
|
||||
if (!computeTree) return;
|
||||
const compute = window.CodemanLineage && window.CodemanLineage.computePath;
|
||||
if (!compute) return;
|
||||
|
||||
const edges = this._collectLineageEdges();
|
||||
this._lineageTotalEdges = edges.length;
|
||||
if (edges.length === 0) return;
|
||||
// Claim colours in strip order for every family before drawing (_lineageColorFor).
|
||||
for (const edge of edges) this._lineageColorFor(edge.parentId);
|
||||
const families = this._lineageFamilies(edges);
|
||||
const focusParents = this._lineageFocusParents(edges);
|
||||
if (!rects) rects = new Map();
|
||||
|
||||
// PHASE 1 — reads.
|
||||
@@ -225,9 +162,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
const orientation =
|
||||
document.documentElement.getAttribute('data-tab-orientation') === 'vertical' ? 'vertical' : 'horizontal';
|
||||
// A session hidden inside a collapsed group of the grouped rail has no row
|
||||
// to anchor to, so its end of the line moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). A child proxied to the same header as
|
||||
// its parent would be a line from a row to itself: skipped.
|
||||
// to anchor to, so its end of the arc moves to that group's header (a
|
||||
// "proxied" endpoint, drawn quieter). Two endpoints proxied to the SAME
|
||||
// header would be an arc from a row to itself: skipped.
|
||||
const resolveEndpoint = (id) => {
|
||||
const tab = strip.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (tab) return { key: 'tab:' + id, element: tab, proxied: false };
|
||||
@@ -236,125 +173,71 @@ Object.assign(CodemanApp.prototype, {
|
||||
const header = strip.querySelector(`[data-tab-group-header="${CSS.escape(groupId)}"]`);
|
||||
return { key: 'group:' + groupId, element: header, proxied: !!header };
|
||||
};
|
||||
const measure = (endpoint) => {
|
||||
if (!rects.has(endpoint.key)) {
|
||||
const resolvedEdges = [];
|
||||
for (const edge of edges) {
|
||||
const parentEndpoint = resolveEndpoint(edge.parentId);
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (parentEndpoint.key === childEndpoint.key) continue;
|
||||
resolvedEdges.push({ edge, parentEndpoint, childEndpoint });
|
||||
for (const endpoint of [parentEndpoint, childEndpoint]) {
|
||||
if (rects.has(endpoint.key)) continue;
|
||||
rects.set(endpoint.key, endpoint.element ? endpoint.element.getBoundingClientRect() : null);
|
||||
}
|
||||
return rects.get(endpoint.key);
|
||||
};
|
||||
const resolvedFamilies = [];
|
||||
for (const family of families) {
|
||||
const parentEndpoint = resolveEndpoint(family.parentId);
|
||||
const parentRect = measure(parentEndpoint);
|
||||
if (!parentRect) continue;
|
||||
const children = [];
|
||||
for (const edge of family.edges) {
|
||||
const childEndpoint = resolveEndpoint(edge.childId);
|
||||
if (childEndpoint.key === parentEndpoint.key) continue;
|
||||
const rect = measure(childEndpoint);
|
||||
if (rect) children.push({ edge, endpoint: childEndpoint, rect });
|
||||
}
|
||||
if (children.length > 0) resolvedFamilies.push({ family, parentEndpoint, parentRect, children });
|
||||
}
|
||||
if (resolvedFamilies.length === 0) return;
|
||||
// The header strip's rows come from EVERY tab in it (computeTree hangs a row's
|
||||
// gap under its tallest tab), web tabs included. The rail needs none.
|
||||
const tabRects = [];
|
||||
if (orientation === 'horizontal') {
|
||||
for (const tab of strip.querySelectorAll('.session-tab')) {
|
||||
const id = tab.getAttribute('data-id');
|
||||
const key = id ? 'tab:' + id : null;
|
||||
if (key && rects.has(key)) tabRects.push(rects.get(key));
|
||||
else {
|
||||
const rect = tab.getBoundingClientRect();
|
||||
if (key) rects.set(key, rect);
|
||||
tabRects.push(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
this._lineageEdgeCount = resolvedEdges.length;
|
||||
|
||||
// PHASE 2 — writes, from the cache only.
|
||||
// Lanes follow STRIP order, so a family keeps its lane when the selection moves;
|
||||
// only the DRAW order changes (the emphasized families last, on top). A row gap
|
||||
// fits a few lanes, so they cycle: families that share one are told apart by colour.
|
||||
const laneLimit = Math.max(1, (window.CodemanLineage && window.CodemanLineage.MAX_LANES) || 3);
|
||||
const laneCount = Math.min(laneLimit, resolvedFamilies.length);
|
||||
const drawOrder = resolvedFamilies
|
||||
.map((resolved, index) => ({
|
||||
...resolved,
|
||||
lane: index % laneCount,
|
||||
focus: focusParents.has(resolved.family.parentId),
|
||||
}))
|
||||
.sort((a, b) => a.focus - b.focus);
|
||||
for (const { family, parentEndpoint, parentRect, children, lane, focus } of drawOrder) {
|
||||
const geom = computeTree({
|
||||
for (const { edge, parentEndpoint, childEndpoint } of resolvedEdges) {
|
||||
const parentRect = rects.get(parentEndpoint.key);
|
||||
const childRect = rects.get(childEndpoint.key);
|
||||
if (!parentRect || !childRect) continue;
|
||||
|
||||
const geom = compute({
|
||||
parent: parentRect,
|
||||
children: children.map((c) => ({ id: c.edge.childId, rect: c.rect })),
|
||||
child: childRect,
|
||||
strip: stripRect,
|
||||
tabs: tabRects,
|
||||
depth: edge.depth,
|
||||
orientation,
|
||||
lane,
|
||||
laneCount,
|
||||
});
|
||||
if (!geom || geom.routes.length === 0) continue;
|
||||
const byId = new Map(children.map((c) => [c.edge.childId, c]));
|
||||
const color = this._lineageColorFor(family.parentId);
|
||||
// One group per family: it carries the translucency, so the stretches its
|
||||
// routes share (the trunk) do not stack into a brighter line than the branches,
|
||||
// and the emphasis for the selected tab's families (styles.css).
|
||||
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||
group.setAttribute('class', 'lineage-family' + (focus ? ' lineage-family--focus' : ''));
|
||||
group.setAttribute('data-parent-tab', family.parentId);
|
||||
// Working routes go in FIRST, so an idle sibling's solid stroke covers the
|
||||
// shared trunk and only the working child's own branch shows its dashes.
|
||||
const routes = geom.routes
|
||||
.map((route) => ({ route, child: byId.get(route.id) }))
|
||||
.filter((r) => r.child)
|
||||
.sort((a, b) => (b.child.edge.status === 'working') - (a.child.edge.status === 'working'));
|
||||
for (const { route, child } of routes) {
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', route.d);
|
||||
// `status` is the CHILD's, which is the interesting end: a working child's
|
||||
// route is dashed (and marches, motion permitting).
|
||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||
line.setAttribute(
|
||||
'class',
|
||||
'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : '')
|
||||
);
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps
|
||||
// owning weight and dash; an empty colour leaves the --session-blue fallback.
|
||||
if (color) line.style.setProperty('--lineage-color', color);
|
||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + child.edge.childId);
|
||||
line.setAttribute('data-parent-tab', family.parentId);
|
||||
line.setAttribute('data-child-tab', child.edge.childId);
|
||||
group.appendChild(line);
|
||||
}
|
||||
// Direction marker at each CHILD end, after every path so no stroke covers it.
|
||||
for (const { route, child } of routes) {
|
||||
const working = child.edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || child.endpoint.proxied;
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', String(route.endX));
|
||||
dot.setAttribute('cy', String(route.endY));
|
||||
// Fallback radius only: styles.css sizes the dot through `--lineage-dot-r`
|
||||
// (larger in an emphasized family), and `lineage-dot-pulse` breathes from it.
|
||||
dot.setAttribute('r', '2.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', child.edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
group.appendChild(dot);
|
||||
}
|
||||
svg.appendChild(group);
|
||||
this._lineageEdgeCount += routes.length;
|
||||
if (!geom) continue; // scrolled out of the strip, or a degenerate rect
|
||||
|
||||
const line = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
line.setAttribute('d', geom.d);
|
||||
// The working class marches the dashes, so an active worker is visible along
|
||||
// the line itself. `status` is the CHILD's, which is the interesting end.
|
||||
const working = edge.status === 'working' ? ' lineage-line--working' : '';
|
||||
const proxied = parentEndpoint.proxied || childEndpoint.proxied;
|
||||
line.setAttribute('class', 'connection-line lineage-line' + working + (proxied ? ' lineage-line--proxied' : ''));
|
||||
// The PARENT's colour rides a CSS custom property so the stylesheet keeps owning
|
||||
// opacity, glow and dash; an empty colour leaves the --session-blue fallback.
|
||||
// Every arc out of one tab shares it — see _lineageColorFor().
|
||||
const color = this._lineageColorFor(edge.parentId);
|
||||
if (color) line.style.setProperty('--lineage-color', color);
|
||||
// `data-agent-id` is what _applyLineEntrances() queries — see the file header.
|
||||
line.setAttribute('data-agent-id', 'lineage:' + edge.childId);
|
||||
line.setAttribute('data-parent-tab', edge.parentId);
|
||||
line.setAttribute('data-child-tab', edge.childId);
|
||||
svg.appendChild(line);
|
||||
|
||||
// Direction marker at the CHILD end. A circle rather than an SVG <marker>:
|
||||
// markers need a <defs> block and fight the dash pattern.
|
||||
const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
dot.setAttribute('cx', String(geom.endX));
|
||||
dot.setAttribute('cy', String(geom.endY));
|
||||
// Resting radius; `lineage-dot-pulse` breathes it 3.5 → 4.5 while the child
|
||||
// works, so the two have to be changed together.
|
||||
dot.setAttribute('r', '3.5');
|
||||
dot.setAttribute('class', 'lineage-line-dot' + working + (proxied ? ' lineage-line-dot--proxied' : ''));
|
||||
dot.setAttribute('data-child-tab', edge.childId);
|
||||
if (color) dot.style.setProperty('--lineage-color', color);
|
||||
svg.appendChild(dot);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The strip scrolls (desktop `overflow-x: auto` and every wrapped layout), and a
|
||||
* scroll moves both endpoints without firing any render, so the lines would slide
|
||||
* off their tabs. Passive listener, and the redraw is the normal coalesced one.
|
||||
* scroll moves both endpoints without firing any render, so the arcs would slide off
|
||||
* their tabs. Passive listener, and the redraw is the normal coalesced one.
|
||||
*
|
||||
* Installed once; the guard also keeps a re-init from stacking listeners.
|
||||
*/
|
||||
@@ -365,24 +248,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._lineageScrollHandler = () => {
|
||||
// Sidebar layout and the vertical rail scroll the SAME element
|
||||
// vertically, and there the subagent/ultracode connectors anchor to tab
|
||||
// rects too (the sidebar skips lineage entirely, and the rail can show
|
||||
// connectors with zero lineage edges, so _lineageEdgeCount alone would
|
||||
// never redraw them).
|
||||
// rects too (the sidebar skips lineage arcs entirely, and the rail can
|
||||
// show connectors with zero lineage edges, so _lineageEdgeCount alone
|
||||
// would never redraw them).
|
||||
if (this._lineageEdgeCount > 0 || this._isVerticalTabList?.()) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('scroll', this._lineageScrollHandler, { passive: true });
|
||||
|
||||
// ⚠ A SELECTION RESIZES TABS AFTER THE REDRAW. The active tab reveals its gear
|
||||
// and close icons by transitioning their padding (styles.css), so it keeps
|
||||
// widening for ~150ms after `_updateActiveTabImmediate()` has already redrawn,
|
||||
// and the tab it was selected from shrinks. That can move tabs or re-wrap a row,
|
||||
// which left a family's trunk hanging under the tab's OLD position. Redraw once
|
||||
// a size transition inside the strip ends (coalesced, like every other redraw).
|
||||
this._lineageTransitionHandler = (event) => {
|
||||
const prop = event.propertyName || '';
|
||||
if (!(prop === 'width' || prop === 'max-width' || prop.startsWith('padding'))) return;
|
||||
if (this._lineageTotalEdges > 0) this.updateConnectionLines();
|
||||
};
|
||||
strip.addEventListener('transitionend', this._lineageTransitionHandler);
|
||||
},
|
||||
});
|
||||
|
||||
+68
-88
@@ -596,22 +596,6 @@ body {
|
||||
max-height: var(--tab-strip-max-height, 40vh);
|
||||
}
|
||||
|
||||
/* Lineage routing room (`_syncLineageGutter()` in session-lineage.js, geometry in
|
||||
computeLineageTree, constants.js). Present only while some tab has spawned
|
||||
another, and never toggled by the selection, so switching tabs cannot resize
|
||||
the header (and with it the terminal). The bottom padding is the last row's
|
||||
gap; a wrapped strip also widens the row gap the routes run in, and opens the
|
||||
spine channel on the left that joins one row's gap to another's. */
|
||||
.session-tabs.lineage-tree {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.session-tabs.lineage-tree.tabs-two-rows,
|
||||
.session-tabs.lineage-tree.tabs-auto-wrap {
|
||||
row-gap: 12px;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.session-tabs::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 0;
|
||||
@@ -1697,10 +1681,9 @@ html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
|
||||
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
|
||||
the endpoint from the element's own computed value: a subagent line rests at
|
||||
0.9, a lineage line at 1 (its `.lineage-family` group carries the
|
||||
translucency), a proxied one at 0.5. Pinning 0.9 here - as `line-enter-fade`
|
||||
above still does - lands every lineage line on the wrong opacity and snaps it
|
||||
when the class comes off. */
|
||||
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
|
||||
here - as `line-enter-fade` above still does - lands every lineage line on the
|
||||
wrong opacity and snaps it when the class comes off. */
|
||||
html[data-line-anim="blur"] .connection-line.line-enter {
|
||||
animation-name: line-enter-blur;
|
||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||
@@ -8537,6 +8520,12 @@ kbd {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* An action with no key bound (Close Session by default): say so, quietly. */
|
||||
.shortcut-overlay-unbound {
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.shortcut-overlay-footer {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
@@ -10682,121 +10671,112 @@ kbd {
|
||||
}
|
||||
|
||||
/* ===== Session lineage lines (tab → tab it spawned, session-lineage.js) =====
|
||||
One quiet orthogonal tree per spawning tab, every family always drawn and
|
||||
routed through the gaps between tab rows (computeLineageTree in constants.js).
|
||||
The SELECTED tab's families (what it spawned, and the family it was spawned
|
||||
into) are emphasized by `.lineage-family--focus`: thicker, full opacity, a
|
||||
larger end dot, drawn last (owner call 2026-10-07: see every connection always,
|
||||
thicker when selected). The routing room itself is reserved on the strip by
|
||||
`.session-tabs.lineage-tree` near the top of this file.
|
||||
|
||||
⚠ QUIET ON PURPOSE (owner call 2026-10-06, "B2"): 1.5px, solid, rounded
|
||||
corners and a 1px dark outline in place of the old double glow. The old lines
|
||||
were glowing dashed beziers that hung into the terminal, and with ten children
|
||||
across a wrapped strip they read as a tangle. The lines no longer cross text,
|
||||
so they no longer need a glow to survive it. Dashes now MEAN something: only a
|
||||
working child's route is dashed.
|
||||
Deliberately quieter and thinner than the subagent lines above so the two
|
||||
layers read as different things in the same SVG.
|
||||
|
||||
Colour: every rule reads --lineage-color, which session-lineage.js sets INLINE
|
||||
per path and dot from the CodemanLineage.COLORS palette (per SPAWNING tab,
|
||||
first-seen order). The first spawning tab gets no override and falls through to
|
||||
--session-blue, which EVERY skin block already defines and tunes for its own
|
||||
background. Do not add a per-skin `.lineage-line` override inside the
|
||||
html:not([data-skin="og"]) block: a bare class rule in there resolves to
|
||||
(0,2,1) and would outrank this one from a surprising place.
|
||||
|
||||
Opacity lives on the family GROUP (`.lineage-family`), not on the paths: a
|
||||
parent's routes overlap exactly along the trunk, and translucent paths would
|
||||
stack there into a brighter line than the branches. */
|
||||
.lineage-family {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.lineage-family--focus {
|
||||
opacity: 1;
|
||||
}
|
||||
per line from the CodemanLineage.COLORS palette (per child, first-seen order,
|
||||
owner call 2026-08-15: several connected tabs must get several colours). The
|
||||
FIRST line gets no override, so it falls through to --session-blue, which EVERY
|
||||
skin block already defines and tunes for its own background; a lone arc therefore
|
||||
still renders the skin-aware blue that shipped in 1.18.2. Do not add a per-skin
|
||||
`.lineage-line` override inside the html:not([data-skin="og"]) block: a bare
|
||||
class rule in there resolves to (0,2,1) and would outrank this one from a
|
||||
surprising place.
|
||||
|
||||
⚠ BLUE, NOT THE VIOLET THIS SHIPPED WITH (owner call, 2026-08-14: "make these
|
||||
lines in blue that they are better visible"). Violet sits close to the terminal's
|
||||
own dim foreground and lost contrast the moment it crossed text. Hue therefore no
|
||||
longer separates this layer from the subagent lines, so the separation rests
|
||||
entirely on SHAPE (this one hangs under the strip and never reaches a window),
|
||||
weight and dash: keep those differences intact. Per skin the two are not even the
|
||||
same blue, since --session-blue is tuned per palette while the subagent rule
|
||||
hardcodes #3b82f6. */
|
||||
/* ⚠ QUIETER THAN THE SUBAGENT LINES, NOT INVISIBLE. The first cut ran 2px at 0.55
|
||||
with a single 5px glow, which reads on a design mock and disappears on a real
|
||||
1080p desktop: a faint thread over terminal text, exactly what it is drawn on
|
||||
top of. The weight stays UNDER the subagent lines' 3px so the two layers still
|
||||
separate, and the second, wider glow is what buys the contrast instead: it lifts
|
||||
the line off the terminal without thickening it. Dashes scale with the stroke
|
||||
(4 4 on a 2.5px line reads as a dotted smudge), and `lineage-flow` marches by
|
||||
exactly two dash cycles, so it has to move with them. */
|
||||
.connection-line.lineage-line {
|
||||
stroke: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-dasharray: 5 5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 1;
|
||||
--line-glow: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||
opacity: 0.72;
|
||||
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
|
||||
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
filter: var(--line-glow);
|
||||
}
|
||||
|
||||
/* The selected tab's families: thicker (the group above makes them opaque). */
|
||||
.lineage-family--focus .connection-line.lineage-line {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* A working child's route is dashed even with motion turned off; the march
|
||||
below is the extra. Routes start at the PARENT, so dashes on the shared trunk
|
||||
stay in phase between working siblings. */
|
||||
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
|
||||
the line exists to signal, and pairing the brightness with the marching dashes
|
||||
left every worker's arc at the resting 0.72 for anyone who turns motion off. */
|
||||
.connection-line.lineage-line--working {
|
||||
stroke-dasharray: 4 3;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line:hover {
|
||||
opacity: 1;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
/* The radius rides a variable so the pulse below can breathe from whichever size
|
||||
the dot rests at; the `r` attribute session-lineage.js sets is only a fallback. */
|
||||
.lineage-line-dot {
|
||||
--lineage-dot-r: 2.5px;
|
||||
r: var(--lineage-dot-r);
|
||||
fill: var(--lineage-color, var(--session-blue, #2b8fd9));
|
||||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
|
||||
opacity: 0.85;
|
||||
filter: drop-shadow(0 0 4px var(--lineage-color, var(--session-blue, #2b8fd9)))
|
||||
drop-shadow(0 0 9px var(--lineage-color, var(--session-blue, #2b8fd9)));
|
||||
}
|
||||
|
||||
.lineage-family--focus .lineage-line-dot {
|
||||
--lineage-dot-r: 3.5px;
|
||||
}
|
||||
|
||||
/* A line re-anchored to a collapsed group's header (its row is hidden): quieter
|
||||
/* An arc re-anchored to a collapsed group's header (its row is hidden): quieter
|
||||
and dotted, so it reads as "somewhere in there" rather than a real row. */
|
||||
.connection-line.lineage-line--proxied {
|
||||
stroke-dasharray: 1 5;
|
||||
opacity: 0.5;
|
||||
stroke-dasharray: 2 7;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
.connection-line.lineage-line--proxied.lineage-line--working {
|
||||
opacity: 0.65;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.lineage-line-dot.lineage-line-dot--proxied {
|
||||
opacity: 0.5;
|
||||
opacity: 0.46;
|
||||
}
|
||||
|
||||
/* The working route marches from parent to child, so the line itself carries
|
||||
the signal. Motion is opt-out-able at the OS level. */
|
||||
/* The child end marches while that worker is actually working, so the line
|
||||
itself carries the signal. Motion is opt-out-able at the OS level. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.connection-line.lineage-line--working {
|
||||
animation: lineage-flow 1.1s linear infinite;
|
||||
}
|
||||
|
||||
.lineage-line-dot--working {
|
||||
opacity: 1;
|
||||
animation: lineage-dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Two full dash cycles, so the march loops seamlessly. Tied to the working
|
||||
`stroke-dasharray` above: at `4 3` the cycle is 7px, so this is -14. Leaving
|
||||
them out of step makes the dashes jump once per iteration. */
|
||||
/* Two full dash cycles, so the march loops seamlessly. Tied to `stroke-dasharray`
|
||||
above: at `5 5` the cycle is 10px, so this is -20 rather than the -16 that
|
||||
matched the old `4 4`. Leaving them out of step makes the dashes jump once per
|
||||
iteration. */
|
||||
@keyframes lineage-flow {
|
||||
to {
|
||||
stroke-dashoffset: -14;
|
||||
stroke-dashoffset: -20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Breathes from the dot's own resting radius (`--lineage-dot-r`). */
|
||||
@keyframes lineage-dot-pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.75;
|
||||
r: var(--lineage-dot-r);
|
||||
r: 3.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
r: calc(var(--lineage-dot-r) + 0.8px);
|
||||
r: 4.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,640 +1,16 @@
|
||||
// src/web/public/terminal-split.js
|
||||
|
||||
/**
|
||||
* @fileoverview SplitTerminalPane — a second, independent live terminal pane
|
||||
* ("Pane B") for split-view sessions. 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
|
||||
* feature by nature — see docs/split-pane-sessions-plan.md.
|
||||
* @fileoverview Split-pane orchestration: opens a second live session
|
||||
* ("Pane B") beside the active one, in a TerminalTile (terminal-tile.js), with
|
||||
* a draggable divider, a session picker, and auto-collapse when either
|
||||
* session ends. Desktop-only; 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.5 of 16 — loaded after terminal-ui.js, before respawn-ui.js
|
||||
* @dependency terminal-tile.js (window.TerminalTile)
|
||||
* @dependency constants.js (window.CodemanSplitPane, SPLIT_PANE_MIN_WIDTH)
|
||||
* @loadorder 7.5 of 16, loaded after terminal-tile.js and 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, {
|
||||
/**
|
||||
* 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
|
||||
// sessionId === activeSessionId: two live WebSockets to the same
|
||||
// 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;
|
||||
// The picker's own exclusions (buildSplitPickerSessions in constants.js),
|
||||
// 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);
|
||||
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,
|
||||
fontSettings: this.loadAppSettingsFromStorage?.() || {},
|
||||
detachedSessions: this.detachedSessions,
|
||||
@@ -900,7 +276,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// frame). Coalesced to one call per animation frame below — a raw
|
||||
// mousemove stream fires far faster than the browser repaints, and
|
||||
// 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
|
||||
// `tmux resize-window` child plus a SIGWINCH per frame — roughly fifty
|
||||
// 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._installShiftDragSelection();
|
||||
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
|
||||
// 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
|
||||
// Alt+B sidebar toggle, or a tab-rail drag, and its PTY silently
|
||||
// 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
|
||||
};
|
||||
|
||||
@@ -1827,9 +1830,24 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Register a custom link provider for xterm.js that detects file paths
|
||||
* in terminal output and makes them clickable.
|
||||
* 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 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
|
||||
let lastInvokedLine = -1;
|
||||
@@ -1842,7 +1860,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
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
|
||||
const line = buffer.getLine(bufferLineNumber - 1);
|
||||
|
||||
@@ -1866,7 +1884,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||
buffer,
|
||||
bufferLineNumber - 1,
|
||||
self.terminal.cols,
|
||||
terminal.cols,
|
||||
MAX_STITCHED_ROWS
|
||||
);
|
||||
if (!logical) {
|
||||
@@ -1919,10 +1937,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
window.open(text, '_blank', 'noopener,noreferrer');
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
setHovered(true);
|
||||
},
|
||||
leave() {
|
||||
self._linkHovered = false;
|
||||
setHovered(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1978,17 +1996,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
// path clicked in the response viewer previewed fine. The preview
|
||||
// reads those through the guarded attachment routes, so external
|
||||
// paths route there and the two surfaces agree.
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, self.activeSessionId)) {
|
||||
self.openFilePreview(text, self.activeSessionId);
|
||||
const sessionId = getSessionId();
|
||||
if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) {
|
||||
self.openFilePreview(text, sessionId);
|
||||
return;
|
||||
}
|
||||
self.openLogViewerWindow(text, self.activeSessionId);
|
||||
self.openLogViewerWindow(text, sessionId);
|
||||
},
|
||||
hover() {
|
||||
self._linkHovered = true;
|
||||
setHovered(true);
|
||||
},
|
||||
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
|
||||
// rather than growing a second, driftable copy of the patterns.
|
||||
// See _terminalLinkAtPoint.
|
||||
this._terminalLinkProvider = provider;
|
||||
this.terminal.registerLinkProvider(provider);
|
||||
if (isPrimary) this._terminalLinkProvider = provider;
|
||||
terminal.registerLinkProvider(provider);
|
||||
|
||||
console.log('[LinkProvider] File path link provider registered');
|
||||
return provider;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -4480,8 +4500,45 @@ Object.assign(CodemanApp.prototype, {
|
||||
// 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() {
|
||||
this.terminal.clear();
|
||||
this._focusedPane().terminal?.clear();
|
||||
},
|
||||
|
||||
/** 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").
|
||||
*/
|
||||
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) {
|
||||
this.showToast('No active session', 'warning');
|
||||
return;
|
||||
@@ -4639,15 +4704,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
* terminal._core for cell dimensions, and falls back to cleaning normally if
|
||||
* a future xterm renames it. SelectionMode.COLUMN is 3.
|
||||
*/
|
||||
cleanedTerminalSelection(text) {
|
||||
const raw = text ?? (this.terminal?.hasSelection?.() ? this.terminal.getSelection() : '');
|
||||
cleanedTerminalSelection(text, target = {}) {
|
||||
// `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 (this.terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
if (terminal?._core?._selectionService?._activeSelectionMode === 3) return raw;
|
||||
const clean = window.CodemanCopySelection?.clean;
|
||||
if (!clean) return raw;
|
||||
const range = this._normalisedSelectionRange();
|
||||
const range = this._normalisedSelectionRange(terminal);
|
||||
return clean(raw, {
|
||||
margin: this._cliGutterColumns(),
|
||||
margin: this._cliGutterColumns(target.sessionId),
|
||||
firstLinePartial: !!range && range.start.x > 0,
|
||||
});
|
||||
},
|
||||
@@ -4717,8 +4785,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
|
||||
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
|
||||
// option serves plain HTTP, where navigator.clipboard is undefined.
|
||||
async copyTerminalSelection(text) {
|
||||
const selection = this.cleanedTerminalSelection(text);
|
||||
async copyTerminalSelection(text, target = {}) {
|
||||
// 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
|
||||
// alone, which are truthy, and a bare newline pasted into a chat composer
|
||||
// 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
|
||||
// later interrupt; it cleans to '' and the press reaches the PTY. What the
|
||||
// clear avoids is a highlight that sits there having copied nothing.
|
||||
this.terminal?.clearSelection?.();
|
||||
terminal?.clearSelection?.();
|
||||
this.showToast('Nothing to copy', 'warning');
|
||||
return false;
|
||||
}
|
||||
@@ -4735,14 +4806,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (ok) {
|
||||
// Clearing is what makes a second Ctrl+C an interrupt (and xterm already
|
||||
// drops the selection on any keypress, so this matches existing feel).
|
||||
this.terminal.clearSelection?.();
|
||||
terminal.clearSelection?.();
|
||||
this.showToast('Copied to clipboard', 'success');
|
||||
} else {
|
||||
this.showToast('Failed to copy', 'error');
|
||||
}
|
||||
// The execCommand fallback focuses a temp textarea, so hand focus back. This
|
||||
// is the CJK-aware focus router, not xterm's raw focus().
|
||||
this.terminal.focus();
|
||||
terminal.focus();
|
||||
return ok;
|
||||
},
|
||||
|
||||
@@ -5688,10 +5759,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update overlay font cache and re-render at new cell dimensions
|
||||
this._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontSize = size;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontSize = size;
|
||||
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._localEchoOverlay?.refreshFont();
|
||||
this._predictiveEcho?.refreshFont();
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontFamily = resolved;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontFamily = resolved;
|
||||
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 */
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.fontWeight = fontWeight;
|
||||
this._splitPane.terminal.options.fontWeightBold = fontWeightBold;
|
||||
this._splitPane.fitAddon?.fit();
|
||||
}
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.fontWeight = fontWeight;
|
||||
tile.terminal.options.fontWeightBold = fontWeightBold;
|
||||
tile.fit(); // a font change is a size change: tell its PTY too (#464)
|
||||
});
|
||||
},
|
||||
|
||||
loadFontSize() {
|
||||
@@ -6245,13 +6316,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this._splitPane?.terminal) {
|
||||
this._splitPane.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
this._splitPane.terminal.options.theme = { ...theme };
|
||||
this._forEachTile?.((tile) => {
|
||||
tile.terminal.options.minimumContrastRatio = minimumContrastRatio;
|
||||
tile.terminal.options.theme = { ...theme };
|
||||
try {
|
||||
this._splitPane.terminal.refresh(0, this._splitPane.terminal.rows - 1);
|
||||
tile.terminal.refresh(0, tile.terminal.rows - 1);
|
||||
} catch {}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -554,6 +554,11 @@ const VoiceInput = {
|
||||
_analyserSource: null, // MediaStreamSource for level meter
|
||||
_audioContext: null, // AudioContext 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() {
|
||||
this._initRecognition();
|
||||
@@ -663,10 +668,12 @@ const VoiceInput = {
|
||||
|
||||
start() {
|
||||
if (this.isRecording) return;
|
||||
if (!app.activeSessionId) {
|
||||
const target = app._focusedPane?.()?.sessionId || app.activeSessionId;
|
||||
if (!target) {
|
||||
app.showToast('No active session', 'warning');
|
||||
return;
|
||||
}
|
||||
this._targetSessionId = target;
|
||||
this._retryCount = 0;
|
||||
|
||||
const provider = this._resolveProvider();
|
||||
@@ -959,8 +966,31 @@ const VoiceInput = {
|
||||
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) {
|
||||
if (!app.activeSessionId || !text.trim()) return;
|
||||
const target = this._targetSession();
|
||||
if (!target || !text.trim()) return;
|
||||
const trimmed = text.trim();
|
||||
const mode = this._getDeepgramConfig().insertMode || 'direct';
|
||||
|
||||
@@ -975,14 +1005,17 @@ const VoiceInput = {
|
||||
this._showComposeOverlay(trimmed);
|
||||
}
|
||||
} else {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
// Direct mode: inject into local echo overlay if available, else send to PTY.
|
||||
// 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);
|
||||
} else {
|
||||
app.sendInput(trimmed).catch(() => {});
|
||||
this._sendToTarget(target, trimmed).catch(() => {});
|
||||
}
|
||||
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
|
||||
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;
|
||||
// otherwise just send \r directly to the PTY
|
||||
if (app._localEchoEnabled && app._localEchoOverlay) {
|
||||
// otherwise just send \r directly to the PTY. Both the overlay and the
|
||||
// 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 || '';
|
||||
app._localEchoOverlay.clear();
|
||||
app._localEchoOverlay.suppressBufferDetection();
|
||||
@@ -1065,7 +1103,7 @@ const VoiceInput = {
|
||||
const send = () => {
|
||||
const val = textarea.value.trim();
|
||||
overlay.remove();
|
||||
if (val) app.sendInput(val + '\r').catch(() => {});
|
||||
if (val) this._sendToTarget(this._targetSession(), val + '\r').catch(() => {});
|
||||
};
|
||||
const cancel = () => overlay.remove();
|
||||
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>';");
|
||||
});
|
||||
});
|
||||
@@ -166,9 +166,8 @@ describe('entrance animation styles', () => {
|
||||
expect(blur).not.toBeNull();
|
||||
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
|
||||
// The 100% frame deliberately omits opacity so the endpoint comes from the
|
||||
// element's own resting value: 0.9 on a subagent line, 1 on a lineage
|
||||
// line (its family group is translucent), 0.5 on a proxied one. Pinning a
|
||||
// number here snaps them.
|
||||
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
|
||||
// line, 0.95 on a working one. Pinning a number here snaps three of them.
|
||||
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
|
||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
|
||||
it('documents implemented global and tab shortcuts', () => {
|
||||
expectShortcut(helpModal, ['Ctrl', 'W'], 'Close Session');
|
||||
// Ctrl+W is NOT an app shortcut: it is delete-word in the terminal, and as
|
||||
// Close Session it killed sessions with no confirm (ctrl-w-never-closes.test.ts).
|
||||
expect(helpModal).not.toMatch(/<kbd>Ctrl<\/kbd>\s*\+\s*<kbd>W<\/kbd>/i);
|
||||
expectShortcut(helpModal, ['Ctrl', 'Tab'], 'Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', '['], 'Previous / Next Session');
|
||||
expectShortcut(helpModal, ['Alt/Option', ']'], 'Previous / Next Session');
|
||||
|
||||
@@ -143,6 +143,7 @@ function loadImageInputApp() {
|
||||
app.activeSessionId = 'session-1';
|
||||
app.showToast = vi.fn();
|
||||
app.sendInput = vi.fn(async () => {});
|
||||
app._sendInputAsync = vi.fn();
|
||||
app._normalizeImageForUpload = vi.fn(async (file) => file);
|
||||
app._uploadPasteImage = vi.fn(async (_sessionId, file: { path: string }) => file.path);
|
||||
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(app.sendInput).not.toHaveBeenCalled();
|
||||
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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' }]);
|
||||
|
||||
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', () => {
|
||||
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\)/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
const handler = appSource.slice(
|
||||
appSource.indexOf('_onWsInputAck(seq, msg)'),
|
||||
appSource.indexOf('_onWsInputAck(seq, msg, sessionId'),
|
||||
appSource.indexOf('/** Called from ws.onopen')
|
||||
);
|
||||
|
||||
|
||||
+320
-600
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
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');
|
||||
|
||||
it('swallows every event type for Shift/Ctrl+Enter', () => {
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* 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
|
||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-split.js (Pane B, a real
|
||||
* `SplitTerminalPane`) attach. Nothing restates their predicate. What stands in for the server is
|
||||
* terminal-ui.js (the main pane, `app.terminal`) and terminal-tile.js (Pane B, a real
|
||||
* `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
|
||||
* 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');
|
||||
mount.style.cssText = 'position:fixed;left:0;top:0;width:400px;height:300px;';
|
||||
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(() => {});
|
||||
// What xterm emits here is exactly what Pane B's own onData forwards to its WebSocket.
|
||||
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([]);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(shift.focused).toBe(true);
|
||||
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 { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
@@ -6,7 +6,7 @@ import { WebServer } from '../src/web/server.js';
|
||||
const PORT = 3175;
|
||||
const BASE_URL = `http://localhost:${PORT}`;
|
||||
|
||||
describe('SplitTerminalPane in a real browser', () => {
|
||||
describe('TerminalTile in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
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
|
||||
// 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
|
||||
// SplitTerminalPane.
|
||||
// TerminalTile.
|
||||
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
|
||||
return id;
|
||||
});
|
||||
@@ -48,7 +48,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
||||
mount.style.height = '300px';
|
||||
document.body.appendChild(mount);
|
||||
|
||||
const pane = new (window as any).SplitTerminalPane(id, mount);
|
||||
const pane = new (window as any).TerminalTile(id, mount);
|
||||
pane.connect();
|
||||
|
||||
// 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;
|
||||
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
|
||||
// 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
|
||||
@@ -135,7 +135,7 @@ describe('SplitTerminalPane in a real browser', () => {
|
||||
mount.style.height = '300px';
|
||||
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();
|
||||
|
||||
// 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';
|
||||
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 new Promise((resolve) => {
|
||||
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 } };
|
||||
}
|
||||
|
||||
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 teammateFits: ReturnType<typeof vi.fn>[] = [];
|
||||
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 = {
|
||||
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
|
||||
// it moves cols/rows, and `applyTerminalFontWeights` now routes its refit
|
||||
// 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', () => {
|
||||
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', () => {
|
||||
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
|
||||
// Port: N/A (no server/browser; SplitTerminalPane is loaded via `vm`, like
|
||||
// test/terminal-tile-unit.test.ts
|
||||
// Port: N/A (no server/browser; TerminalTile is loaded via `vm`, like
|
||||
// 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
|
||||
// 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
|
||||
@@ -15,7 +16,7 @@
|
||||
// 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
|
||||
// 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 { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
@@ -73,9 +74,9 @@ const fetchMock = vi.fn();
|
||||
const rafQueue: Array<() => void> = [];
|
||||
/** Recorded deadline timers (see the context's setTimeout); `fn` aborts the request. */
|
||||
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({
|
||||
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
|
||||
@@ -111,18 +112,18 @@ function loadSplitTerminalPane() {
|
||||
});
|
||||
// The module's tail patches CodemanApp.prototype; nothing on it runs here.
|
||||
vm.runInContext(`class CodemanApp { _onSessionDeleted() {} selectSession() {} }\n${SOURCE}`, context);
|
||||
return (context.window as { SplitTerminalPane: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.SplitTerminalPane;
|
||||
return (context.window as { TerminalTile: new (id: string, mount: unknown, opts?: object) => PaneUnderTest })
|
||||
.TerminalTile;
|
||||
}
|
||||
|
||||
const SplitTerminalPane = loadSplitTerminalPane();
|
||||
const TerminalTile = loadTerminalTile();
|
||||
|
||||
function makePane(
|
||||
mode = 'claude',
|
||||
mount: unknown = {},
|
||||
opts: { detachedSessions?: Set<string> } = {}
|
||||
): PaneUnderTest & { terminal: FakeTerminal } {
|
||||
const pane = new SplitTerminalPane('s1', mount, { mode, ...opts });
|
||||
const pane = new TerminalTile('s1', mount, { mode, ...opts });
|
||||
pane.terminal = {
|
||||
// xterm invokes a write's callback once everything before it is parsed.
|
||||
write: vi.fn((_data: string, done?: () => void) => done?.()),
|
||||
@@ -175,7 +176,8 @@ function deferred<T>() {
|
||||
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. */
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
@@ -187,7 +189,7 @@ beforeEach(() => {
|
||||
clock = 0;
|
||||
});
|
||||
|
||||
describe('SplitTerminalPane.destroy()', () => {
|
||||
describe('TerminalTile.destroy()', () => {
|
||||
it('nulls every WebSocket handler, onclose included, before closing the socket', () => {
|
||||
const pane = makePane();
|
||||
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 () => {
|
||||
const pane = makePane();
|
||||
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 () => {
|
||||
const pane = makePane('shell');
|
||||
const term = pane.terminal;
|
||||
@@ -721,8 +723,11 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
expect(connect).toContain('this._installWheelListener();');
|
||||
expect(connect).toContain('this._onLiveClear();');
|
||||
expect(connect).not.toContain('this.terminal.clear();');
|
||||
// The tests below drive the close through _onSocketClosed() directly.
|
||||
expect(connect).toContain('this.ws.onclose = () => this._onSocketClosed();');
|
||||
// The tests below drive the close through _onSocketClosed() directly; the
|
||||
// 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', () => {
|
||||
@@ -746,9 +751,9 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
void pane._pullHistory();
|
||||
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]);
|
||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/Pane B disconnected/));
|
||||
expect(writes.at(-1)).toEqual(expect.stringMatching(/\[disconnected/));
|
||||
expect(pane.terminal.write).toHaveBeenCalledWith(marker);
|
||||
});
|
||||
|
||||
@@ -1003,7 +1008,7 @@ describe('SplitTerminalPane scroll-to-top history pull', () => {
|
||||
await settle();
|
||||
|
||||
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