From 312a8faa06265b80e3eb379a78d8136ef009ff75 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 08:20:02 +0200 Subject: [PATCH] fix(tiles): wire the Android soft-keyboard controller into every tile (#541 parity) #541 fixed Android autocorrect duplicating the typed line in the primary pane: xterm's keyCode-229 textarea diff is append-only, so an autocorrect on space (delete a word, insert the corrected one) sent the whole line again. The fix, an edit-based diff that sends one DEL per deleted code point and then the inserted text, lives in terminal-keycode229-recovery.js together with #441's next-keydown drain (a character committed in the same task as Enter goes out ahead of the \r) and the original orphaned-insertText recovery. Only the primary pane created that controller, so a grid tile or the split's Pane B still ran xterm's stock behaviour. Both are gated on width alone (1180 CSS px), which a wide Android tablet clears. TerminalTile now creates its own controller in connect(), after the xterm opens and before the first await, handed this tile's textarea, this tile's CompositionHelper and _onTerminalData as the send path, so recovered bytes go to the tile's own session through the exactly-once queue. As in the primary pane, handleKeyEvent runs first in the custom key handler, above the keyCode-229 early return, and notifyCanonicalData sits in the onData lambda, gated on the same two CodemanTerminalInput predicates, never in _onTerminalData, which the recovered bytes also take. destroy() tears the controller down before disposing the xterm, which restores xterm's own diff and removes the capture listeners. No mode or device gate, matching the primary. The module itself is unchanged apart from its header; terminal-ui.js gains only a comment naming the twin. Tests: test/terminal-tile-input.test.ts now loads the real module into its vm harness (with window timers, without which create() would silently throw and every test would run against no controller) and drives a fake CompositionHelper carrying xterm's own append-only diff. It covers install and restore on the tile's own helper and textarea, autocorrect sent as an edit (with a control reproducing the device-log duplicate), the last character and an autocorrect each followed by Enter in one task, a self-rescued 229 key delivered once, the onData gate ignoring query replies and focus reports, two refused inserts after one keydown both recovered, robustness when the controller throws, per-tile controllers, and a source pin keeping the call above the early return. Removing the create, the handleKeyEvent call, the notify, its gate, or the destroy each turns at least one of them red, as does moving the notify into _onTerminalData. The browser suite gains a TerminalTile block in test/terminal-keycode229-recovery.browser.test.ts (real xterm, trusted execCommand input, chunks asserted to address the tile's session, with a destroyed-controller control). Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 +- docs/architecture-invariants.md | 2 +- docs/split-pane-sessions-plan.md | 11 +- docs/tile-grid-plan.md | 6 +- .../public/terminal-keycode229-recovery.js | 5 +- src/web/public/terminal-tile.js | 94 +++- src/web/public/terminal-ui.js | 3 + test/mocks/terminal-tile-fakes.ts | 31 +- ...rminal-keycode229-recovery.browser.test.ts | 192 +++++++- test/terminal-tile-input.test.ts | 419 +++++++++++++++++- 10 files changed, 745 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4ab21163..f142a8f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection and drop recovery use a bounded 1 MiB `?tail=`, and a Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Split-pane sessions** (`showSplitButton`, header button, default OFF, desktop-only, per-device): a second live session ("Pane B") beside the active one, in a `TerminalTile` (terminal-tile.js; the picker, divider and auto-collapse stay in terminal-split.js) with its own xterm + WebSocket, resizable via a draggable divider. Pane B reconnects after a drop, sends input through the exactly-once queue over its own socket (`_registerInputSocket`), has clickable paths and image paste, and owns its geometry (no 40x10 floor, `zc` columns adopted, font changes call `tile.fit()`). ⚠️ Only typed input enters that persisted queue: xterm's query replies are dropped and focus/mouse reports go out ephemeral. ⚠️ App-level terminal actions find their pane through `_focusedPane()` (the terminal focused last), never `this.terminal`. Still plainer than the primary pane (no local-echo overlay, CJK IME or touch handlers) and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [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 textarea or touch handlers), though it wires the same keyCode-229 soft-keyboard controller (Android autocorrect, #441/#541), and NOT persisted across reloads. The tile grid (below) reuses `TerminalTile`, and the two are never open together. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds, desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-device persistence (`codeman:tile-grid`, ids only, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE with the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) of `tileGridOpenSet` (constants.js: the stored grid, else an open split's two, else the tabs in order, the active one focused; trimmed or filled by `tileGridSetForCount`, a stored grid re-formed into its cells by `reformTileCells`), never a menu; right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot ` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. → [architecture-invariants#tile-grid](docs/architecture-invariants.md#tile-grid) @@ -327,7 +327,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-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `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) → `spreadsheet-preview.js`(16.5). `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 edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). `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) → `tile-grid.js`(7.6) → `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) → `spreadsheet-preview.js`(16.5). `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 edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `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 ``; 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) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 87e6a360..233632b3 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -801,7 +801,7 @@ Further detail: with many sessions the horizontal strip stops being scannable, w ### Split-pane sessions -**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell 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 copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. +**Split-pane sessions** (`showSplitButton`, header button, default OFF, per-device like `showFileViewerButton` — not in `SettingsUpdateSchema`, `displayKeys` in settings-ui.js): shows two live sessions side-by-side in one Codeman window. Pane A is the untouched, existing singleton terminal (`this.terminal`/`this._ws` in terminal-ui.js); Pane B is an independent `TerminalTile` (terminal-tile.js, constructed by the orchestration in terminal-split.js) with its own xterm instance and its own `/ws/sessions/:id/terminal` WebSocket, whose `cid` is the tab's identity with a `:tile` suffix, so it can never supersede the primary pane's socket (the registry supersedes by cid per session). ⚠️ **Pane B is deliberately plainer than Pane A** — no local-echo overlay, no CJK IME, no touch/mobile handlers, no keyboard accessory bar (it does carry the primary pane's hollow-buffer wheel paging and desktop click report, through terminal-ui.js's own gates aimed at the tile, and its own keyCode-229 soft-keyboard controller from terminal-keycode229-recovery.js, the #441 next-keydown drain and #541's edit-based diff for Android autocorrect, since the 1180px width gate is reachable by a wide Android tablet; see terminal-tile.js's fileoverview) — since this is a desktop-only feature (a split view needs a wide viewport) and those features exist for mobile/touch input; `.btn-split` is hard-hidden below 1180px regardless of the setting by the `@media (max-width: 1179px)` rule in styles.css (mobile.css only carries a comment pointing at it: that file loads up to 1023px, so it cannot cover the 1024-1179px tablet range the feature also needs to stay off), and the per-device setting means turning it on at a desk can never sync it onto a phone in the first place. No persistence: closing the browser tab or reloading always returns to the normal single-pane view; there is no localStorage key for split state. ⚠️ Splitting a session against itself is disallowed (the picker excludes the active session), **as is splitting against a popped-out (detached) session** — `buildSplitPickerSessions()` excludes `detachedSessions` because a detached session's own window is already claiming its PTY size, and `MAX_WS_PER_SESSION` needs no change since Pane A/B are always two different sessions. ⚠️ Either pane's session ending (deleted locally or from another client) auto-collapses the split — Pane A's session ending promotes Pane B to the new single pane via `selectSession(id, { auto: true })` (an app-driven selection, so it must not spend the session's idle alert — see the Approvals Inbox note above), never by trying to hot-swap the lightweight `TerminalTile` object into the primary singleton state. ⚠️ Pane B refits on every window/sidebar/tab-rail resize via the SAME trailing-edge `ResizeObserver` callback that resizes Pane A (`throttledResize` in terminal-ui.js) — it only ever measured Pane A's own container, so without the explicit `this._forEachTile?.((tile) => tile.fit(), { grid: false })` call there (grid tiles are left out: the grid's own observer refits them on the same resize) Pane B silently kept its stale PTY size through every resize that did not happen to be a divider drag. ⚠️ A dropped WebSocket writes a `[disconnected, reconnecting…]` marker into Pane B and reconnects on the primary pane's backoff ladder (`CodemanWsReconnect` plus jitter; the attempt count resets only on a successful open), then refreshes the buffer to close the output gap. ⚠️ The open handler clears `_wsClosed`/`_markerOwed` BEFORE that refresh, or the refresh re-owes the marker and stamps it under a healthy pane. ⚠️ 4003/4004/4009/4010 stop the pane for good, report once through `onExit(code)` and write a marker saying why (`CodemanWsReconnect` alone would retry 4003). ⚠️ A replacement socket detaches the old one first and every handler ignores a socket that is no longer `this.ws`, so a late 4010 from a superseded socket cannot stop its successor; `destroy()` cancels a pending reconnect. Related but distinct: `detachSession()` already opens one session in a separate OS-level browser window (`isSoloWindow`) — that is prior art for "two sessions visible at once" but not for one window with a draggable in-page divider, which is what this feature adds. ⚠️ Pane B installs its own `attachCustomKeyEventHandler` gating the same app-level chords Pane A's own handler gates (command palette, Alt+1-9/[/] tab nav, Alt+B sidebar toggle, Ctrl+Z suspend, Shift/Ctrl+Enter newline, smart-copy Ctrl+C) — without it the document capture-phase handler's `preventDefault()` (which never stops xterm) let each chord ALSO write its raw byte/escape sequence into Pane B's live PTY on top of whatever the app action did to Pane A (COD-153). Ctrl+Z is swallowed unless Pane B's own session is `mode === 'shell'`, mirroring terminal-ui.js's reasoning: in a plain shell it is the user's own job-control tool, everywhere else it silently suspends an unattended agent loop. Shift/Ctrl+Enter POSTs to `/api/sessions/:id/send-key` (`{key:'S-Enter'|'C-Enter'}`, typed by tmux `send-keys -H`: Ctrl+Enter is always a real 0x0a, Shift+Enter is the CLI's declared `capabilities.newline` chord, also 0x0a unless the CLI declares another; sent on keydown only, with the keypress and keyup swallowed too, see the keypress rule under Command palette and shortcut registry) targeting THIS pane's own `sessionId` rather than the primary pane's `activeSessionId` — without it xterm's plain `\r` would submit an incomplete prompt instead of adding a line to it. Smart-copy Ctrl+C/Ctrl+Shift+C is re-implemented against `this.terminal` (Pane B's own) rather than reusing `app.copyTerminalSelection()`, which reads Pane A's terminal and would copy the wrong pane's selection; Ctrl+Shift+C never falls through even with nothing to copy, mirroring terminal-ui.js's own `ev.shiftKey` branch. ⚠️ **This is a UX-parity fix, not an interrupt-safety one** — verified live in a real browser: xterm's `evaluateKeyboardEvent` routes a shifted ctrl-letter into a branch that assigns `c.key` only for two special cases (`_`→US, `@`→NUL), so it emits no data for Ctrl+Shift+C at all regardless of any application gate; a synthetic keydown with the gate removed produces zero WS frames, proving no accidental interrupt reaches the PTY either way. What gating the whole copy block on `hasSelection()` (an earlier draft) actually cost: with no selection, a selection-less Ctrl+Shift+C fell 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 copy attempt — Pane A always intercepts it. Ctrl+V goes through the primary pane's paste trap aimed at Pane B (`_handleImagePaste({ terminal, sessionId })`), so a pasted image uploads to Pane B's session. ⚠️ `buildSplitPickerSessions()` also excludes any session with `pid === null` (an exited CLI, a crash-looped session whose breaker tripped, a restore that never re-attached): Pane B has no equivalent of `selectSession()`'s auto re-attach POST, so a pane opened onto one has nothing reading its tmux pane — no `terminal` events ever arrive, and `Session.write()` silently drops every keystroke with no ack either way, so the loss is invisible behind a socket that reports healthy. Pane B's keystrokes go through the exactly-once queue (`_sendInputAsync`) over its own socket, registered in the app's input-socket map (`_registerInputSocket` / `_inputSocketFor`), so they are seq-tagged, ACKed (`{t:'ia'}` is routed by the RECEIVING socket's session, since the frame names none), redelivered after a drop, and the ACK clears the idle alert. ⚠️ What xterm generates never enters that persisted queue: query replies are dropped (`shouldSuppressTerminalQueryResponse`, as the primary pane drops them) and focus/mouse reports go through `_sendInputEphemeral`, or a reload would replay them as typed text. ⚠️ A SHELL Pane B pulls scrollback itself when the wheel goes up at the top of its buffer (`_maybeLoadMoreHistory`/`_pullHistory`): tmux repaints a burst of output instead of scrolling it, so Pane B's own xterm holds about one screen of scrollback while tmux holds every line, and it loaded history exactly once at connect and never again. It is the same bounded pull as Pane A's (`?full=1&tail=TERMINAL_TAIL_SIZE`, no rewrite when the window holds no more rows than the pane already has or the pane is at its `scrollback + rows` cap, and a 60 s back-off instead of 4 s when that skipped window was truncated or the pane is full, since each ask costs the server a whole-history `capture-pane`), against Pane B's OWN terminal rather than `app.terminal`, so it cannot share `_maybeRefetchFullHistory`. The wheel listener is capture-phase because xterm `stopPropagation()`s the events it consumes; the alternate-screen skip (nano, vim, less) only matters for a direct-PTY shell, since under tmux the browser xterm never enters the alternate buffer; skipped too for a detached session (mirrors `_sendResize()`'s own check and app.js's `_maybeRefetchFullHistory`), since its own window already owns its PTY size and scrollback. Live frames arriving from the response onward, a `{t:'c'}` clear frame included, are held with their arrival time (`_liveQueue`, opened right after `await fetch(...)` beside `capturedAt`; a frame from before it is replaced by the capture or written unchanged, so the pane keeps painting during the round trip) and replayed in order only if they arrived after the capture (the response's arrival stands in for the capture instant, as in `_finishBufferLoad`, so a frame inside that one round trip can be lost or doubled); the request uses the primary pane's budget (`CodemanFetchDeadline.terminalFetchDeadlineMs({ full: true })`, 45 s, 10 s only if that helper is absent) and the body read gets 10 s once the headers land, because from then on the pull holds the pane's live output. The request phase holds no live output, but it does hold the single-flight flag, so a coalesced `{t:'r'}` refresh and a marker owed by a close (below) wait for the response, at worst for that whole budget (accepted: a Codeman restart resets an in-flight request along with the socket, so that pull fails at once and stamps the marker). ⚠️ The "disconnected" marker must be the LAST thing on screen. A replay's own `\x1bc` would otherwise wipe a marker written before the pull and paint a fresh, current-looking history while `onData` keeps silently dropping every keystroke on the dead socket (a Codeman restart drops the socket while the tmux session, and so the HTTP pull, still succeeds), so `_pullHistory()` re-stamps it after the live-frame flush. A close DURING any load (a pull, a refresh; `connect()` awaits the initial load before it creates the socket, so no close lands in that one) writes nothing: `_onSocketClosed()` sets `_markerOwed` while a load's work runs (`_loadRunning`; a load that only waits in the tile grid's queue writes the marker at once, and its refresh clears the screen only when its turn comes), since written there it would sit above the held frames the pull flushes after a skip, a downgrade, a failed fetch or the deadline, above a refresh's replay, or land mid-way through a chunked replay. Each load settles the marker in its OWN `finally` (`_stampMarkerIfOwed()`), after the queue flush, EXCEPT when a trailing refresh is pending: that refresh's `clear()` is synchronous while xterm parses a `write()` on a later tick, so a marker stamped just before it lands in the freshly cleared buffer ABOVE the refresh's replay (a second, stale copy; the default fake terminal in `test/terminal-tile-unit.test.ts` writes synchronously and cannot show it, so an async-parse fake there pins it). The marker stays owed instead, and the trailing refresh, which re-owes it on a closed socket anyway, writes the one copy below its own replay. Anything that wipes the terminal on a closed socket (a replay's `\x1bc`, a refresh's `clear()`) sets `_markerOwed` too, so the marker is rewritten whether or not the close landed during the load. Tracked via `_wsClosed`/`_markerOwed` rather than routed through `_onLiveOutput()`, since a close landing before the response is stamped before the cutoff and would be dropped with the rest of the pre-capture queue. There is no "Load full history" banner in Pane B, so a shell history past that 1 MiB window stays out of reach there. Non-shell Pane B is unchanged: it already loads `full=1`, and its history is out of scope for this pull (codex and Claude's inline renderer do grow tmux history; this just isn't how they recover it). ⚠️ App-level terminal actions find their pane through `_focusedPane()`, the terminal focused LAST (not `document.activeElement`, which the mic or a header button takes): Ctrl+L, Ctrl+Shift+R, voice and image paste act on Pane B while it holds the keyboard. Ctrl+W is not an app shortcut at all (Close Session has no default key), so it reaches whichever pane is focused as delete-word. ⚠️ Geometry: `TerminalTile.fit()` sends the size the xterm actually holds, unfloored (the divider's 20% clamp leaves about 28 columns), skips an unchanged size, re-sends on every fresh socket, and adopts the PTY's column count from `{t:'zc'}`; the font, family and weight setters call `tile.fit()`, never a local refit alone (#464). Every capture a pane fetches carries a deadline covering the body (`CodemanFetchDeadline`), so a capture that never answers cannot hold its single-flight flag forever. Design: `docs/split-pane-sessions-plan.md`; the tile class: `docs/tile-grid-plan.md`. ### Tile grid diff --git a/docs/split-pane-sessions-plan.md b/docs/split-pane-sessions-plan.md index 6b3259e1..19b1f379 100644 --- a/docs/split-pane-sessions-plan.md +++ b/docs/split-pane-sessions-plan.md @@ -93,9 +93,14 @@ view needs a wide viewport). So: instance with no overlay is exactly how Codeman behaved before the local- echo overlay existed for touch devices — normal, not degraded, for a keyboard-and-mouse user. (Since moved to `TerminalTile`, terminal-tile.js, - which has gained two pieces of the primary pane through its own gates aimed - at the tile: hollow-buffer wheel paging (#555) and the desktop click report - for a CLI with `cliMouseTracking` on. See that file's fileoverview.) + which has gained three pieces of the primary pane: hollow-buffer wheel + paging (#555) and the desktop click report for a CLI with + `cliMouseTracking` on, both through the primary pane's gates aimed at the + tile, and its own keyCode-229 soft-keyboard controller + (terminal-keycode229-recovery.js: the #441 next-keydown drain and #541's + edit-based diff, so an Android autocorrect is not sent twice). The 1180px + width gate is all that keeps a phone out, and a wide Android tablet clears + it. See that file's fileoverview.) If this asymmetry actually bothers you in daily use, promoting Pane B to full parity is a scoped v2 (extract the shared logic already once you have two diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index de6612f3..da30d020 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -224,7 +224,11 @@ no `+`, owner decision 9). 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. + those features existed. (One exception, since the 1180 px gate is width + only and a wide Android tablet clears it: every tile wires the main + terminal's keyCode-229 soft-keyboard controller, terminal-keycode229-recovery.js, + so an Android autocorrect is sent as an edit rather than a duplicated line, + #541, and a character committed with Enter is not lost, #441.) - Server-side persistence of grids (named presets per owner). - Pop-out windows (`/session/:id`, solo mode) showing a grid. diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js index c6d36f0f..22e44cba 100644 --- a/src/web/public/terminal-keycode229-recovery.js +++ b/src/web/public/terminal-keycode229-recovery.js @@ -42,8 +42,9 @@ * also be a CAPTURE listener; see the measured table at the addEventListener * call below. * - * @dependency none (standalone IIFE; consumed by terminal-ui.js) - * @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller) + * @dependency none (standalone IIFE; consumed by terminal-ui.js for the primary pane and by + * terminal-tile.js for every grid tile and the split's Pane B, one controller per xterm) + * @loadorder 5.55 (before app.js/terminal-ui.js/terminal-tile.js, which create controllers) */ (function (global) { 'use strict'; diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 243d4d97..39d04ee0 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -10,7 +10,7 @@ * synchronous tmux call that blocks the server's event loop. * * Deliberately plainer than the primary pane (this.terminal/this._ws in - * terminal-ui.js): no local-echo overlay, no CJK IME, no touch/mobile + * terminal-ui.js): no local-echo overlay, no CJK IME textarea, no touch/mobile * handlers (a swipe on a touch screen pages nothing), no keyboard accessory * bar, and no SGR wheel forwarding to Claude's fullscreen renderer * (docs/tile-grid-plan.md follow-up 4). Built for wide screens; see @@ -28,10 +28,21 @@ * - The desktop click report: a plain left-click hand-encoded as SGR while * the session's CLI has mouse tracking on (cliMouseTracking), for the modes * whose mouse DECSETs the server strips (_installClickListener). + * - The soft-keyboard controller (terminal-keycode229-recovery.js), one per + * pane, on this pane's own textarea and composition helper and sending to + * this pane's session (_createKeyCode229Recovery): it forwards an + * `insertText` xterm refused, settles a pending textarea edit at the next + * keydown ahead of that key (#441: the last character an Android keyboard + * commits in the same task as Enter), and replaces xterm's append-only + * keyCode-229 diff with an edit-based one (#541: autocorrect on space + * duplicated the line). Not a desktop-only concern: the grid and the split + * are gated on width alone (SPLIT_PANE_MIN_WIDTH, 1180 CSS px), which a wide + * Android tablet, or a large foldable unfolded in landscape, reaches. * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/pageKeysForTravel, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_handleDesktopTerminalClick) + * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -185,6 +196,9 @@ this._overflowRows = 0; // The desktop click reporter (_installClickListener). this._onClick = null; + // The soft-keyboard controller (terminal-keycode229-recovery.js): created + // in connect() once the xterm is open, torn down in destroy(). + this._keyCode229Recovery = null; } async connect() { @@ -227,7 +241,26 @@ this._onFocusIn = () => global.app?._noteFocusedTile?.(this); this.terminal.textarea?.addEventListener('focus', this._onFocusIn); - this.terminal.onData((data) => this._onTerminalData(data)); + this._createKeyCode229Recovery(); + // The twin of terminal-ui.js's onData gate (initTerminal; keep the two in + // step). Canonical xterm data tells the controller this keystroke was + // delivered, but not a query reply or a focus/mouse report, which xterm + // emits on its own and which would otherwise stand a pending recovery + // down. The notify lives HERE and not in _onTerminalData(): the + // controller's own recovered bytes go through _onTerminalData() too, and + // must never count as xterm's, or a second pending character from the + // same keystroke window would stand down and be lost. + this.terminal.onData((data) => { + try { + const input = global.CodemanTerminalInput; + if (!input?.shouldSuppressTerminalQueryResponse?.(data) && !input?.isTerminalFocusOrMouseReport?.(data)) { + this._keyCode229Recovery?.notifyCanonicalData?.(); + } + } catch { + /* Bookkeeping must never block real input. */ + } + this._onTerminalData(data); + }); // xterm has no gates of its own, so every app-level chord that the // document capture-phase handler (app.js) only preventDefault()s (never @@ -242,6 +275,19 @@ // here too. Ctrl+V goes through the primary pane's paste trap // (image-input.js), aimed at this pane (below). this.terminal.attachCustomKeyEventHandler((ev) => { + // FIRST, above the IME early return below, as in terminal-ui.js: every + // keydown settles this pane's pending textarea edit and drains a + // pending recovery BEFORE xterm handles the key, so a character an + // Android keyboard committed in the same task as Enter is sent ahead + // of the \r. Below that return a keyCode-229 keydown would skip the + // settle, the drain and the snapshot, and the panes would differ. + // Read at call time, never captured, so the controller can be swapped + // (the tests count xterm's emissions through it). + try { + this._keyCode229Recovery?.handleKeyEvent?.(ev); + } catch { + /* The controller must never interfere with xterm's own handling. */ + } if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; if ( ev.altKey && @@ -552,6 +598,44 @@ app?._sendInputAsync?.(this.sessionId, data); } + // The soft-keyboard controller, the twin of the primary pane's wiring in + // terminal-ui.js initTerminal() (keep the two in step); the behaviour lives + // once, in terminal-keycode229-recovery.js. Everything it is handed is THIS + // pane's: its textarea, its xterm's CompositionHelper (whose + // `_handleAnyTextareaChanges` it patches, per instance) and its send path. + // Recovered text goes straight to _onTerminalData(), never through xterm's + // onData, so it is not counted as xterm's own (see connect()'s onData). + // Created after terminal.open(): xterm's capture `input` listener on the + // textarea is registered there, and must run before the controller's. No + // device or mode gate, as in the primary pane: with a hardware keyboard it + // costs one assignment per keydown. A failure leaves xterm's own handling. + _createKeyCode229Recovery() { + this._destroyKeyCode229Recovery(); + if (!this.terminal) return; + try { + this._keyCode229Recovery = + global.CodemanKeyCode229Recovery?.create?.({ + textarea: this.terminal.textarea, + emitRecovered: (data) => this._onTerminalData(data), + getCompositionHelper: () => this.terminal?._core?._compositionHelper, + isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true, + }) ?? null; + } catch { + this._keyCode229Recovery = null; + } + } + + // Restores xterm's own textarea diff and removes the controller's capture + // listeners from the live textarea, so it runs before terminal.dispose(). + _destroyKeyCode229Recovery() { + try { + this._keyCode229Recovery?.destroy?.(); + } catch { + /* Optional; teardown must continue. */ + } + this._keyCode229Recovery = null; + } + // 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. @@ -1154,6 +1238,10 @@ this.terminal?.textarea?.removeEventListener('focus', this._onFocusIn); this._onFocusIn = null; } + // Before dispose(): puts xterm's own textarea diff back and takes the + // controller's listeners off the textarea; its pending timers are inert + // once it is destroyed. + this._destroyKeyCode229Recovery(); // 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); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 403bcc44..92d31442 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1838,6 +1838,9 @@ Object.assign(CodemanApp.prototype, { // registers its own listener with `capture: true`; on bubble xterm's // `cancel()` (stopPropagation) would swallow exactly the handled events — // see the measured table in terminal-keycode229-recovery.js. + // Twin: TerminalTile (terminal-tile.js _createKeyCode229Recovery and its + // connect() key handler and onData) wires its own controller the same way + // for every grid tile and the split's Pane B; keep the two in step. try { this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, diff --git a/test/mocks/terminal-tile-fakes.ts b/test/mocks/terminal-tile-fakes.ts index 3bb4a349..f68428a0 100644 --- a/test/mocks/terminal-tile-fakes.ts +++ b/test/mocks/terminal-tile-fakes.ts @@ -70,6 +70,12 @@ export class FakeTerminal { * puts it. */ static emulateScroll = false; + /** + * Opt-in, set by a test BEFORE the tile connects (and reset after): extra + * fields merged into `_core`, e.g. xterm's `_compositionHelper` for the + * keyCode-229 controller, which reads it once when the tile creates it. + */ + static coreFactory: ((term: FakeTerminal) => Record) | null = null; options: Record; cols = 80; rows = 24; @@ -88,7 +94,10 @@ export class FakeTerminal { querySelector: (sel: string) => sel === '.xterm-screen' ? { getBoundingClientRect: () => ({ ...this.screenRect }) } : null, }; - _core = { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } }; + _core: Record = { + _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } }, + ...(FakeTerminal.coreFactory?.(this) ?? {}), + }; constructor(options: Record) { this.options = { ...options }; FakeTerminal.last = this; @@ -109,12 +118,26 @@ export class FakeTerminal { } keyHandler: ((ev: Record) => boolean) | null = null; focusListeners: Array<() => void> = []; + /** Every other textarea listener, with the capture flag it was added with (the keyCode-229 controller's). */ + textareaListeners: Array<{ type: string; fn: (ev: Record) => void; capture: unknown }> = []; textarea = { - addEventListener: (type: string, fn: () => void) => { - if (type === 'focus') this.focusListeners.push(fn); + /** The helper textarea's text, which xterm's keyCode-229 diff (and the controller's) reads. */ + value: '', + addEventListener: (type: string, fn: (ev?: Record) => void, capture?: unknown) => { + if (type === 'focus') this.focusListeners.push(fn as () => void); + else this.textareaListeners.push({ type, fn, capture }); }, - removeEventListener: (type: string, fn: () => void) => { + removeEventListener: (type: string, fn: (ev?: Record) => void, capture?: unknown) => { if (type === 'focus') this.focusListeners = this.focusListeners.filter((f) => f !== fn); + else { + this.textareaListeners = this.textareaListeners.filter( + (l) => !(l.type === type && l.fn === fn && Boolean(l.capture) === Boolean(capture)) + ); + } + }, + /** Delivers `ev` to the textarea's listeners of `type`, in registration order. */ + fire: (type: string, ev: Record = {}) => { + for (const l of this.textareaListeners.filter((x) => x.type === type)) l.fn({ type, ...ev }); }, }; focusTextarea() { diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts index 04d1dc8f..c8e78dbe 100644 --- a/test/terminal-keycode229-recovery.browser.test.ts +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -12,7 +12,9 @@ * - a keystroke xterm DOES handle is delivered exactly once, not twice; * - a character committed in the SAME page task as Enter reaches the send * path ahead of the `\r`, which is the ordering the zero-delay timer - * alone cannot produce. + * alone cannot produce; + * - a TerminalTile (grid tile, split Pane B) wires its own controller, so + * the same shapes come out right there too, addressed to the tile's session. * * Browser-driven, so it is excluded from `npm run test:ci` like the other * Playwright suites. Run locally: @@ -393,4 +395,192 @@ describe('orphaned terminal input recovery wiring', () => { // Byte for byte what the phone sent in the device log. expect(line).toBe('testing the peompttesting the prompt rompt '); }); + + /** + * The same keyboard shapes through a TerminalTile, the pane a grid tile and the split view's + * Pane B are made of. It wires its OWN controller (terminal-tile.js _createKeyCode229Recovery): + * its own xterm, helper textarea and composition helper, sending to its own session through + * `app._sendInputAsync(tileSessionId, …)`, never the active one. Declared after the primary + * pane's control above, which only switched the PRIMARY controller off. + */ + describe("in a TerminalTile (a grid tile, the split view's Pane B)", () => { + const TILE_ID = 'cod541-tile-browser'; + + beforeAll(async () => { + await page.evaluate(async (id) => { + const w = window as any; + const mount = document.createElement('div'); + mount.id = 'tile229Mount'; + mount.style.cssText = 'position:fixed;left:0;top:0;width:480px;height:320px;z-index:9999;'; + document.body.appendChild(mount); + // A session that does not exist: its socket is refused (4004) and the load finds + // nothing, neither of which the controller needs. Input is stubbed per test below. + const tile = new w.TerminalTile(id, mount, { mode: 'shell' }); + w.__tile229 = tile; + await tile.connect(); + }, TILE_ID); + await page.waitForFunction(() => (window as any).__tile229?._keyCode229Recovery, null, { timeout: 30000 }); + }, 60000); + + afterAll(async () => { + await page.evaluate(() => { + const w = window as any; + w.__tile229?.destroy(); + delete w.__tile229; + document.getElementById('tile229Mount')?.remove(); + }); + }); + + type Scenario = 'autocorrect' | 'lastCharThenEnter' | 'autocorrectThenEnter' | 'orphanThenEnter' | 'selfRescued'; + + /** + * Runs one keyboard shape against the tile's own textarea (scoped to its mount: + * `document.querySelector` would find the PRIMARY pane's) and reports what reached the send + * path, which session each chunk was addressed to, and how often xterm itself spoke. + */ + async function inTile(scenario: Scenario) { + return page.evaluate( + async ({ scenario, tileId }) => { + const w = window as any; + const app = w.app; + const tile = w.__tile229; + const textarea = document.querySelector('#tile229Mount .xterm-helper-textarea') as HTMLTextAreaElement; + const originalSendInput = app._sendInputAsync; + const originalSessionId = app.activeSessionId; + const rec = tile._keyCode229Recovery; + const sent: Array<[string, string]> = []; + let xtermEmitted = 0; + const keydown = (init: KeyboardEventInit, keyCode: number) => { + const down = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, composed: true, ...init }); + Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } }); + textarea.dispatchEvent(down); + }; + const key229 = () => keydown({ key: 'Unidentified' }, 229); + const enter = () => keydown({ key: 'Enter', code: 'Enter' }, 13); + const tick = () => new Promise((resolve) => setTimeout(resolve, 20)); + const typeKeys = async (text: string) => { + for (const ch of text) { + key229(); + document.execCommand('insertText', false, ch); + await tick(); + } + }; + const autocorrectEdit = () => { + key229(); + textarea.setSelectionRange(textarea.value.length - 5, textarea.value.length); + document.execCommand('delete'); + key229(); + document.execCommand('insertText', false, 'rompt '); + }; + try { + app.activeSessionId = 'cod541-not-the-tile'; + app._sendInputAsync = (sessionId: string, chunk: string) => sent.push([sessionId, chunk]); + if (scenario === 'selfRescued') { + // Counts xterm's own canonical emissions: the tile's onData reads the property at + // call time, and the controller object itself is frozen. + tile._keyCode229Recovery = { + handleKeyEvent: (e: any) => rec.handleKeyEvent(e), + notifyCanonicalData: () => { + xtermEmitted += 1; + return rec.notifyCanonicalData(); + }, + destroy: () => rec.destroy(), + }; + } + textarea.value = ''; + textarea.focus(); + + if (scenario === 'autocorrect') { + await typeKeys('testing the peompt'); + autocorrectEdit(); // both edits in one task, before any timer runs + } else if (scenario === 'lastCharThenEnter') { + await typeKeys('hell'); + key229(); + document.execCommand('insertText', false, 'o'); + enter(); // same task + } else if (scenario === 'autocorrectThenEnter') { + await typeKeys('testing the peompt'); + autocorrectEdit(); + enter(); // same task + } else if (scenario === 'orphanThenEnter') { + // #441's batched shape: a keydown, the composed insertText xterm refuses, Enter. + keydown({ key: 'Unidentified' }, 65); + textarea.value = 'o'; + textarea.dispatchEvent( + new InputEvent('input', { data: 'o', inputType: 'insertText', bubbles: true, composed: true }) + ); + enter(); + } else { + key229(); + textarea.value = 'y'; + textarea.dispatchEvent( + new InputEvent('input', { data: 'y', inputType: 'insertText', bubbles: true, composed: true }) + ); + } + await new Promise((resolve) => setTimeout(resolve, 120)); + + const raw = sent.map(([, chunk]) => chunk).join(''); + const line: string[] = []; + for (const ch of raw) { + if (ch === '\x7f') line.pop(); + else line.push(ch); + } + return { + raw, + line: line.join(''), + textarea: textarea.value, + sessions: [...new Set(sent.map(([sessionId]) => sessionId))], + xtermEmitted, + tileId, + }; + } finally { + app._sendInputAsync = originalSendInput; + app.activeSessionId = originalSessionId; + tile._keyCode229Recovery = rec; + textarea.value = ''; + } + }, + { scenario, tileId: TILE_ID } + ); + } + + it('an autocorrect on space reaches the shell once, not duplicated', async () => { + const r = await inTile('autocorrect'); + expect(r.textarea).toBe('testing the prompt '); + expect(r.line).toBe('testing the prompt '); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('a 229 last character in the same task as Enter submits the whole line', async () => { + const r = await inTile('lastCharThenEnter'); + expect(r.raw).not.toContain('\x7f'); + expect(r.line).toBe('hello\r'); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('an autocorrect plus Enter in one task submits the corrected line', async () => { + const r = await inTile('autocorrectThenEnter'); + expect(r.line).toBe('testing the prompt \r'); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('a refused insertText committed in the same task as Enter goes out BEFORE the carriage return', async () => { + const r = await inTile('orphanThenEnter'); + expect(r.raw).toBe('o\r'); + expect(r.sessions).toEqual([TILE_ID]); + }); + + it('a 229 keystroke xterm diffed itself is delivered once', async () => { + const r = await inTile('selfRescued'); + expect(r.xtermEmitted).toBe(1); + expect(r.raw).toBe('y'); + }); + + it("control: with the tile's controller destroyed, xterm alone duplicates the line", async () => { + // Keep LAST in this block: it leaves the tile's controller off. + await page.evaluate(() => (window as any).__tile229._keyCode229Recovery.destroy()); + const r = await inTile('autocorrect'); + expect(r.line).toBe('testing the peompttesting the prompt rompt '); + }); + }); }); diff --git a/test/terminal-tile-input.test.ts b/test/terminal-tile-input.test.ts index 3eddf5d9..a3873bf1 100644 --- a/test/terminal-tile-input.test.ts +++ b/test/terminal-tile-input.test.ts @@ -16,10 +16,16 @@ * 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 (test/mocks/terminal-tile-fakes.ts); - * `connect()` runs for real. + * The last blocks pin the soft-keyboard controller every tile wires + * (terminal-keycode229-recovery.js, the primary pane's #441/#541 fixes): an + * Android autocorrect is sent as an edit, not a duplicated line, a character + * committed in the same task as Enter goes out ahead of the \r, and the + * controller is bound to THIS tile's textarea, composition helper and session. + * + * Real code under test: constants.js + terminal-keycode229-recovery.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 (test/mocks/terminal-tile-fakes.ts); `connect()` runs for real. */ import { readFileSync } from 'node:fs'; import { performance } from 'node:perf_hooks'; @@ -36,6 +42,12 @@ function loadContext() { addEventListener: vi.fn(), removeEventListener: vi.fn(), CodemanBase: { base: '' }, + // The keyCode-229 controller defaults its timers to window's. Late-bound, + // like the context's own, so vi.useFakeTimers() reaches it; without them + // its create() throws into the tile's catch and every controller test + // would run against no controller at all. + setTimeout: (fn: () => void, ms?: number) => globalThis.setTimeout(fn, ms), + clearTimeout: (id: ReturnType) => globalThis.clearTimeout(id), }; const context = vm.createContext({ console: { ...console, log: vi.fn(), debug: vi.fn() }, @@ -63,7 +75,8 @@ function loadContext() { }, }); vm.runInContext( - `${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` + + `${read('constants.js')}\n${read('terminal-keycode229-recovery.js')}\n${read('app.js')}\n` + + `${read('terminal-ui.js')}\n${read('terminal-tile.js')}\n` + 'globalThis.__CodemanApp = CodemanApp;', context ); @@ -138,6 +151,7 @@ afterEach(() => { }); beforeEach(() => { + FakeTerminal.coreFactory = null; FakeFit.proposed = { cols: 80, rows: 24 }; FakeSocket.instances = []; fetchMock.mockReset(); @@ -684,3 +698,398 @@ describe('the server coming back kicks Pane B', () => { expect(branch).toContain('this._splitPane?.reconnectNow?.();'); }); }); + +/** + * xterm's CompositionHelper, reduced to what the keyCode-229 controller touches. + * Its `_handleAnyTextareaChanges` is xterm's own append-only diff as shipped + * (node_modules/@xterm/xterm/src/browser/input/CompositionHelper.ts), so a + * control without the controller reproduces the device-log duplicate, and its + * `triggerDataEvent` feeds the tile's onData, as xterm's core service does. + */ +type Helper = { + _isComposing: boolean; + _isSendingComposition: boolean; + _dataAlreadySent: string; + _coreService: { triggerDataEvent: (data: string, wasUserInput?: boolean) => void }; + _handleAnyTextareaChanges: () => void; +}; + +/** + * Gives every FakeTerminal created from now on a composition helper. Returns + * them in creation order, with xterm's own diff each one started with. + */ +function withCompositionHelpers() { + const helpers: Helper[] = []; + const originals: Array = []; + FakeTerminal.coreFactory = (term) => { + const helper: Helper = { + _isComposing: false, + _isSendingComposition: false, + _dataAlreadySent: '', + _coreService: { triggerDataEvent: (data: string) => term.type(data) }, + _handleAnyTextareaChanges(this: Helper) { + const oldValue = term.textarea.value; + setTimeout(() => { + if (this._isComposing) return; + const newValue = term.textarea.value; + const diff = newValue.replace(oldValue, ''); + this._dataAlreadySent = diff; + if (newValue.length > oldValue.length) this._coreService.triggerDataEvent(diff, true); + else if (newValue.length < oldValue.length) this._coreService.triggerDataEvent('\x7f', true); + else if (newValue !== oldValue) this._coreService.triggerDataEvent(newValue, true); + }, 0); + }, + }; + helpers.push(helper); + originals.push(helper._handleAnyTextareaChanges); + return { _compositionHelper: helper }; + }; + return Object.assign(helpers, { originals }); +} + +/** + * Drives a tile the way an Android soft keyboard drives xterm. The fake xterm + * runs no CompositionHelper.keydown of its own, so `key229()` does what xterm + * does, in xterm's order: the custom key handler first, then (keyCode 229, no + * composition) the helper's `_handleAnyTextareaChanges()`, read off the helper + * at call time so the controller's patch is what runs. + */ +function softKeyboard(term: FakeTerminal, helper: Helper) { + const textarea = term.textarea; + const key229 = () => { + term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 }); + helper._handleAnyTextareaChanges(); + }; + return { + key229, + /** One appended character, settled on its own timer before the next key. */ + typeKeys(text: string) { + for (const ch of text) { + key229(); + textarea.value += ch; + vi.advanceTimersByTime(1); + } + }, + /** The textarea now reads `value` (what the keyboard's input event left there). */ + edit(value: string) { + textarea.value = value; + }, + /** Enter: the custom handler, then xterm's own \r, then xterm clearing its textarea. */ + enter() { + const passed = term.keyHandler!({ type: 'keydown', key: 'Enter', keyCode: 13 }); + if (passed) term.type('\r'); + textarea.value = ''; + }, + }; +} + +/** Every byte the tile sent as input, in order. */ +const joinFrames = (frames: Array<{ d?: string }>) => frames.map((f) => f.d ?? '').join(''); +const wireOf = (ws: FakeSocket) => joinFrames(ws.inputFrames()); + +/** The line a shell ends up with: every DEL erases the character before it. */ +function lineOf(frames: Array<{ d?: string }>) { + const out: string[] = []; + for (const ch of joinFrames(frames)) { + if (ch === '\x7f') out.pop(); + else out.push(ch); + } + return out.join(''); +} + +type ControllerTile = Tile & { _keyCode229Recovery: unknown }; + +describe("TerminalTile wires the primary pane's soft-keyboard controller (#441, #541)", () => { + it("installs on THIS tile's composition helper and textarea, and destroy() restores xterm's own", async () => { + const helpers = withCompositionHelpers(); + const { tile, term } = await connectTile(makeApp()); + const helper = helpers[0]; + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + + // The controller patched this tile's helper (xterm's diff is no longer the one that runs) and + // listens on this tile's textarea in the CAPTURE phase (see the module's measured table). + expect(helper._handleAnyTextareaChanges).not.toBe(helpers.originals[0]); + const captured = term.textareaListeners.filter((l) => l.capture === true).map((l) => l.type); + expect(captured.sort()).toEqual(['compositionend', 'compositionstart', 'input']); + + tile.destroy(); + + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + expect(helper._handleAnyTextareaChanges).toBe(helpers.originals[0]); + expect(term.textareaListeners).toEqual([]); + }); + + it('an autocorrect on space is sent as an edit, not a duplicated line', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const app = makeApp(); + app.activeSessionId = 'some-other-session'; + const { tile, ws, term } = await connectTile(app); + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('testing the peompt'); + // The device log's shape: ONE keydown deleting five characters, a second inserting `rompt `, + // both before any timer runs. + kb.key229(); + kb.edit('testing the p'); + kb.key229(); + kb.edit('testing the prompt '); + vi.advanceTimersByTime(1); + + const frames = ws.inputFrames(); + expect(lineOf(frames)).toBe('testing the prompt '); + expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5); + // Every byte went to THIS tile's session through the exactly-once queue, never the active one. + expect(frames.every((f) => Number.isInteger(f.seq))).toBe(true); + expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(frames.map((f) => f.d)); + expect(app._pendingDeliveries.has('some-other-session')).toBe(false); + }); + + it('control: without the controller, xterm alone duplicates the line exactly as the device did', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const saved = windowStub.CodemanKeyCode229Recovery; + delete windowStub.CodemanKeyCode229Recovery; + try { + const { tile, ws, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('testing the peompt'); + kb.key229(); + kb.edit('testing the p'); + kb.key229(); + kb.edit('testing the prompt '); + vi.advanceTimersByTime(1); + + expect(lineOf(ws.inputFrames())).toBe('testing the peompttesting the prompt rompt '); + } finally { + windowStub.CodemanKeyCode229Recovery = saved; + } + }); + + it('a 229 last character in the same task as Enter goes out ahead of the \\r (#441 + #541)', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('hell'); + // One task: the last character's keydown and edit, then Enter, no timer in between. + kb.key229(); + kb.edit('hello'); + kb.enter(); + // Settled synchronously at the Enter keydown, not by its timer. + expect(wireOf(ws)).toBe('hello\r'); + + vi.advanceTimersByTime(1); + const wire = wireOf(ws); + expect(wire).toBe('hello\r'); + expect(wire).not.toContain('\x7f'); + }); + + it('an autocorrect plus Enter in one task submits the corrected line', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.typeKeys('testing the peompt'); + kb.key229(); + kb.edit('testing the p'); + kb.key229(); + kb.edit('testing the prompt '); + kb.enter(); + vi.advanceTimersByTime(1); + + const frames = ws.inputFrames(); + expect(lineOf(frames)).toBe('testing the prompt \r'); + expect(frames.filter((f) => f.d === '\x7f')).toHaveLength(5); + }); + + it('a 229 keystroke xterm diffed itself is delivered once, not again by the recovery', async () => { + vi.useFakeTimers(); + const helpers = withCompositionHelpers(); + const { tile, ws, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + ws.open(); + const kb = softKeyboard(term, helpers[0]); + + kb.key229(); + kb.edit('y'); + term.textarea.fire('input', { inputType: 'insertText', data: 'y', isComposing: false }); + vi.advanceTimersByTime(1); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['y']); + }); +}); + +describe("the tile's onData tells the controller only about what a human typed", () => { + /** A keystroke xterm refused: a keydown, then the committed `insertText` it did not forward. */ + const orphan = (term: FakeTerminal, data: string) => { + term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 }); + term.textarea.fire('input', { inputType: 'insertText', data, isComposing: false }); + }; + + it('recovers a refused insertText through a query reply and a focus report, to this tile', async () => { + vi.useFakeTimers(); + const app = makeApp(); + const { tile, ws, term } = await connectTile(app); + expect((tile as ControllerTile)._keyCode229Recovery).not.toBeNull(); + ws.open(); + + orphan(term, 'x'); + term.type('\x1b[?1;2c'); // a DA reply xterm answers on its own: dropped, and not "xterm spoke" + term.type('\x1b[I'); // a focus report: sent ephemeral, and not "xterm spoke" either + vi.advanceTimersByTime(1); + + const frames = ws.inputFrames(); + expect(frames.map((f) => f.d)).toEqual(['\x1b[I', 'x']); + const recovered = frames.find((f) => f.d === 'x')!; + expect(Number.isInteger(recovered.seq)).toBe(true); + expect(app._pendingDeliveries.get('s-tile')?.map((r) => r.data)).toEqual(['x']); + }); + + it("never counts the controller's own recovered bytes as xterm's: two refused inserts after one keydown both arrive", async () => { + // Pins WHERE the notify lives: in the onData lambda, not in _onTerminalData(), which the + // recovered bytes also go through. Counted there, the first recovery would read as "xterm + // spoke" for the second candidate, which shares its keydown snapshot, and drop it. + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + + term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 65 }); + term.textarea.fire('input', { inputType: 'insertText', data: 'a', isComposing: false }); + term.textarea.fire('input', { inputType: 'insertText', data: 'b', isComposing: false }); + vi.advanceTimersByTime(1); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['a', 'b']); + }); + + it('stands down when xterm really did deliver the keystroke', async () => { + vi.useFakeTimers(); + const { ws, term } = await connectTile(makeApp()); + ws.open(); + + orphan(term, 'x'); + term.type('x'); // xterm's own canonical emission for this keystroke + vi.advanceTimersByTime(1); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['x']); + }); +}); + +describe('the controller can never break a tile', () => { + type FakeController = { + handleKeyEvent: ReturnType; + notifyCanonicalData: ReturnType; + destroy: ReturnType; + }; + let saved: unknown; + beforeEach(() => { + saved = windowStub.CodemanKeyCode229Recovery; + }); + afterEach(() => { + windowStub.CodemanKeyCode229Recovery = saved; + }); + + const fakeController = (overrides: Partial = {}): FakeController => ({ + handleKeyEvent: vi.fn(), + notifyCanonicalData: vi.fn(), + destroy: vi.fn(), + ...overrides, + }); + + it('a create() that throws leaves the tile connected and typing', async () => { + windowStub.CodemanKeyCode229Recovery = { + create: () => { + throw new Error('broken'); + }, + }; + const { tile, ws, term } = await connectTile(makeApp()); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + ws.open(); + + term.type('a'); + + expect(ws.inputFrames().map((f) => f.d)).toEqual(['a']); + }); + + it("a handleKeyEvent that throws leaves every one of the tile's key gates working", async () => { + const controller = fakeController({ + handleKeyEvent: vi.fn(() => { + throw new Error('broken'); + }), + }); + windowStub.CodemanKeyCode229Recovery = { create: () => controller }; + const { term } = await connectTile(makeApp()); + + expect(term.keyHandler!({ type: 'keydown', key: '1', code: 'Digit1', altKey: true })).toBe(false); + expect(term.keyHandler!({ type: 'keydown', key: 'z', code: 'KeyZ', ctrlKey: true })).toBe(false); + expect(term.keyHandler!({ type: 'keydown', key: 'Unidentified', keyCode: 229 })).toBe(true); + expect(term.keyHandler!({ type: 'keydown', key: 'a', code: 'KeyA', keyCode: 65 })).toBe(true); + // It still ran first, for every one of them. + expect(controller.handleKeyEvent).toHaveBeenCalledTimes(4); + }); + + it('a destroy() that throws still lets the tile dispose its xterm', async () => { + const controller = fakeController({ + destroy: vi.fn(() => { + throw new Error('broken'); + }), + }); + windowStub.CodemanKeyCode229Recovery = { create: () => controller }; + const { tile, term } = await connectTile(makeApp()); + const dispose = vi.spyOn(term, 'dispose'); + + tile.destroy(); + + expect(controller.destroy).toHaveBeenCalledTimes(1); + expect(dispose).toHaveBeenCalledTimes(1); + expect((tile as ControllerTile)._keyCode229Recovery).toBeNull(); + }); + + it('each tile gets its own controller on its own textarea, and destroys only its own', async () => { + const made: Array<{ options: { textarea: unknown }; controller: FakeController }> = []; + windowStub.CodemanKeyCode229Recovery = { + create: (options: { textarea: unknown }) => { + const controller = fakeController(); + made.push({ options, controller }); + return controller; + }, + }; + const app = makeApp(); + const a = await connectTile(app); + const b = await connectTile(app); + + expect(made).toHaveLength(2); + expect(made[0].options.textarea).toBe(a.term.textarea); + expect(made[1].options.textarea).toBe(b.term.textarea); + + a.tile.destroy(); + + expect(made[0].controller.destroy).toHaveBeenCalledTimes(1); + expect(made[1].controller.destroy).not.toHaveBeenCalled(); + }); +}); + +describe('terminal-tile.js keeps the controller call where it works (source pin)', () => { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-tile.js'), 'utf8'); + + it('calls handleKeyEvent ABOVE the IME early return, so a 229 keydown reaches it', () => { + const call = source.indexOf('this._keyCode229Recovery?.handleKeyEvent?.(ev)'); + const earlyReturn = source.indexOf("ev.key === 'Process' || ev.keyCode === 229) return true"); + expect(call).toBeGreaterThan(-1); + expect(earlyReturn).toBeGreaterThan(-1); + expect(call).toBeLessThan(earlyReturn); + }); + + it("hands the controller this tile's own composition helper", () => { + expect(source).toMatch(/getCompositionHelper:\s*\(\)\s*=>\s*this\.terminal\?\._core\?\._compositionHelper/); + }); +});