diff --git a/CLAUDE.md b/CLAUDE.md index 9abf9837..01c55c06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,7 +177,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Key Patterns -**Input**: `session.writeViaMux()` for programmatic/curl input via tmux `send-keys -l` + `send-keys Enter`, single-line only. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) +**Input**: `session.writeViaMux()` handles single-line programmatic/curl input. Interactive browser input separates editable per-session drafts (`TerminalInputStateStore`, `codeman:sessionDrafts`) from submitted exactly-once delivery records (`codeman:pendingInput`); never merge those stores. A stable `clientId` + monotonic per-session `seq` remains persisted until ACK, so a dropped link cannot lose or double-deliver a prompt. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. @@ -185,6 +185,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Plan-usage chip** (statusLine telemetry, opt-in `showPlanUsageLimits`, default OFF): Codeman injects its own `statusLine.command` exporter which POSTs Claude's `rate_limits` blob to `POST /api/status-telemetry`. The exporter is identified by a marker, so it only ever adds/updates/removes a statusLine that is **ours**, never a user's hand-authored one, and it prints the footer through so the in-terminal statusline is not blanked. Claude-mode only; distinct from auto-resume, which reacts to the limit *message* rather than showing live %. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Passive background Bash wakeup**: Codeman's one real `PostToolUse(Bash)` hook uses `asyncRewake` to watch the session transcript for the matching background task completion, then exits 2 so Claude resumes without terminal-input injection. → [architecture-invariants#passive-background-bash-wakeup](docs/architecture-invariants.md#passive-background-bash-wakeup) + **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. **Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md` @@ -193,9 +195,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker cases**: a case can point at a **container**, with any of the five CLI backends running inside it. Like remote-SSH this is a **LOCATION OVERLAY on cases, never a sixth `SessionMode`**. Exactly one long-lived container **per case**, shared by all its sessions, so killing a session kills only that session's in-container tmux and **never** `docker stop` while siblings remain. The workspace is a real host dir bind-mounted at the **same absolute path**, which is what keeps file-routes/watchers on real host bytes and makes the in-container transcript projHash match the host. Credentials are **seeded** (RO mount, copied into the container once) rather than shared RW, so in-container CLIs never write refreshed tokens back to the host, and bind mounts are excluded from `docker commit` so exports stay secret-free. **NEVER a create-time `-e` for secrets, NEVER `--privileged`, NEVER the docker socket.** Config drift is detected via a label hash and a drifted launch is REFUSED rather than silently launched with stale config. ⚠️ On the loopback-only prod bind a container cannot reach 127.0.0.1, so in-container hooks need `CODEMAN_DOCKER_BRIDGE_HOOKS=1`; otherwise idle detection falls back to output-based. → [architecture-invariants#docker-cases](docs/architecture-invariants.md#docker-cases), `docs/docker-cases.md` (user guide), `docs/docker-cases-plan.md` (design) -**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token/CLI-info parsing, ❯-prompt readiness); these CLIs render their own TUIs, so readiness is output stabilization instead. All three **require tmux with no direct PTY fallback**, because secrets are injected via socket-scoped `tmux setenv` and never on the spawn command line. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope; reading the raw shape silently breaks the run. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini) +**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off; all three require tmux because secrets are injected via socket-scoped `tmux setenv`, never on the spawn command line. New local Codex sessions explicitly pass the App Settings animation preference and default decorative TUI animation off. ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. → [architecture-invariants#external-cli-modes-opencode-codex-gemini](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini) -**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 sessions with the same `w-` name. `_ensureCreatedSessionVisible()` runs before `selectSession()`, and `_onSessionCreated()` stays an idempotent upsert, so POST-first and SSE-first ordering both produce exactly one rendered tab. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) +**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 sessions. Launch progress writes to xterm only on the session-less home screen; with an active session it uses toasts so another launch cannot pollute the current terminal snapshot. → [architecture-invariants#run-launch-synchronization](docs/architecture-invariants.md#run-launch-synchronization) **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history, and Claude transcript files into one deduped list (pure core in `src/services/unified-session-service.ts`). Transcript rows fold into their owning session via a `claudeSessionId → Codeman id` alias map, so resumed and `/clear`-respawned sessions do not appear twice. No terminal buffers in the response, unlike `/api/sessions`. Backs the Cmd+K Session Manager, plus pinning and cross-device tab order (`PUT /api/session-order`; pure merge helpers in `src/session-order.ts`, pushing device wins and server-only ids are never dropped). → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) @@ -205,7 +207,7 @@ 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 entire tmux scrollback, bounded by the configured history limit. On success the capture is returned ALONE (`source='mux-full-history'`), superseding the byte buffer so nothing duplicates. Only the FIRST buffer load after a page load requests `full=1`; tab switches keep the cheap `?tail=` path. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) +**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns bounded tmux scrollback alone, superseding the byte buffer so nothing duplicates. `format=stream` is a lossless incremental wire format with cursor headers; bounded history pages keep a small read window plus the latest pane. Replay and keyboard covers must remain immutable while visible, then release over the settled underlying viewport after a compositor fence. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay), `docs/terminal-streaming.md` **Self-update** (App Settings → Updates): in-app updater for git-clone installs supervised by systemd/launchd (`systemd`, `launchd`, `launchd-daemon`, else `none` → "restart manually"). The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` that outlives the restart and writes progress to `update-status.json`, which the browser polls across the connection drop. `src/web/self-update.ts` splits pure helpers (unit-tested) from IO wrappers. npm installs report as non-updatable. → [architecture-invariants#self-update](docs/architecture-invariants.md#self-update) @@ -229,13 +231,13 @@ 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) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `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). +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) → `terminal-input-state.js`(5.55) → `terminal-input-controller.js`(5.58) → `sanitize-html.js`(5.6) → `app.js`(6) → `terminal-ui.js`(7) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `image-input.js`(16). **Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry) **Per-device vs synced settings**: the `displayKeys` set in settings-ui.js is a **client-side merge policy**, not a wire filter. A display key seeds from the server only when localStorage has no value for it, which is what prevents one device overwriting another; `showPlanUsageLimits` is additionally `delete`d from the incoming payload outright. Separately, `SettingsUpdateSchema` is `.strict()` and simply **does not declare** `skin`, `showFileViewerButton`, `showCronButton`, `webglRendererEnabled`, `localEchoEnabled`, `cjkInputEnabled`, or `extendedKeyboardBar`, so sending one of those is a validation error. The rest (`showResponseViewer`, `showPlanUsageLimits`, `language`, and most `show*` keys) ARE in the schema and do persist server-side; they are per-device by client policy only. ⚠️ Adding a new per-device setting means deciding **both** questions: membership in `displayKeys`, and presence in the schema. -**Header button visibility**: most header controls are opt-in and hidden by a marker class (`btn-multimonitor--hidden`, `btn-response-viewer-header--hidden`, `btn-file-viewer--hidden`, `btn-cron--hidden`) that `applyHeaderVisibilitySettings()` (settings-ui.js) toggles after settings load; the multi-monitor button is instead stripped at render by `renderIndexHtml`. ⚠️ Hiding must go through the marker class: the base rules are `display:inline-flex !important`, so an inline style cannot override them. Current desktop default is WS/CPU/MEM + File Viewer + gear, with the token chip and lifecycle-log button OFF. ⚠️ New header controls must not leak onto phones; `test/mobile-header-buttons-policy.test.ts` is the static guard. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron) +**Header button visibility**: marker classes (`btn-*-hidden`) are load-bearing because base rules use `display:inline-flex !important`. Desktop defaults to WS/CPU/MEM + File Viewer + gear. Header controls stay phone-hidden unless deliberately allowlisted; File Viewer is the exception and opens a mobile bottom sheet when its per-device setting is enabled. Response chunks support semantic whole-message double-click/double-tap copy. → [architecture-invariants#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron](docs/architecture-invariants.md#header-button-visibility-multi-monitor-response-viewer-file-viewer-cron), `docs/file-viewer.md` **Gesture control** (camera hand-tracking overlay, opt-in, default OFF): `CODEMAN_GESTURE=1` makes the feature *available*; `gestureControlEnabled` turns it on. The bundle is injected by `renderIndexHtml` only when enabled, which is why that method is `async` and reads settings with `readSettings(true)` (a fresh read: a post-save reload lands inside the 2s cache TTL and would otherwise render the pre-toggle state). **Source lives in `packages/gesture-control/`; edit there, run `npm run build:gesture`, and commit the regenerated bundle** because dev serves the committed bundle with no runtime bundler. The MediaPipe wasm + model are fetched separately and gitignored. ⚠️ Keep `MP_VERSION` in `fetch-gesture-assets.mjs` in sync with `@mediapipe/tasks-vision`. → [architecture-invariants#gesture-control-the-source-package](docs/architecture-invariants.md#gesture-control-the-source-package) diff --git a/config/vitest.ci.config.ts b/config/vitest.ci.config.ts index 24e06052..0ed8fb44 100644 --- a/config/vitest.ci.config.ts +++ b/config/vitest.ci.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ 'test/mobile/**', // browser/visual (Playwright + chromium) 'test/perf-*.test.ts', // timing-sensitive perf benchmarks (flaky in CI) 'test/inline-rename.test.ts', // browser (Playwright) - 'test/opencode-resize.test.ts', // browser (Playwright) + 'test/terminal-viewport-resize.test.ts', // browser (Playwright) 'test/webgl-fallback.test.ts', // browser (Playwright) ], setupFiles: ['./test/setup.ts'], diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6f954c77..b441af0e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -20,7 +20,7 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### External CLI modes (OpenCode, Codex, Gemini) -**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). +**External CLI modes (OpenCode, Codex, Gemini)**: `isExternalCliMode()` in `session.ts` (`mode === 'opencode' || 'codex' || 'gemini'`) gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). All three modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv` (socket-scoped `${this.tmux()} setenv`, never on the spawn command line): OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars`), Gemini gets `GEMINI_API_KEY`/`GOOGLE_API_KEY`/`GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI` etc. (`setGeminiEnvVars`, all in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode). App Settings → Codex CLI → **Animated Status Effects** controls `CodexConfig.animations`; new local Codex sessions explicitly pass `--config tui.animations=` and default it off to reduce decorative redraw traffic without filtering terminal bytes or mutating global Codex config. Gemini specifics: command built by `buildGeminiCommand()` (`--skip-trust` always, `--approval-mode ` defaulting to `yolo` for parity with Claude's `--dangerously-skip-permissions`, `--model`, `--resume` from the `geminiConfig` payload); availability via `GET /api/gemini/status` — session/quick-start routes fail with `OPERATION_FAILED` + install hint (`npm install -g @google/gemini-cli`) when missing. Codex AND Gemini export `COLORTERM=truecolor` + unset `NO_COLOR` (other modes unset `COLORTERM`); Gemini joins `isAltScreenStripMode()` (Codex/Claude/Gemini are Ink TUIs that repaint inline → strip alt-screen/`3J` so scrollback survives). Codex availability via `GET /api/codex/status`. Frontend: run-mode dropdown → `runCodex()`/`runGemini()` in `session-ui.js` ("Run CX"/"Run GM" labels), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. ⚠️ `run*()` MUST unwrap the `{success,data}` envelope (`(await res.json()).data.available` / `data.data.sessionId`) — reading the raw shape silently breaks the run. Tests: `test/run-mode-ui.test.ts` + `test/gemini-mode.test.ts` (vm-sandbox harness, no real DOM). ### Remote sessions over SSH @@ -38,7 +38,11 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### Input delivery and WS resilience -**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). +**Input**: `session.writeViaMux()` for programmatic/curl input — tmux `send-keys -l` (literal) + `send-keys Enter`. Single-line only (fire-and-once). Every interactive **browser** producer (xterm, Android IME/helper textarea, CJK/voice, paste, accessory controls) first enters `TerminalInputController` (`src/web/public/terminal-input-controller.js`), which solely owns transient composition/mutation/dedup state, local-echo edits, and action ordering. Do not add a direct producer-to-transport path. The controller feeds two deliberately separate durable states through injected ports. `TerminalInputStateStore` (`src/web/public/terminal-input-state.js`) solely owns editable per-session drafts in `codeman:sessionDrafts`: pending/flushed/CJK text survives session and Home switches, browser minimization or tab discard, phone sleep, and reload without submission. Its explicit `handoff()` output lets switching flush pending text into that session's PTY while retaining flushed metadata for correct mobile Backspace. Submitted frames use the **exactly-once** layer: each carries a stable `clientId` + monotonic per-session `seq`, persisted in `codeman:pendingInput` until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect cannot lose or double-deliver a prompt. Never merge draft storage with delivery records. **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). + +### Passive background Bash wakeup + +**Passive background Bash wakeup**: `generateHooksConfig()` installs one real `PostToolUse(Bash)` command hook using Claude Code's `asyncRewake`. Its inline Node helper extracts the background task ID, watches that session's JSONL transcript for the matching completed/failed/killed queue notification, then exits 2 so Claude resumes immediately with the output path. It never injects terminal input, which protects browser/mobile drafts; ordinary Bash calls exit the helper immediately. `refreshStaleCodemanHooks()` upgrades Codeman-owned hook blocks missing the `CODEMAN_BACKGROUND_REWAKE_V1` marker. Transcript tool lifecycle handling must accept `tool_result` blocks from `user` entries (Claude's normal shape), not only assistant entries. Tests: `test/hooks-config.test.ts`, `test/transcript-watcher.test.ts`. ### Auto-resume on usage limit @@ -58,11 +62,11 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough ### Full-scrollback replay -**Full-scrollback replay** (COD-164/#148): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests `full=1` (one-shot `_initialFullBufferLoad` flag in app.js); tab switches keep the cheap `?tail=` visible-frame path. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`. +**Full-scrollback replay** (COD-164/#148): `GET /api/sessions/:id/terminal?full=1` returns the ENTIRE tmux scrollback (capture-pane `-e -S -` bounded by the configured history limit, explicit `maxBuffer` from the terminal-history config, early byte-cap before normalization, CRLF-normalized for shell panes). On success the capture is returned ALONE (`source='mux-full-history'` — it supersedes the byte buffer; no duplication). Only the FIRST buffer load after a page load requests `full=1`; tab switches keep the cheap `?tail=` visible-frame path. `format=stream` changes only the wire representation: raw losslessly compressed text is incrementally decoded instead of buffering a JSON string, while `X-Codeman-Terminal-*` cursor headers reconcile concurrent SSE output. History paging uses bounded physical-row slices and keeps only a small read window plus the latest pane; see `docs/terminal-streaming.md`. During replay, tab switches, and mobile keyboard refits, a cloned terminal frame stays opaque and immutable until output is parsed and quiet; xterm paints underneath it, then a double compositor yield removes the cover in one handoff. A cold load may adopt the bounded latest frame only when there is no valid outgoing frame to preserve. The JSON form remains the compatibility fallback. Tests: `test/tmux-capture-full-history.test.ts`, `test/tmux-scrollback-eol.test.ts`, `test/terminal-buffer-flush.test.ts`, `test/mobile/keyboard.test.ts`. ### Run launch synchronization -**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Tests: `test/run-mode-ui.test.ts`. +**Run launch synchronization**: the main Run entrypoint in `session-ui.js` holds an in-flight lock and disables `#runBtn` for the whole launch (at least 500ms), so a double click cannot create duplicate sessions with the same `w-` name. A successful create/quick-start also calls `_ensureCreatedSessionVisible()` before `selectSession()`: local creates use the response's full session snapshot; quick-start modes fetch `GET /api/sessions/:id` only when `session:created` SSE has not already populated the map. The normal `_onSessionCreated()` handler remains the idempotent upsert, so POST-first and SSE-first ordering both produce one immediately-rendered tab. Launch progress may write to xterm only on the session-less home screen; while another session is active it uses toasts so launch chrome cannot enter that session's serialized terminal snapshot. Tests: `test/run-mode-ui.test.ts`. ### Circuit breakers: Ralph and PTY-exit @@ -124,7 +128,7 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in **Fastify specifics.** The proxy lives in an **encapsulated plugin scope** with `removeAllContentTypeParsers()` + a `'*'` pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps `text/plain` RAW, which was a real CSRF hole once). One `app.route({ method:'GET', handler, wsHandler })` serves both HTTP and the WebSocket upgrade; **HEAD must NOT be declared** on the sibling route because `exposeHeadRoutes` already derives it and the duplicate is a startup error. ⚠️ **Every exit path in the proxy handler RETURNS `reply.send(...)`**: the handler is `async`, and a bare `return` after `reply.send(stream)` resolves the handler promise to `undefined` before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off `setNotFoundHandler`, so real Codeman routes always win. -**Frontend** (`src/web/public/webview-tabs.js`, load order 12.5): web tabs render into `#sessionTabs` carrying `data-webview-id` instead of `data-id`, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ `_renderSessionTabsImmediate()`'s `canIncremental` check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (`0 === 0`), where opening a dashboard would never draw its tab. ⚠️ `isActive` for a session tab is `id === activeSessionId && !activeWebviewId`: `activeSessionId` stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. Frames stay MOUNTED while hidden (LRU-evicted past `maxLiveFrames`) so tab switching never reloads a dashboard. +**Frontend** (`src/web/public/webview-tabs.js`, load order 12.5): web tabs render into `#sessionTabs` carrying `data-webview-id` instead of `data-id`, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ `_renderSessionTabsImmediate()`'s `canIncremental` check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (`0 === 0`), where opening a dashboard would never draw its tab. ⚠️ `isActive` for a session tab is `id === activeSessionId && !activeWebviewId`: `activeSessionId` stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. That same dual-state rule applies to input and selection: mobile terminal controls are disabled while `activeWebviewId` is set, and selecting the already-active session must still hide the webview, refit xterm, and reclaim PTY sizing rather than taking `selectSession()`'s same-session early return. Frames stay MOUNTED while hidden (LRU-evicted past `maxLiveFrames`) so tab switching never reloads a dashboard. **Pre-existing bug fixed alongside**: `.toolbar` has `backdrop-filter`, which makes it a stacking context and TRAPS `.run-mode-menu`'s `z-index: 1000` inside it. With the toolbar itself at `z-index: auto`, `.welcome-overlay` (z-index 10, inside `
`) painted over the popped-up Run menu, making **every** item in it unclickable whenever no session was open. `.toolbar` now carries `z-index: 20` (must stay below `.modal`'s 1000). @@ -148,7 +152,8 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in **Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`. ⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. -**File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected. +Whole-reply copy is delegated to double-click/double-tap: last-response mode stores raw transcript text on the viewer body, full-context mode stores it on each `.rv-message`, and `_copyResponseViewerChunk()` copies that semantic source through `_copyText` while excluding links and interactive controls. Do not derive whole-message copy from rendered `textContent`, which loses Markdown structure. +**File Viewer button** (header, 1.4.1) is **shown by default on desktop** and defaults off on handheld devices: toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set). The template starts with `btn-file-viewer--hidden` to avoid a pre-settings flash; `applyHeaderVisibilitySettings()` removes or restores that marker after settings load. When enabled on a phone, the button remains available and opens a safe-area-aware bottom sheet with a full-viewport diff preview. The panel has Files, Changes, and History tabs; it defaults to the selected session's current Git worktree while a validated scope selector exposes the main repository and linked worktrees. Current changes auto-refresh only while visible; commit and diff data load lazily; compact/full previews are bounded. The pencil action can resynchronize a local session's `workingDir`, persisted mux metadata, and path-aware watchers, then resets scope to `current`; remote and Docker sessions cannot use this host-path edit. Full contract: `docs/file-viewer.md`. The default desktop header remains WS/CPU/MEM + File Viewer + gear; token count and lifecycle log default off. The **Cron toolbar button** remains opt-in (`showCronButton`, default off). ### Gesture control: the setting diff --git a/docs/archive/phase7-test-infrastructure-plan.md b/docs/archive/phase7-test-infrastructure-plan.md index 0023c081..cc3a0f0a 100644 --- a/docs/archive/phase7-test-infrastructure-plan.md +++ b/docs/archive/phase7-test-infrastructure-plan.md @@ -120,7 +120,7 @@ This avoids port conflicts entirely and runs fast. Only tests that need SSE or W | 3223 | `test/routes/ralph-routes.test.ts` | Ralph API | | 3224–3229 | Reserved | Future route tests | -Most tests should NOT need real ports — `app.inject()` is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3211 in `opencode-resize.test.ts`). +Most tests should NOT need real ports — `app.inject()` is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3211 in `terminal-viewport-resize.test.ts`). --- diff --git a/docs/audits/2026-07-25-mobile-navigation-and-hooks-audit.md b/docs/audits/2026-07-25-mobile-navigation-and-hooks-audit.md new file mode 100644 index 00000000..e2939299 --- /dev/null +++ b/docs/audits/2026-07-25-mobile-navigation-and-hooks-audit.md @@ -0,0 +1,352 @@ +# Mobile Navigation and Hook Audit + +**Date:** 2026-07-25 +**Checkout:** `master` at `86c6349` (`codeman@1.8.0`) +**Scope:** Keyboard-hidden mobile navigation between dispatched-agent menu +entries, an up+down Enter chord, physical volume-key feasibility, and stale +Claude hook registrations. + +## Executive Summary + +| Proposal | Verdict | Reason | +| --------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ | +| Keyboard-hidden terminal controls | Implemented | Esc, Up, Enter, Down, and Tab reuse Codeman's reliable raw PTY input path. | +| Press up+down together for Enter | Implemented | Pointer Events detect the chord without focusing an editable element. | +| Vertical swipe over the terminal | Do not add globally | One-finger vertical movement already drives xterm scrollback and would double-act. | +| Vertical swipe in a dedicated edge zone | Implemented on the pad | The navigation band is a separate gesture surface from terminal scrollback. | +| Physical phone volume buttons | DOM fallback only | Android Chrome filters the hardware keys before Blink; exposed standard events still work. | +| Active phone/desktop viewport handoff | Implemented | A trusted pointer interaction claims the shared PTY size without forcing keyboard focus. | +| Remove nonexistent Codeman hooks | No removals found | All six generated registrations are current Claude Code events or notification matchers. | +| Reduce hook noise | Implemented | Lifecycle hooks remain, with quieter categorization and drawer defaults. | + +The implementation is a compact touch navigation pad that is visible while the +software keyboard is hidden, paired with the existing full accessory bar while +the keyboard is open. Both surfaces use the same setting and raw PTY transport. +The keyboard-hidden surface never calls `terminal.focus()`. + +## Implementation Outcome + +`MobileNavigationPad` now supplies 48 px Esc, Up, Enter, Down, and Tab controls +above the mobile toolbar. Esc and Tab occupy the far edge zones, separated from +the centered Up, Enter, and Down cluster to reduce accidental activation. Arrow +actions fire on release, simultaneous Up+Down emits one Enter without leaking +arrows, and blank space in the same band accepts short vertical swipes. The +controls are enabled by default on touch devices and are controlled from +**App Settings → Input → Mobile Terminal Controls**. +When the reader leaves live output, a contextual jump-to-latest icon appears +above the centered three-button cluster without changing the band or terminal +height. It covers both xterm scrollback and Claude-owned transcript history. +The same setting enables the full keyboard-open accessory bar and Volume Up, +Volume Down, and two-key Enter handling when the browser dispatches standardized +volume-key events. Android Chrome does not. + +The setting is device-local. With an active session, the keyboard-hidden pad +swaps to the full accessory bar when the software keyboard appears; Codeman's +footer toolbar stays hidden so exactly one 44 px control row is reserved. +Without an active session, neither surface reserves space. Touch tablets can +enable the same setting even when their wider layout does not show the +keyboard-hidden pad. Phone keyboard geometry follows stable handheld identity +rather than only a width breakpoint, because display scaling, landscape, and +foldable postures can expose a desktop-width viewport on a physical phone. +Android Chromium is asked to resize the layout viewport around the software +keyboard through `interactive-widget=resizes-content`; the existing +`visualViewport` handling remains the fallback for browsers that ignore that +viewport token. Accessory buttons are vertically contained by their fixed-height +row so the operating-system keyboard cannot cover half of a control. + +Ordinary xterm and Claude transcript scroll gestures keep their existing +direction and momentum. While the phone keyboard is open, a drag over +non-input transcript content preserves input focus and pins the pending draft +above the keyboard; a tap remains a content activation and dismisses input. +CLI permission and selection prompts remain inside the terminal above the +reserved control band. Codeman HTML modals temporarily hide both control +surfaces and their layout reservation so they cannot cover modal actions, +including dynamically inserted, `.show`, and inline-display dialogs. + +The PTY has one row/column size shared by every connected viewport. Resize +messages can now carry an explicit `takeControl` flag. Trusted primary pointer +interactions, page focus/visibility return, tab activation, keyboard transitions, +and ordinary input reassert that connection's last announced dimensions, +allowing the phone or desktop that is actually in use to claim the PTY +immediately. Accessory taps do not repeatedly refit the PTY while the software +keyboard is open; the existing one-shot keyboard transition resize remains +authoritative. Passive background desktop restores do not displace an active +phone. This prevents a desktop-width stream from being rendered in a narrow +mobile xterm, which caused the dot-fill, wrapped-line, and overdraw artifacts. + +Hook processing also remains intact. Response-complete drawer entries now default +off, while legacy users with browser, audio, or push delivery explicitly enabled +retain it. Agent-team events use the existing opt-in subagent categories instead +of the general session idle/stop preferences. + +## Current Mobile Input Paths + +### Session swipes + +[`SwipeHandler`](../../src/web/public/mobile-handlers.js) listens on `.main` for +a single fast horizontal gesture. Left and right call `nextSession()` and +`prevSession()` respectively. + +Session selection already protects against unwanted keyboard activation. +[`_shouldFocusTerminalForTabSwitch()`](../../src/web/public/app.js) focuses xterm +on touch devices only when `KeyboardHandler.keyboardVisible` is already true. +Horizontal session swipes therefore do not summon a hidden keyboard. + +### Terminal vertical touch + +[`terminal-ui.js`](../../src/web/public/terminal-ui.js) owns one-finger vertical +movement because xterm's DOM renderer does not provide a native scrollable +viewport. After 8 px of movement it: + +1. marks the interaction as a scroll; +2. calls `preventDefault()`; +3. translates pixels into `terminal.scrollLines()` calls; and +4. applies momentum after touch end. + +Only a non-scrolling input tap calls `terminal.focus()`. When input already owns +an open keyboard, the handler delays its content-blur decision until the 8 px +threshold: a drag keeps the draft pinned and scrolls, while a tap still belongs +to the foreground TUI. Extending the global `SwipeHandler` with up/down actions +would make a fast scrollback gesture also navigate the terminal menu. + +### Existing arrow transport + +[`MobileTerminalControls`](../../src/web/public/keyboard-accessory.js) owns the +shared semantic mapping used by both responsive surfaces: + +| Action | PTY bytes | +| ---------- | --------- | +| Arrow up | `\x1b[A` | +| Arrow down | `\x1b[B` | +| Enter | `\r` | +| Escape | `\x1b` | +| Tab | `\t` | + +`sendKey()` routes these bytes through `app.sendTerminalKey()`, which uses +Codeman's existing reliable input queue. While the keyboard is hidden, it also +requests active viewport sizing without waiting on that network request. The +transport does not require terminal focus. The keyboard-open accessory still +refocuses xterm after applicable actions to keep the virtual keyboard open; the +keyboard-hidden pad deliberately does not. + +## Implemented Interaction + +`MobileNavigationPad` follows these constraints: + +- Initialize only on touch devices. +- Show only when a session is active, the keyboard is hidden, and the user + enables the control. +- Render fixed-size Esc, Up, Enter, Down, and Tab buttons outside the xterm + touch surface. +- Show the centered jump-to-latest action only while the active session is + known to be reading local or TUI-owned history. +- Use the existing reliable raw PTY input queue. +- Never call `terminal.focus()`, focus a hidden textarea, or synthesize a terminal + click. +- Set `touch-action: none` on the controls and handle `pointercancel`. +- Keep button geometry stable and at least 44 by 44 CSS pixels. +- Include a visible Enter icon as an accessible fallback even if the two-button + chord is supported. + +The first version should target the agent dispatcher's terminal menu. If the +desired target is instead Codeman's own subagent panel, +[`selectSubagent()`](../../src/web/public/panels-ui.js) can use the same controls: +filter agents by `parentSessionId === activeSessionId`, apply the panel's existing +active-first/activity ordering, and select the adjacent entry. Parent associations +already persist in `subagentParentMap`. + +### Up+down Enter chord + +Arrow actions must fire on release, not press. Otherwise the first finger leaks an +arrow before the second finger completes the chord. + +```text +pointerdown(direction, pointerId): + remember pointerId and direction + if the opposite direction is held: + send "\r" once + mark both pointers consumed + +pointerup(pointerId): + if pointerId was not consumed: + send the remembered arrow sequence + clear pointer state + +pointercancel(pointerId): + clear pointer state without sending +``` + +Use pointer capture and suppress the subsequent `click` event so one physical +gesture cannot dispatch twice. Do not add key repeat in the first release; repeat +timers complicate chord detection and can flood an interactive menu. + +### Dedicated swipe mode + +Vertical navigation is not bound to the whole `.main` or xterm container. The +implementation uses the pad itself: an upward gesture sends Arrow Up and a +downward gesture sends Arrow Down. + +This preserves ordinary one-finger scrollback everywhere else. A two-finger +terminal swipe is technically distinguishable, but it is less discoverable and +competes with browser zoom gestures. + +## Volume-Key Feasibility + +The W3C defines `AudioVolumeUp` and `AudioVolumeDown` key values, but does not +require user agents to expose them. Android defines physical volume key codes for +native applications, while browser and operating-system routing can consume those +keys before a page receives an event. The Media Session action list also has no +volume up/down handler. + +Codeman listens for the standardized DOM key values as a progressive enhancement +while Mobile Terminal Controls are visible. A single volume direction sends its +arrow on release; overlapping up and down presses send one Enter without leaking +either arrow. The handler prevents the event's default action when possible, +never focuses xterm, and disables itself with the same setting and modal +visibility rules as the on-screen pad. Pixel 8 testing confirmed that Android Chrome's +`ContentUiEventHandler` filters `KEYCODE_VOLUME_UP` and `KEYCODE_VOLUME_DOWN` +before Blink, so Chrome never creates the DOM events this handler needs. + +Consequences: + +- Keep the on-screen pad as the dependable control; volume keys are an additional + input path. +- A browser may never dispatch the physical key event, and `preventDefault()` is + not guaranteed to suppress the operating system's volume change. +- Reliable phone volume-button support requires a native Android wrapper that + intercepts `KeyEvent` before its WebView. That adds a separate distribution and + lifecycle surface. +- A physical up+down volume chord is even less dependable because the operating + system can intercept or serialize the presses. +- Automated browser coverage verifies the DOM-event mapping, not whether a + particular phone/browser combination exposes its physical buttons. + +Primary references: + +- [W3C UI Events key values](https://www.w3.org/TR/uievents-key/) +- [Android `KeyEvent`](https://developer.android.com/reference/android/view/KeyEvent.html) +- [Chromium Android input routing](https://chromium.googlesource.com/chromium/src/+/27c7fe6e7a57093e09bcdb675cc6cfedac716110/content/public/android/java/src/org/chromium/content/browser/ContentUiEventHandler.java) +- [Media Session actions](https://developer.mozilla.org/en-US/docs/Web/API/MediaSession/setActionHandler) + +## Hook Audit + +[`generateHooksConfig()`](../../src/hooks-config.ts) creates six Codeman +registrations: + +| Registration | Current support | Codeman dependency | +| --------------------------------- | --------------- | ----------------------------------------- | +| Notification `idle_prompt` | Supported | idle state and user attention | +| Notification `permission_prompt` | Supported | approval required | +| Notification `elicitation_dialog` | Supported | question/elicitation blocking | +| `Stop` | Supported | definitive respawn-controller idle signal | +| `TeammateIdle` | Supported | agent-team progress | +| `TaskCompleted` | Supported | agent-team progress | + +The installed hook targets were also checked: + +- the generated Codeman hooks post directly with `curl`; +- the configured `codegraph prompt-hook` executable exists; +- the configured `pbcm-nudge.sh` script exists and is executable; +- the enabled session-driver plugin reports a valid installation; and +- Claude debug logs contain no matching missing-command, `ENOENT`, or invalid-hook + errors. + +No hook registration qualifies for removal. In particular, deleting `Stop` would +break the respawn controller, transcript/session ID adoption, SSE broadcasting, +push delivery, and run summaries handled by +[`hook-event-routes.ts`](../../src/web/routes/hook-event-routes.ts). + +The repeated `hookify/.../hooks/stop.py` missing-file prompt was separate from +Codeman's generated hooks. It came from a stale cached Codex plugin bundle under +`~/.codex/plugins/cache/claude-code-plugins/hookify/`; that broken cache entry was +removed. No Codeman source hook was removed to silence an external plugin error. + +### Actual source of hook noise + +[`settings-ui.js`](../../src/web/public/settings-ui.js) emits a drawer notification +for every `Stop`, `TeammateIdle`, and `TaskCompleted` event. +[`notification-manager.js`](../../src/web/public/notification-manager.js) adds an +enabled event to the drawer even when browser and audio delivery are disabled. +It also aliases: + +- `hook-teammate-idle` to the general `idle_prompt` preference; and +- `hook-task-completed` to the general `stop` preference. + +Implemented cleanup: + +1. Keep all backend hook registrations. +2. Preserve `Stop` processing while defaulting its drawer category off. +3. Route `teammate_idle` and `task_completed` through opt-in subagent activity + preferences instead of unrelated idle/stop categories. + +Future refinements could coalesce repeated teammate-idle entries by teammate +identity and label Claude-only preferences by run mode. + +The local hook reference was stale and omitted the two agent-team events. It was +updated as part of this audit against the current +[Claude Code hook reference](https://code.claude.com/docs/en/hooks). + +## Verification + +Targeted JSDOM tests cover exact arrow/Enter bytes, touch and volume-key chord +suppression, `pointercancel`, short-swipe rejection, dialog visibility, and +notification migration. The mobile Playwright suite covers: + +1. hidden-keyboard visibility with an active session; +2. a flush 48 px five-button row with protected Esc/Tab edge zones, a centered + Up/Enter/Down cluster, matching terminal background, and non-overlap among CLI + content, pad, and toolbar; +3. trusted Up input with viewport takeover but without xterm focus or + viewport-height change; +4. exactly one Enter from simultaneous Up+Down; +5. navigation-band swipe input; +6. the App Settings disable/save/reopen/enable path; and +7. keyboard-open accessory input without a refit or keyboard dismissal; +8. swapping between the navigation pad and the keyboard-open full accessory bar; +9. no blank reservation when controls are disabled or during keyboard animation; +10. modal coexistence for both responsive surfaces; and +11. standardized volume-key DOM events, including the two-key Enter chord. + +Server and transport tests additionally cover explicit mobile takeover while a +desktop claim is fresh, desktop reclaim on ordinary pointer interaction, and +HTTP/WebSocket flag forwarding. A scaled Android regression verifies stable +handheld keyboard geometry above the phone breakpoint, layout-viewport resizing, +and full accessory-button containment at the keyboard edge. WebKit-specific +execution and an explicit regression test proving terminal vertical swipes never +post arrows remain useful follow-up coverage. + +## Repository Coherence Pass + +The final pass kept the feature within existing ownership boundaries: + +- `MobileTerminalControls` owns initialization, enablement, modal policy, and the + shared semantic key mapping; `KeyboardAccessoryBar` and `MobileNavigationPad` + own only their responsive interaction surfaces. +- `CodemanApp.sendTerminalKey()` is the single bridge into the existing reliable + input queue and viewport takeover protocol. +- `CodemanApp.sendResize()` is the single owner of dimension tracking, viewport + classification, and WebSocket-first transport with HTTP fallback. Debounced + window resizes keep their existing reflow cleanup, then delegate transport to + this method. +- One canonical device-local setting replaces the old simple/extended mode + toggle. The preview `mobileNavigationPadEnabled` key and shipped + `extendedKeyboardBar` key remain read-only fallbacks. Legacy `false` is not a + disable signal because it previously selected compact mode; enabled controls + now use the unified full bar. +- `KeyboardHandler` remains the only owner of virtual-keyboard transition sizing; + accessory taps do not create competing resize loops. +- The standards-based Android layout-viewport opt-in complements that handler; + unsupported browsers continue through the existing `visualViewport` path. +- Existing session resize arbitration handles both HTTP and WebSocket takeover, + with focused route, arbitration, and cross-viewport browser coverage. +- Stable handheld CSS applies across scaled, landscape, and foldable widths, while + the five-button navigation row remains limited to compact layouts. + +## Original Estimated Scope + +| Work | Estimate | +| ---------------------------------------------------------------- | --------------------------------- | +| Keyboard-hidden pad, chord state, preference, and targeted tests | 0.5-1 day | +| Dedicated edge swipe and conflict tests | additional 0.5 day | +| Notification categorization/noise cleanup and tests | 0.5 day | +| Native Android volume-key wrapper | 2-5 days plus release maintenance | diff --git a/docs/audits/2026-07-28-mobile-experience-pr-split-audit.md b/docs/audits/2026-07-28-mobile-experience-pr-split-audit.md new file mode 100644 index 00000000..9362c4a9 --- /dev/null +++ b/docs/audits/2026-07-28-mobile-experience-pr-split-audit.md @@ -0,0 +1,164 @@ +# Mobile Experience Change-Set Audit + +**Date:** 2026-07-28 +**Scope:** `origin/master..agent/mobile-terminal-experience` plus the pending terminal input controller extraction +**Mode:** Full, with architecture, code-quality, and test specialists +**Size:** 112 tracked and pending files, 43 existing commits, approximately 21,000 added lines before audit commits + +## Assessment + +The combined branch is not reviewable as one pull request. It mixes terminal +transport, mobile controls, destructive instance management, Git-backed file +browsing, hook configuration, case management, and unrelated settings. The +features should be rebuilt from `origin/master` and published as independent +draft pull requests. + +| Severity | Count | Status | +| --- | ---: | --- | +| Critical | 4 | Fixed in the audited working tree | +| High | 5 | Four fixed; CI coverage split remains follow-up work | +| Medium | 6 | Three fixed; remaining gaps are documented per PR | + +## Blocking Findings + +### Passwordless remote shutdown + +`src/web/routes/system-routes.ts:144` relied on `requireAdmin`, while +`src/web/route-helpers.ts:185` treats passwordless single-user mode as admin. +Any network client reaching Codeman could therefore schedule a persistent +service shutdown. + +**Resolution:** passwordless single-user shutdown now returns `403`; authenticated +single-user installs and multi-user admins retain access. Route tests cover +passwordless and non-admin denial. + +### Self-deleting quick-start fixture + +`test/quick-start.test.ts` used the real `~/codeman-cases` directory and removed +the default `testcase` recursively during cleanup. Running CI from a checkout at +`~/codeman-cases/testcase/Codeman` therefore deleted the repository, its Git +metadata, and `node_modules` while Vitest was still running. + +**Resolution:** the test now assigns a unique temporary home before dynamically +importing `WebServer`, so its module-level `CASES_DIR` resolves inside the +fixture. The test restores the environment and removes only that temporary home. + +### Cross-user repository worktrees + +`src/web/routes/file-routes.ts:462` accepted a linked-worktree root returned by +Git without reapplying `isWorkingDirAllowed`. A regular multi-user session in +one allowed worktree could select and read a sibling worktree outside its user +space. + +**Resolution:** every repository scope transition is authorized, overview +metadata filters disallowed worktrees, and commit, diff, tree, content, raw, +preview, and thumbnail routes share the same check. Tests cover a linked +worktree outside the user's case space. + +### Mixed hook configuration loss + +`src/hooks-config.ts:354` identified the entire hooks object as Codeman-owned +when any command referenced `/api/hook-event`, then replaced the full object. +Mixed Codeman and user hooks lost user handlers during self-healing. + +**Resolution:** ownership is now determined per command handler. Installation +and refresh replace only Codeman handlers and preserve user events, matchers, +and handlers sharing a matcher. + +### Mobile terminal could become unfocusable + +`src/web/public/terminal-ui.js:3602` recognized only a narrow set of prompt +glyphs and otherwise classified live Claude rows as content. Touch handling +prevented the browser compatibility focus event, leaving no way to open the +keyboard on promptless redraws. + +**Resolution:** known menus, working indicators, and transcript rows remain TUI +content; the live cursor and a lower-screen fallback band remain focusable. +The Playwright regression asserts the helper textarea becomes +`document.activeElement` and no mouse report is sent. + +### Input ownership invariant was false + +Voice input, image paths, accessory commands, and fallback Enter called +`sendInput` directly despite the documented controller-only boundary. +The pending controller extraction also left three path-picker tests bound to +the deleted implementation. + +**Resolution:** all interactive producers now enter semantic +`TerminalInputController` methods. A static producer boundary test rejects +future direct transport calls, DOM-adapter tests dispatch real composition, +delete, and paste events, and the path-picker harness asserts delegation. + +### Controls were not opt-in + +`src/web/public/settings-ui.js:2006` and the corresponding HTML controls enabled +mobile controls and haptics by default despite the feature description calling +them optional. + +**Resolution:** canonical defaults are off. Explicit canonical and legacy true +values still migrate to enabled; device detection alone no longer enables the +feature. + +### Phone header policy was weakened + +`test/mobile-header-buttons-policy.test.ts:32` allowlisted File Viewer and +instance shutdown on the phone header. + +**Resolution:** the allowlist is empty again and CSS explicitly hides both +buttons on phone widths. + +## Remaining Test Gaps + +- The main CI config excludes the Playwright mobile suites. Each frontend PR + must report its focused Playwright command until a stable browser smoke job + is added. +- The broad mobile browser run still carries unrelated baseline debt: 24 visual + snapshots need an intentional refresh, while 23 structural assertions already + disagree with `origin/master` behavior such as disabled pinch zoom and the + legacy 430px phone breakpoint. These should be repaired separately instead of + weakening feature-specific gates. +- Terminal history paging and warm-cache tests still lean heavily on source + assertions. The streaming PR should add executable state-machine coverage. +- `Session.setWorkingDir` needs a focused collaborator synchronization test. +- Instance shutdown orchestration needs injected timer/process ports before its + idempotency and supervisor rollback paths can be unit tested safely. +- Volume-key support is progressive enhancement only. Browser tests can verify + DOM key mapping, not whether Android or iOS exposes physical volume keys. + +## Pull Request Slices + +1. Self-contained quick-start test fixture. +2. Mobile terminal controls and opt-in settings. +3. Mobile terminal tap routing. +4. Persistent terminal draft state and the centralized input controller. +5. PTY viewport sizing ownership. +6. Terminal history streaming, cache, and frame reconciliation. +7. Background-command reawake hooks. +8. Transcript tool-result completion. +9. Lifecycle notification noise reduction. +10. Repository/worktree browser and file diff UI. +11. Response viewer. +12. Secure instance shutdown. +13. Inline case edit/delete actions. +14. Active-session launch preservation. +15. Codex animation preference. + +Backend terminal protocol changes should precede frontend replay/cache changes. +The draft store should precede the controller adapter if those input changes are +published as stacked PRs. All other slices can target `master` independently. + +## Validation Recorded During Audit + +- Controller/input focused unit tests: passed. +- Shutdown and header policy tests: passed. +- Hook configuration and self-heal tests: passed. +- Repository scope, symlink, binary, and size-limit tests: passed. +- Promptless mobile focus Playwright regression: passed. +- Focused keyboard, controls, header, tabs, settings, and repository Playwright + suites: 160 passed. +- Full CI: 200 files passed, 3,977 tests passed, 12 skipped. +- Self-contained quick-start regression: 15 passed and the checkout remained + intact. +- TypeScript typecheck: passed. +- Frontend syntax and public asset checks: passed. +- `git diff --check`: passed. diff --git a/docs/claude-code-hooks-reference.md b/docs/claude-code-hooks-reference.md index e11aad09..ec309406 100644 --- a/docs/claude-code-hooks-reference.md +++ b/docs/claude-code-hooks-reference.md @@ -2,14 +2,18 @@ > Official documentation for Claude Code hooks system, extracted from [code.claude.com](https://code.claude.com/docs/en/hooks). -**Last Updated**: 2026-01-24 +**Last Updated**: 2026-07-25 **Source**: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks) +> This is a maintained summary, not an exhaustive copy of the upstream reference. +> Check the source link for event-specific schemas before adding a new hook. + --- ## Overview Hooks are automated scripts that execute at specific events during your Claude Code session. They allow you to: + - Validate, modify, or block tool usage - Add context to prompts - Implement custom workflows @@ -21,12 +25,12 @@ Hooks are automated scripts that execute at specific events during your Claude C Hooks are configured in settings files: -| File | Scope | -|------|-------| -| `~/.claude/settings.json` | User (global) | -| `.claude/settings.json` | Project | +| File | Scope | +| ----------------------------- | -------------------------- | +| `~/.claude/settings.json` | User (global) | +| `.claude/settings.json` | Project | | `.claude/settings.local.json` | Local project (gitignored) | -| Plugin hook files | Plugin-specific | +| Plugin hook files | Plugin-specific | ### Basic Structure @@ -49,8 +53,9 @@ Hooks are configured in settings files: ``` **Key Fields**: + - `matcher`: Pattern to match tool names (case-sensitive, supports regex like `Edit|Write` or `*` for all) -- `type`: `"command"` for bash or `"prompt"` for LLM-based evaluation +- `type`: `"command"`, `"http"`, `"mcp_tool"`, `"prompt"`, or `"agent"` where the event supports it - `command`: Bash command to execute - `prompt`: LLM prompt for evaluation (prompt-based hooks only) - `timeout`: Optional timeout in seconds (default: 60) @@ -59,6 +64,10 @@ Hooks are configured in settings files: ## Hook Events +Claude Code's current event surface is broader than the detailed subset below. In +particular, `TeammateIdle` and `TaskCompleted` are supported lifecycle events used +by Codeman; they are not stale or plugin-defined event names. + ### PreToolUse **When**: After Claude creates tool parameters, before processing the tool call. @@ -66,15 +75,17 @@ Hooks are configured in settings files: **Use Cases**: Approval, denial, or modification of tool calls. **Common Matchers**: + - `Bash` - Shell commands - `Write` - File writing - `Edit` - File editing - `Read` - File reading -- `Task` - Subagent tasks +- `Agent` - Subagent tasks - `WebFetch`, `WebSearch` - Web operations - `mcp____` - MCP tools **Output Control**: + ```json { "hookSpecificOutput": { @@ -96,13 +107,14 @@ Hooks are configured in settings files: **Use Cases**: Auto-approve or deny permissions. **Output Control**: + ```json { "hookSpecificOutput": { "hookEventName": "PermissionRequest", "decision": { "behavior": "allow|deny", - "updatedInput": { }, + "updatedInput": {}, "message": "deny reason", "interrupt": false } @@ -117,6 +129,7 @@ Hooks are configured in settings files: **Use Cases**: Provide feedback, run formatters/linters, log operations. **Output Control**: + ```json { "decision": "block", @@ -128,15 +141,30 @@ Hooks are configured in settings files: } ``` +#### Asynchronous Rewake + +Command hooks can set `"asyncRewake": true` to run asynchronously and wake an +idle Claude turn when the hook exits with code 2. The hook's stderr is delivered +to Claude as a system reminder. This implies `"async": true`; ordinary async +hooks do not wake an idle turn, and their output waits for the next interaction. + +Codeman uses this on `PostToolUse(Bash)`: a self-contained Node helper extracts +the background task ID from the Bash result, watches the session transcript for +the matching completion notification, and exits 2. It does not send terminal +input, so it cannot submit a user's partially written prompt. + ### Notification **When**: When Claude Code sends notifications. **Matchers**: + - `permission_prompt` - `idle_prompt` - `auth_success` - `elicitation_dialog` +- `elicitation_complete` +- `elicitation_response` ### UserPromptSubmit @@ -145,6 +173,7 @@ Hooks are configured in settings files: **Use Cases**: Add context, validate, or block prompts. **Output Control**: + ```json { "decision": "block", @@ -165,6 +194,7 @@ Hooks are configured in settings files: **Use Cases**: **Ralph Wiggum loops** - block exit and refeed prompt. **Output Control**: + ```json { "decision": "block", @@ -173,6 +203,7 @@ Hooks are configured in settings files: ``` Or to allow exit: + ```json { "continue": true, @@ -184,15 +215,32 @@ Or to allow exit: ### SubagentStop -**When**: When a subagent (Task tool call) finishes responding. +**When**: When a subagent (Agent tool call) finishes responding. **Use Cases**: Control nested loops, verify subagent output. +### TeammateIdle + +**When**: When an agent-team teammate is about to go idle. + +**Use Cases**: Reassign work, continue a teammate loop, or notify an orchestrator. + +**Matcher Support**: None. The hook fires for every occurrence. + +### TaskCompleted + +**When**: When a task is about to be marked completed. + +**Use Cases**: Validate completion or forward team progress to an external UI. + +**Matcher Support**: None. The hook fires for every occurrence. + ### PreCompact **When**: Before a compact operation. **Matchers**: + - `manual` - Invoked from `/compact` - `auto` - Invoked from auto-compact @@ -201,6 +249,7 @@ Or to allow exit: **When**: When Claude Code starts or resumes a session. **Matchers**: + - `startup` - Fresh start - `resume` - From `--resume`, `--continue`, or `/resume` - `clear` - From `/clear` @@ -209,6 +258,7 @@ Or to allow exit: **Use Cases**: Load development context, set environment variables. **Persisting Environment Variables**: + ```bash #!/bin/bash if [ -n "$CLAUDE_ENV_FILE" ]; then @@ -219,6 +269,7 @@ exit 0 ``` **Output Control**: + ```json { "hookSpecificOutput": { @@ -233,6 +284,7 @@ exit 0 **When**: When a session ends. **Reason Values**: + - `clear` - `logout` - `prompt_input_exit` @@ -254,7 +306,7 @@ Hooks receive JSON via stdin with common fields: "permission_mode": "default", "hook_event_name": "PreToolUse", "tool_name": "Bash", - "tool_input": { }, + "tool_input": {}, "tool_use_id": "toolu_01ABC123..." } ``` @@ -262,6 +314,7 @@ Hooks receive JSON via stdin with common fields: ### Tool-Specific Input **Bash**: + ```json { "tool_name": "Bash", @@ -274,6 +327,7 @@ Hooks receive JSON via stdin with common fields: ``` **Write**: + ```json { "tool_name": "Write", @@ -285,6 +339,7 @@ Hooks receive JSON via stdin with common fields: ``` **Edit**: + ```json { "tool_name": "Edit", @@ -302,11 +357,11 @@ Hooks receive JSON via stdin with common fields: ### Exit Codes -| Code | Behavior | -|------|----------| -| 0 | Success. `stdout` processed (shown in verbose or added as context) | -| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event | -| Other | Non-blocking error. `stderr` shown in verbose, execution continues | +| Code | Behavior | +| ----- | --------------------------------------------------------------------- | +| 0 | Success. `stdout` processed (shown in verbose or added as context) | +| 2 | Blocking error. Only `stderr` used. Blocks tool/prompt based on event | +| Other | Non-blocking error. `stderr` shown in verbose, execution continues | ### JSON Output (Exit Code 0) @@ -323,7 +378,12 @@ Hooks receive JSON via stdin with common fields: ## Prompt-Based Hooks -For Stop and SubagentStop events, you can use LLM-based evaluation: +Prompt and agent handlers are supported by decision-oriented events including +`PreToolUse`, `PermissionRequest`, `PostToolUse`, `PostToolUseFailure`, +`PostToolBatch`, `UserPromptSubmit`, `Stop`, `SubagentStop`, `TaskCreated`, and +`TaskCompleted`. Check the upstream reference before choosing a handler type. + +For example, a Stop event can use LLM-based evaluation: ```json { @@ -344,6 +404,7 @@ For Stop and SubagentStop events, you can use LLM-based evaluation: ``` **LLM Response Format**: + ```json { "ok": true, @@ -362,17 +423,18 @@ Hooks can be defined in Skills, Agents, and Slash Commands using frontmatter: name: secure-operations hooks: PreToolUse: - - matcher: "Bash" + - matcher: 'Bash' hooks: - type: command - command: "./scripts/security-check.sh" + command: './scripts/security-check.sh' --- ``` These hooks: + - Are scoped to the component's lifecycle - Only run when that component is active -- Support: PreToolUse, PostToolUse, Stop +- Support all hook events; a subagent-scoped `Stop` is converted to `SubagentStop` --- @@ -550,11 +612,11 @@ exit 0 ## Environment Variables -| Variable | Description | -|----------|-------------| -| `CLAUDE_PROJECT_DIR` | Project root directory | -| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI | -| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) | +| Variable | Description | +| -------------------- | ------------------------------------------------ | +| `CLAUDE_PROJECT_DIR` | Project root directory | +| `CLAUDE_CODE_REMOTE` | `"true"` for web, empty for CLI | +| `CLAUDE_ENV_FILE` | Path to write persistent env vars (SessionStart) | --- @@ -593,4 +655,4 @@ Use `/hooks` command to view registered hooks and make changes. --- -*Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)* +_Source: [Claude Code Hooks Documentation](https://code.claude.com/docs/en/hooks)_ diff --git a/docs/file-viewer.md b/docs/file-viewer.md new file mode 100644 index 00000000..d77e9f50 --- /dev/null +++ b/docs/file-viewer.md @@ -0,0 +1,112 @@ +# File Viewer + +The File Viewer is a session-scoped repository browser for files, current Git +changes, and commit history. Its browser UI lives in +`src/web/public/panels-ui.js`; file and repository HTTP routes live in +`src/web/routes/file-routes.ts`; Git command/parsing logic lives in +`src/git-repository-browser.ts`. + +## Views + +- **Files** lists the selected scope's directory tree and opens the existing + file preview for text, images, audio/video, PDF, SVG, and Office documents. +- **Changes** lists the selected worktree's current staged, unstaged, and + untracked paths. It refreshes every five seconds only while the panel and + Changes tab are visible. +- **History** lists the latest 30 commits. Expanding a commit lazily loads its + changed paths; selecting a path lazily loads that file's patch and snapshots. +- **Compact diff** renders unified-diff hunks. **Full diff** renders the whole + after-file, highlights additions, and inserts deleted hunk rows at their + corresponding anchors. Deleted files use the before-file. + +## Session And Worktree Scope + +The selected session remains the ownership boundary. For a Git-backed session: + +1. `git rev-parse --show-toplevel` maps a nested `session.workingDir` to the + current worktree root. +2. `git worktree list --porcelain -z` discovers the main repository worktree + and its linked worktrees. +3. The File Viewer defaults to the selected session's current worktree root. + The selector exposes the main/root worktree and every linked worktree. +4. The browser sends only a short scope ID. On every scoped request, the server + rediscovers the repository and accepts the ID only when it exactly matches a + current worktree entry. + +For a non-Git session, `scope=current` falls back to `session.workingDir` and +Changes/History report that Git is unavailable. + +On startup, tmux recovery reads `#{pane_current_path}` with the pane PID. Newly +discovered local sessions use that path instead of the Codeman server's process +directory. Local metadata written by the older process-directory fallback is +self-healed when the saved path still equals `process.cwd()`. Tracked remote and +Docker sessions retain their persisted execution metadata. + +The pencil action in the File Viewer header lets the user synchronize an +already-running local session with a different host work path. Applying it +updates `Session.workingDir`, mux recovery metadata, Bash path resolution, +Ralph's plan watcher, image watching, and persisted/SSE session state. It does +not change the cwd of the running child process. Remote and Docker sessions are +excluded because a host-only metadata edit cannot coherently change their +execution roots. + +Unscoped file URLs retain the original `session.workingDir` confinement. This +is load-bearing for attachments and existing API consumers: repository +discovery must not silently broaden every session file route. + +## HTTP API + +All routes require access to the owning session through `findSessionOrFail`. + +| Route | Purpose | +| ----------------------------------------------------------------------- | ---------------------------------------------- | +| `PUT /api/sessions/:id/working-directory` | Synchronize a local session's host work path | +| `GET /api/sessions/:id/repository?scope=` | Worktrees, current changes, recent commits | +| `GET /api/sessions/:id/repository/commit?scope=&commit=` | One commit and its changed paths | +| `GET /api/sessions/:id/repository/diff?scope=&path=&commit=` | Lazy patch plus bounded before/after snapshots | +| `GET /api/sessions/:id/files?...&scope=` | Directory tree rooted at a validated worktree | +| `GET /api/sessions/:id/file-{content,raw,preview,thumbnail}?...&scope=` | Existing preview routes in that same scope | + +Git is executed with `spawn()` argument arrays, no shell, optional locks +disabled, a ten-second timeout, and bounded stdout/stderr. Patches are capped at +2 MiB and marked truncated. Before/after text snapshots are capped at 1 MiB; +binary files are detected before decoding. File paths are normalized, confined +to the selected worktree, and realpath-checked before working-tree reads. + +Git status uses porcelain v2 with NUL delimiters and explicit rename detection. +Git still models an unstaged delete plus an untracked destination as separate +changes; it can report a rename once the move exists in the index. Do not add +client-side filename/similarity guessing. + +## Client Lifecycle + +`loadFileBrowser()` owns an `AbortController` and monotonically increasing +generation. A response may paint only when its generation, session ID, and +active tab still match. This prevents a slower previous session from replacing +the selected session's tree or repository state. + +Switching sessions synchronously changes File Viewer ownership and resets scope +to `current` before terminal resize/history loading begins. An open viewer +starts loading the new repository immediately; a closed viewer invalidates its +old repository state without issuing a request. Reopening then loads the active +session. The selected Files/Changes/History view is preserved. Manual refresh +preserves filters and expanded folders. Closing the panel aborts its request +and stops repository polling. + +A successful work-path edit and a work-path change received through +`session:updated` use the same forced invalidation. The selected scope resets to +`current`, in-flight requests are aborted, and an open viewer reloads once. + +On phones, the opt-in File Viewer header button remains available and opens a +safe-area-aware bottom sheet. Diff preview uses the full visual viewport. + +## Focused Tests + +- `test/git-repository-browser.test.ts`: real temporary repository and linked + worktree, nested discovery, status/history/diffs, rename parsing, and scope + traversal rejection. +- `test/routes/file-routes-repository.test.ts`: session route ownership and + scoped-vs-legacy file access. +- `test/mobile/file-viewer.test.ts`: stale tab-switch response rejection, + worktree selector defaults, Changes and History interactions, compact/full + diff rendering, and phone viewport bounds. diff --git a/docs/local-echo-overlay-plan.md b/docs/local-echo-overlay-plan.md index 8abc2c34..7fe6eaa6 100644 --- a/docs/local-echo-overlay-plan.md +++ b/docs/local-echo-overlay-plan.md @@ -1,6 +1,19 @@ # Local Echo Overlay — Implementation Plan -> **Status: SHIPPED.** Implementation lives in `packages/xterm-zerolag-input/src/` (overlay-renderer.ts, prompt-finder.ts, cell-dimensions.ts, zerolag-input-addon.ts) with the embedded copy in `src/web/public/app.js`. This document is retained as historical design context. +> **Status: SHIPPED.** Source lives in `packages/xterm-zerolag-input/src/` and is bundled into the gitignored public vendor asset by the package/build scripts. This document is retained as historical design context. + +> **Current input rule:** ordinary typing and Backspace never infer editable text from the rendered terminal buffer. Consumers explicitly call `detectBufferText()` after Tab completion or `setFlushed()` when restoring known input. Full-screen TUIs can render status text after a prompt glyph, so implicit buffer adoption causes ghost deletion and stale text submission. + +> **IME rule:** `TerminalInputController` owns the full pre-delivery lifecycle. +> Live composition candidates occupy a separate transient overlay slot; +> `compositionupdate` replaces that slot, and the first finalized browser path +> atomically commits it to pending text before clearing xterm's helper textarea. +> Candidate text is never submitted or deleted as if already committed. The +> controller retains each accepted commit until xterm's delayed composition +> path or Android's late `insertText` path has repeated it or substantive new +> input invalidates it, so the two browser routes cannot append one word twice. + +> **Durability rule:** `TerminalInputStateStore` owns each session's pending, flushed, and optional CJK draft text separately from submitted input delivery. Lifecycle suspension calls `capture()` and only saves the draft; it never sends it. Session/Home switching calls `handoff()`, explicitly delivers the returned `flushText`, and persists flushed metadata so reload does not break Backspace. Restoration uses `get()` plus `restoreDraft(..., false)` until the target terminal frame is ready. ## Context diff --git a/docs/reliable-input-delivery.md b/docs/reliable-input-delivery.md index accc8032..3a730b49 100644 --- a/docs/reliable-input-delivery.md +++ b/docs/reliable-input-delivery.md @@ -17,8 +17,38 @@ dropped once the server ACKs it** — so a half-open socket, a reconnect, or a p reload can never lose input. Redelivery is **exactly-once**: the server applies each `(clientId, seq)` at most once, so a resend can't type the prompt twice. +Editable text that has not been submitted yet has a separate guarantee. A +per-session draft record in `localStorage['codeman:sessionDrafts']` preserves +local-echo text across session switches, Home navigation, browser +minimization/tab discard, phone sleep, and page reload. Restoring a draft never +submits it. + ## How it works +### Input arbitration (`terminal-input-controller.js`) + +- `TerminalInputController` is the single pre-delivery owner for interactive + browser input. Native xterm data, Android helper-textarea mutations, IME + composition, CJK/voice text, clipboard paste, accessory controls, and + modified Enter all enter its public API before anything reaches a draft or + delivery record. +- The controller owns transient composition epochs, helper-textarea mutation + snapshots, alternate-path deduplication, local-echo edits, paste segmentation, + normal-mode batching, and control-key ordering. `terminal-ui.js` remains a + thin adapter for terminal focus/query filtering and Tab-completion rendering. +- A finalized composition atomically commits one value and then clears xterm's + hidden helper textarea. This is load-bearing on Android: retaining old helper + context lets a later Gboard commit include earlier words, producing delayed + `cdcd`/`homecd` duplication even when transport delivers each event once. +- xterm's delayed composition callback and Android's follow-up `insertText` can + expose the same finalized word twice. The controller accepts the first path + and drops the matching alternate callback. The marker survives Space and + punctuation, but is invalidated by a matching replay, a new composition, + different substantive input, or a session reset. +- Draft storage and submitted delivery are injected ports, not controller + state. `TerminalInputStateStore` remains the sole durable draft owner and + `_sendInputAsync` remains the exactly-once transport owner. + ### Client (`app.js`) - A stable **`clientId`** (`localStorage['codeman:clientId']`) identifies this @@ -43,6 +73,31 @@ each `(clientId, seq)` at most once, so a resend can't type the prompt twice. over POST, and fires on SSE-reconnect / `online`. - The connection indicator shows pending count/bytes (`_pendingBytes`). +### Editable drafts + +- `TerminalInputStateStore` (`src/web/public/terminal-input-state.js`) is the + single owner of `{pendingText, flushedText, cjkText, updatedAt}` per session. + Its input API is `capture()`/`set()`/`handoff()`/`clear()`; its output API is + `get()` plus the explicit `flushText` returned by `handoff()`. Storage, clock, + and timers are injectable, so the state machine is self-contained in tests. +- Writes are debounced during typing and forced synchronously on `pagehide` and + hidden `visibilitychange`. +- `pendingText` has not reached the PTY. `flushedText` has reached the PTY + during a session/Home handoff but remains explicitly tracked so mobile + Backspace still maps one character to one PTY Backspace after reload. +- An unfinished xterm IME candidate is persisted as ordinary pending text + because the operating system cannot resume a composition session after page + discard. The optional CJK textarea persists its committed field value + separately. +- Session restore calls `restoreDraft(..., false)` before asynchronous terminal + replay, then renders only after the target frame is ready. This keeps the + draft editable without anchoring it to a stale session frame. +- Enter, Escape, Ctrl+C, voice submit, and session deletion remove the draft. + Draft bytes then follow the normal exactly-once delivery path if submitted. +- Draft strings are not truncated. On `QuotaExceededError`, reproducible + `codeman-xs-*` terminal snapshots are evicted before draft persistence is + abandoned. + ### Server - **`Session.shouldApplyInput(clientId, seq)`** — returns `true` exactly once per @@ -70,3 +125,13 @@ across the narrow restart window. semantics (monotonic, per-client, gap-tolerant, eviction-safe). - `test/routes/session-routes.test.ts` — POST `/input` applies a tagged `(clientId, seq)` once on redelivery; untagged input always applies. +- `test/mobile/keyboard.test.ts` — background/reload rehydration, switched + session editability, submit cleanup for per-session drafts, and single-delivery + Android composition in immediate-echo shells. +- `test/terminal-input-state.test.ts` — pure capture, handoff, persistence, + clearing, and quota-recovery semantics without a server or browser manager. +- `test/terminal-input-controller.test.ts` — pure composition reconciliation, + helper reset, replay deduplication, delete/control ordering, paste framing, + external input, and session-reset semantics. +- `packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts` — atomic + no-render draft restoration against a stale terminal frame. diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 85a23ad4..768fcb70 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -369,12 +369,15 @@ documented here rather than silently diverging from the per‑session claim abov ### Known limitation — `workingDir` scope -The file‑route boundary is the session's `workingDir`, and `POST /api/sessions` -currently accepts an arbitrary absolute `workingDir` (validated as "exists + is a -directory"). A session created with `workingDir=/` can therefore read files -across the filesystem within that boundary. This is **pre‑existing** across all -file routes and not widened by the recent changes. Recommended follow‑up: -constrain `workingDir` to an allowlist (e.g. under the cases dir / `$HOME`). +The file‑route boundary is the session's `workingDir`. `POST /api/sessions` and +`PUT /api/sessions/:id/working-directory` accept an arbitrary safe absolute host +path in single-user/admin mode after verifying that it exists and is a +directory. The mutation route is local-session-only; remote and Docker sessions +are rejected. A session pointed at `workingDir=/` can therefore read files +across the filesystem within that boundary. In multi-user mode, +`isWorkingDirAllowed` additionally realpath-confines non-admin paths to that +user's case space. A stricter single-user/admin deployment can constrain +`workingDir` to an allowlist (for example, the cases directory or `$HOME`). --- diff --git a/docs/terminal-anti-flicker.md b/docs/terminal-anti-flicker.md index 82f45b1b..db0143ab 100644 --- a/docs/terminal-anti-flicker.md +++ b/docs/terminal-anti-flicker.md @@ -5,27 +5,27 @@ Claude Code uses [Ink](https://github.com/vadimdemedes/ink) (React for terminals ## Pipeline Overview ``` -PTY Output → Server Batching → DEC 2026 Wrap → SSE → Client rAF → Sync Parser → xterm.js +PTY Output → Server Batching → DEC 2026 Wrap → WS/SSE → Client rAF → Sync Parser → xterm.js ``` -| Layer | Location | Technique | Latency | -|-------|----------|-----------|---------| -| **1. Server Batching** | `server.ts:batchTerminalData()` | Adaptive 16-50ms collection window | 16-50ms | -| **2. DEC Mode 2026** | `server.ts:flushTerminalBatches()` | Wraps with `\x1b[?2026h`...`\x1b[?2026l` | 0ms | -| **3. SSE Broadcast** | `server.ts:broadcast()` | JSON serialize once, send to all clients | 0ms | -| **4. Client rAF** | `app.js:batchTerminalWrite()` | `requestAnimationFrame` batching | 0-16ms | -| **5. Sync Block Parser** | `app.js:extractSyncSegments()` | Strips DEC 2026 markers, waits for complete blocks | 0-50ms | -| **6. Chunked Loading** | `app.js:chunkedTerminalWrite()` | 64KB/frame for large buffers | variable | +| Layer | Location | Technique | Latency | +| ------------------------ | --------------------------------------------------- | --------------------------------------------------- | -------- | +| **1. Server Batching** | `sse-stream-manager.ts:batchTerminalData()` | Adaptive 16-50ms collection window | 16-50ms | +| **2. DEC Mode 2026** | `sse-stream-manager.ts:flushSessionTerminalBatch()` | Wraps with `\x1b[?2026h`...`\x1b[?2026l` | 0ms | +| **3. SSE Broadcast** | `server.ts:broadcast()` | JSON serialize once, send to all clients | 0ms | +| **4. Client pacing** | `terminal-ui.js:flushPendingWrites()` | One in-flight xterm parse followed by a paint yield | 0-16ms | +| **5. Sync Block Parser** | xterm.js | Native DEC 2026 synchronized-update parsing | variable | +| **6. Chunked Loading** | `terminal-ui.js:chunkedTerminalWrite()` | 32KB per parsed-and-painted replay chunk | variable | -## Server-Side Implementation (`server.ts`) +## Server-Side Implementation (`sse-stream-manager.ts`) ### Constants ```typescript -const TERMINAL_BATCH_INTERVAL = 16; // Base: 60fps +const TERMINAL_BATCH_INTERVAL = 16; // Base: 60fps const BATCH_FLUSH_THRESHOLD = 32 * 1024; // Flush immediately if >32KB -const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update -const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update +const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update +const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update ``` ### Adaptive Batching (`batchTerminalData()`) @@ -43,56 +43,102 @@ const syncData = DEC_SYNC_START + data + DEC_SYNC_END; this.broadcast('session:terminal', { id: sessionId, data: syncData }); ``` -## Client-Side Implementation (`app.js`) +## Client-Side Implementation (`terminal-ui.js`) ### `batchTerminalWrite(data)` 1. Checks if flicker filter is enabled (optional, per-session) 2. If flicker filter active: buffers screen-clear patterns (`ESC[2J`, `ESC[H ESC[J`, `ESC[nA`) 3. Accumulates data in `pendingWrites` -4. Schedules `requestAnimationFrame` if not already scheduled -5. On rAF callback: checks for incomplete sync blocks (start without end) -6. If incomplete: waits up to 50ms via `syncWaitTimeout` -7. Calls `flushPendingWrites()` when complete - -### `extractSyncSegments(data)` - -- Parses DEC 2026 markers, returns array of content segments -- Content before sync blocks returned as-is -- Content inside sync blocks returned without markers -- Incomplete blocks (start without end) returned with marker for next chunk +4. Calls `_scheduleTerminalWriteFlush()` if no flush is pending +5. The yielded callback clears its scheduled flag before calling `flushPendingWrites()` +6. One xterm write remains in flight until its parse callback runs +7. Large batches schedule their next chunk only after that callback and a paint yield ### `flushPendingWrites()` -```javascript -const segments = extractSyncSegments(this.pendingWrites); -this.pendingWrites = ''; // Clear before writing -for (const segment of segments) { - if (segment && !segment.startsWith(DEC_SYNC_START)) { - terminal.write(segment); // Skip incomplete blocks (start with marker) - } -} -``` - -Note: Segments starting with `DEC_SYNC_START` are incomplete blocks awaiting more data. These are skipped (discarded if timeout forces flush). +- Joins the queued terminal data and passes DEC 2026 markers through to xterm.js 6, which handles synchronized output natively. +- Writes at most 16KB per yield on mobile, 32KB for desktop Codex, or 64KB for other desktop modes. +- Prefers the last complete DEC-2026 block inside that budget, publishing one coherent state per display frame. +- Requeues the remainder, waits for xterm's write callback, then gives visible pages a compositor frame before scheduling the next slice. +- Hidden pages use a timer/Worker fallback so output continues to drain when animation frames are throttled. +- Records parse callbacks above 100ms as `XTERM_PARSE` diagnostics. -### `chunkedTerminalWrite(buffer, chunkSize=128KB)` +### `chunkedTerminalWrite(buffer, chunkSize=32KB)` - For large buffer restoration (session switch, reconnect) -- Writes 128KB per `requestAnimationFrame` to avoid UI jank +- Waits for each xterm parse callback before yielding and writing the next 32KB - Strips any embedded DEC 2026 markers from historical data +- In history-follow mode, pins the viewport to `baseY` after every parsed chunk ### `selectSession()` Optimizations -- Starts buffer fetch immediately before other setup -- Shows "Loading session..." indicator while fetching -- Parallelizes session attach with buffer fetch -- Fire-and-forget resize (doesn't block tab switch) +- Pauses client writes and crosses an xterm parser fence before snapshotting or + resetting the shared terminal, so bytes queued by the outgoing session cannot + render into the destination session. +- Restores a bounded in-memory xterm snapshot plus its warm live delta without refetching. +- Keeps a valid cached frame visible while a fresh stream is downloaded and compared. +- Starts bounded `latest=1` and newest tmux history-page requests concurrently. +- Requests 400 physical history rows at a time and starts edge loads three screenfuls early. +- Paints and captures the latest pane first, limited to `.xterm-screen` so the scrollbar stays live. +- Assembles the compressed page off-screen, then performs one bounded replay under the stable frame. +- Loads adjacent history pages only near a reading-window edge and retains at most six pages plus the latest pane. +- Reconciles live SSE batches against the snapshot cursor, including partial overlap. +- Removes the stable frame only after replay and queued live output cross an xterm paint fence. +- An unexpected terminal transport loss freezes the last composited Codex frame + until an authoritative init identifies the reconnect as same-process or + replacement-process. Reconnect bytes may parse underneath but cannot become + visible before that decision. +- A replacement-process reload carries a one-shot recovery marker in + `sessionStorage`. The new page uses a wider quiet window and bounded cover + hold for the restored pane's post-attach redraw bursts, then clears the marker + after revealing a settled frame. +- Terminal output arriving after cover removal was armed invalidates that paint + fence generation. An older xterm callback therefore cannot reveal a frame + after newer output extended the quiet deadline. + +The snapshot transport and cursor contract are documented in +[terminal-streaming.md](terminal-streaming.md). + +### Mobile Keyboard Transitions + +- `KeyboardHandler` records whether terminal input or a regular form field opened the soft keyboard. +- Terminal focus adds a short-lived `keyboard-opening` state before the first `visualViewport` resize, pinning the handheld app before the browser can auto-scroll the layout viewport. +- A terminal-owned keyboard immediately unlocks local history, scrolls to the live prompt, and retains focus on the xterm/CJK input surface. +- `visualViewport` animation frames are coalesced into one settled layout pass. The generic terminal `ResizeObserver` returns while the keyboard is visible so it cannot trigger a delayed second reflow. +- Keyboard resize claims never use the force-redraw flag. The server therefore suppresses a same-size resize instead of sending an unnecessary `SIGWINCH`. +- Before a terminal-owned keyboard transition changes the viewport, Codeman clones the already-painted xterm DOM rows into an inert frame cover. Its frame translates toward the new bottom as the viewport shrinks instead of snapping with a bottom anchor. Local xterm resize renders cannot release the cover; parsed terminal output or completed session selection marks the destination frame ready. After two stable compositor frames, Codeman swaps the fully opaque cover out atomically, with a bounded timeout if the TUI does not repaint. +- Keyboard opening and closing use separate cover generations. Closing is recognized from meaningful growth above the keyboard-open minimum viewport height, so the cover starts during the first animation steps; the final hide threshold restarts it and invalidates any queued release before the full-height fit and `SIGWINCH` redraw can become visible. Smaller address-bar movement stays below that early-close threshold. +- Touch tab switches initially reuse the keyboard cover, then hand off to the destination's screen-only latest-frame cover while history loads. The completed selection restores focus, prompt anchoring, and local echo without scheduling a redundant second keyboard fit. +- Codex keyboard resizes and WebSocket attachment keep the prior frame covered while + live output is gated. After a bounded TUI redraw interval, the browser captures + the rendered tmux pane and its terminal cursor, replaces only xterm's visible rows, + and reconciles queued live events against that boundary. Existing scrollback stays + intact, and timing no longer chooses which intermediate redraw becomes visible. +- Touch Enter or terminal mouse selection on a visible decision prompt uses the same + reconciliation transaction. Hook events improve prompt detection when available, + but numbered-option detection keeps the behavior working when a provider exposes + no corresponding hook. +- Provider mode is part of that rendering contract. New tmux sessions persist it + in the `@codeman-mode` session option so a server restart cannot restore Codex + as Claude and skip the quiet window. Legacy panes without the option are + identified once from their live process and immediately upgraded in place. +- A keyboard-open drag that begins over transcript content keeps terminal focus and pins the local draft overlay to the visible viewport. A plain content tap still activates the touched TUI element and dismisses the keyboard. +- A form-owned keyboard may resize the local layout, but it does not steal focus, scroll terminal history, or resize the PTY behind the form. ## Optional Flicker Filter Per-session toggle via Session Settings. Adds ~50ms latency but eliminates remaining flicker on problematic terminals. +## Codex Status Animation + +Codex CLI's decorative working animation can emit about 30 small cursor-update +frames per second while changing terminal state much less often. On remote and +mobile clients this consumes render budget without adding useful information. +App Settings → Codex CLI → **Animated Status Effects** controls Codex's native +`tui.animations` setting for new local sessions and defaults off. This is a +source-level motion control: Codeman does not discard or rewrite terminal output. + ### Detection Patterns - `ESC[2J` — Clear entire screen @@ -104,35 +150,45 @@ When detected, buffers 50ms of subsequent output before flushing atomically. ## Latency Analysis -| Source | Best Case | Worst Case | Notes | -|--------|-----------|------------|-------| -| Server batching | 0ms (flush) | 50ms (rapid events) | Immediate flush if >32KB | -| Sync block wait | 0ms | 50ms | Only if marker split across packets | -| Flicker filter | 0ms (disabled) | 50ms (enabled) | Optional per-session | -| rAF scheduling | 0ms | 16ms | Display refresh sync | -| **Total** | **0ms** | **~115ms** | Worst case rare in practice | +| Source | Best Case | Worst Case | Notes | +| --------------------- | -------------- | ------------------- | ----------------------------------- | +| Active WebSocket | 0ms (flush) | 50ms | Desktop 8ms/8KB; mobile 50ms/16KB | +| Inactive/fallback SSE | 0ms (flush) | 50ms (rapid events) | Immediate flush if >32KB | +| Sync block wait | 0ms | 50ms | Only if marker split across packets | +| Flicker filter | 0ms (disabled) | 50ms (enabled) | Optional per-session | +| rAF scheduling | 0ms | 16ms | Display refresh sync | +| **Total** | **0ms** | **~115ms** | Worst case rare in practice | **Typical latency:** 16-32ms (server batch + rAF) ## Edge Cases -- **Incomplete sync blocks**: 50ms timeout forces flush (content discarded to prevent freeze) -- **Large buffers**: Chunked writing prevents UI freeze +- **Incomplete sync blocks**: xterm.js retains synchronized output until its closing marker +- **Fragmented WebSocket transactions**: size-bounded messages share one DEC-2026 pair, so transport fragmentation cannot publish partial redraws; authoritative pane replacement closes a partially parsed prior transaction before opening its own atomic repaint +- **Large histories**: Demand-paged tmux rows prevent full-history transfer and parsing; serial chunk budgets remain as a bounded fallback +- **Hidden tabs**: Worker wake-ups keep replay moving without racing the compositor while visible - **Server shutdown**: Skips batching via `_isStopping` flag -- **Session switch**: Clears flicker filter state, pending writes, and sync timeout (prevents cross-session data bleed) -- **SSE reconnect**: `handleInit()` clears all pending write state - -**Trade-off:** If a sync block is split across SSE packets and the end marker doesn't arrive within 50ms, the incomplete content is discarded. This prioritizes responsiveness over completeness. In practice this is rare since the server always sends complete `SYNC_START...SYNC_END` pairs and SSE typically delivers them atomically. +- **Session switch**: Drains xterm's shared parser first, then clears flicker + filter state, pending writes, and sync timeout. A replay epoch also suppresses + stale viewport and local-echo callbacks. +- **Same-process SSE reconnect**: Reconciles session metadata without clearing the + active terminal, scrollback, input draft, snapshots, or warm buffers +- **Server restart/deploy**: A changed `serverStartedAt` epoch persists input and + reloads the page once so an open tab cannot continue running stale frontend + code. The old page remains frame-frozen through epoch detection; the new page + retains its stable frame through restored-session redraw settling. ## DEC Mode 2026 Compatibility -Terminals that natively support DEC 2026 will buffer and render atomically. Terminals that don't support it ignore the escape sequences harmlessly. xterm.js doesn't support DEC 2026 natively, so the client implements its own buffering by parsing the markers. +Terminals that natively support DEC 2026 buffer and render atomically. Codeman uses xterm.js 6, so the client passes the markers through instead of parsing or discarding partial blocks. **Supporting terminals:** WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal ## Files Involved -| File | Key Functions | -|------|---------------| -| `src/web/server.ts` | `batchTerminalData()`, `flushTerminalBatches()`, `broadcast()` | -| `src/web/public/app.js` | `batchTerminalWrite()`, `extractSyncSegments()`, `flushPendingWrites()`, `flushFlickerBuffer()`, `chunkedTerminalWrite()` | +| File | Key Functions | +| ---------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `src/web/sse-stream-manager.ts` | `batchTerminalData()`, `flushSessionTerminalBatch()` | +| `src/web/routes/session-routes.ts` | Streamed terminal snapshot endpoint | +| `src/web/public/terminal-ui.js` | `batchTerminalWrite()`, `flushPendingWrites()`, `chunkedTerminalWrite()`, `_readTerminalSnapshotResponse()` | +| `src/tmux-manager.ts` | Durable provider-mode metadata and legacy pane recovery | diff --git a/docs/terminal-streaming.md b/docs/terminal-streaming.md new file mode 100644 index 00000000..12cfdc8e --- /dev/null +++ b/docs/terminal-streaming.md @@ -0,0 +1,153 @@ +# Terminal Snapshot Streaming + +Codeman restores a terminal from three coordinated sources: + +1. a bounded rendered tmux history page; +2. a current-pane HTTP snapshot; and +3. live `session:terminal` events arriving over SSE, followed by WebSocket output. + +HTTP content encoding remains lossless: Fastify negotiates Brotli or gzip, and the browser +decompresses each response incrementally. History download and terminal parsing are +decoupled: the bounded page assembles off-screen, then xterm parses it in one replay +transaction behind the current-frame cover. + +## Cursor Contract + +Every Session owns a terminal stream id and a lexicographically monotonic cursor: + +```ts +interface TerminalCursor { + stream: string; + generation: number; + start: number; + end: number; +} +``` + +- `stream` changes when the in-memory Session is recreated. +- `generation` increases when the retained terminal buffer is replaced or cleared. +- `start` and `end` are UTF-16 string offsets within that generation. +- A live event covers `[start, end)`. +- A snapshot cursor identifies the live-output boundary represented by the snapshot. + +The browser queues live events while a snapshot is loading. Once the snapshot finishes: + +- events ending at or before the snapshot boundary are discarded; +- events starting at or after the boundary are replayed; +- a batch crossing the boundary replays only its uncovered suffix; +- events from older generations or a replaced stream are discarded; +- events from a newer generation are replayed in full. + +This replaces the previous empty-buffer heuristic, which could either duplicate an Ink +redraw or discard a prompt emitted during the fetch. + +## History Page Contract + +`GET /api/sessions/:id/terminal?historyPage=1&lines=400&format=stream` returns +one bounded physical-row slice of retained tmux history. It never includes the visible pane. + +Page headers: + +- `X-Codeman-History-Start` / `End`: half-open absolute row range from the + oldest retained tmux history row. +- `X-Codeman-History-Total`: retained history rows at capture time. +- `X-Codeman-History-More-Before` / `More-After`: navigable directions. +- `X-Codeman-History-Origin`: opaque pane/history-origin fingerprint. + +Omitting `before`/`after` returns the newest page. `before=` walks toward +older output; `after=` walks toward newer output. The browser requests 400 +rows per page; the server accepts and clamps explicit sizes from 100–2,000 +physical rows. Page capture deliberately does not use tmux `-J`, so each page +coordinate remains one physical tmux row. + +The browser retains at most six pages around the current reading position plus +the separately refreshed latest pane. When the reading window shifts far +enough to omit intermediate rows, a dim gap row separates it from the current +pane. Approaching either edge loads the adjacent page and rebuilds behind the +frame cover while preserving the reader's viewport row. It begins the adjacent +request three screenfuls before an edge so the smaller page normally arrives +before the reader reaches it. + +If tmux page capture fails, the endpoint falls back to the newest 1 MB of the +PTY buffer without page metadata. The client then disables row paging for that +load because byte offsets cannot safely substitute for tmux row coordinates. + +`X-Codeman-History-Origin` changes when the pane or oldest retained rows change. +The client rejects a page with a different origin instead of stitching rows +across tmux eviction or pane replacement. + +## HTTP Snapshot Contract + +`GET /api/sessions/:id/terminal?format=stream` returns raw terminal text rather than the +JSON API envelope. Snapshot metadata is carried in `X-Codeman-Terminal-*` headers. The +existing JSON response remains available for old clients and as a frontend fallback. + +`latest=1` projects the same endpoint down to the current rendered tmux pane. Session +selection starts this request together with the newest history page and uses it for first +paint. The latest response supplies the authoritative PTY cursor for live-event +reconciliation after the composed page replay. + +Mobile keyboard resizes, Codex WebSocket attachment, and touch selection of a visible +terminal decision also request this projection. The browser gates live writes before the +action can redraw, clears only xterm's visible rows, paints the tmux projection inside a +synchronized update, and drops only queued events represented by the response cursor. +This preserves the existing scrollback while preventing transient TUI history redraws +from becoming the revealed frame. + +`full=1` remains available for legacy callers that explicitly require one complete tmux +capture, but the browser no longer uses it during startup or session switching. + +The stream response is `no-store`. It may still be reconstructed from tmux history or a +visible pane capture, so cursor offsets order live events; they are not byte offsets into +the normalized response body. + +## Live Transport Handoff + +SSE and WebSocket use the same per-tab transport id (`clientId:tabNonce`). When a +WebSocket opens, the server flushes that session's pending SSE batch before suppressing +SSE terminal events for the tab. The WebSocket then becomes the only live terminal +transport for that session. + +Active WebSocket output uses a viewport-aware, lossless micro-batch. Desktop and +tablet connections retain the 8 ms / 8 KB low-latency path. A connection that +announces a mobile viewport groups raw PTY output for at most 50 ms and emits +approximately 16 KB frames. The wider phone window combines adjacent decorative +redraws before adding one DEC-2026 synchronized-update pair, reducing full xterm +paints without discarding terminal data. + +Each output message carries the UTF-16 terminal cursor range represented by its raw +payload. When one synchronized transaction spans several WebSocket messages, their +cursor ranges remain contiguous and exclude Codeman's transport markers. The client can +therefore discard snapshot-covered fragments or rewrap an uncovered fragment without +guessing byte offsets. + +Bulk data is split on UTF-8- and ANSI-safe boundaries, but all fragments from one +flush remain inside the same DEC-2026 synchronized-update pair. The first WebSocket +message opens the transaction and the final message closes it, so xterm cannot +publish transport fragments as intermediate terminal states. A single control +sequence, OSC/DCS string, or application-owned +synchronized update remains indivisible even when it exceeds the viewport target; +terminal correctness takes priority over a hard packet limit. If a PTY event ends +inside a control sequence, the connection emits its safe prefix and carries the +incomplete suffix into the next event rather than inserting a synchronization +marker inside the command. + +For an intentional disconnect, the browser sends a handoff frame and waits briefly for +an acknowledgement. The server flushes pending WebSocket output, detaches its Session +listeners, discards any overlapping SSE batch while the tab is still suppressed, then +resumes SSE and acknowledges the handoff. An unexpected close resumes SSE with a +targeted `session:needsRefresh` event so the client reloads an authoritative snapshot +rather than risking a missing transport interval. + +## Rendering + +The client paints the bounded latest-pane response first and captures only xterm's screen +rows in an inert cover. The newest history page assembles in the background. The client +then composes the page, one screen of scroll-only padding, and the absolute-positioned +latest pane into a bounded replay. The padding moves every history row into xterm +scrollback before the current pane is painted. + +Lazy page loads use the same buffer-load transaction. Live output is queued, the bounded +reading window is rebuilt, the prior viewport anchor is restored, and queued output is +reconciled against the latest-pane PTY cursor. Once replay and queued output settle, an +xterm write fence plus two paint yields removes the cover atomically. diff --git a/packages/xterm-zerolag-input/README.md b/packages/xterm-zerolag-input/README.md index ee511c17..b7bb4a1d 100644 --- a/packages/xterm-zerolag-input/README.md +++ b/packages/xterm-zerolag-input/README.md @@ -98,6 +98,7 @@ Most terminal UIs can't do local echo because: 3. **Font matching**: Canvas/WebGL renderers use their own text shaping. A DOM overlay must pixel-match the canvas grid — normal DOM text flow drifts due to sub-pixel glyph width differences. This library solves all three by: + - Using a **DOM overlay** that Ink can't touch (separate z-index layer) - **Scanning the buffer** bottom-up for the prompt character instead of trusting cursor position - Rendering each character as an **absolutely-positioned ``** at exact cell-grid coordinates @@ -160,67 +161,78 @@ Implements xterm.js `ITerminalAddon`. The addon does **not** hook `terminal.onDa ### Input -| Method | Returns | Description | -|--------|---------|-------------| -| `addChar(char)` | `void` | Add a single printable character. Auto-detects existing buffer text on first keystroke. | -| `appendText(text)` | `void` | Append multiple characters (paste). | -| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove last char. See [backspace handling](#backspace-handling). | -| `clear()` | `void` | Clear all state, hide overlay. Call on Enter/Ctrl+C/Escape. | +| Method | Returns | Description | +| -------------------------- | ------------------------------------- | ---------------------------------------------------------------- | +| `addChar(char)` | `void` | Add a single printable character. | +| `appendText(text)` | `void` | Append multiple characters (paste). | +| `setCompositionText(text)` | `void` | Replace the visible, uncommitted IME candidate. | +| `commitComposition(text)` | `void` | Atomically replace the candidate with finalized pending text. | +| `clearComposition()` | `void` | Remove an unfinished candidate without changing pending text. | +| `removeChar()` | `'pending'` \| `'flushed'` \| `false` | Remove last char. See [backspace handling](#backspace-handling). | +| `clear()` | `void` | Clear all state, hide overlay. Call on Enter/Ctrl+C/Escape. | +| `restoreDraft(draft, render?)` | `void` | Atomically restore pending/flushed text. Pass `false` until the current terminal frame loads. | + +Composition text is rendered after `pendingText` but remains transient. Call `setCompositionText()` for each `compositionupdate`, then `commitComposition()` with xterm's finalized `onData` payload. This prevents candidate rewrites from duplicating partially composed words. ### Backspace Handling `removeChar()` cascades through three layers and tells you what it removed: -| Return | Source | Your action | -|--------|--------|-------------| -| `'pending'` | Unsent text (never transmitted to PTY) | Do nothing | -| `'flushed'` | Text already sent to PTY | Send `\x7f` backspace to PTY | -| `false` | Nothing to remove | Do nothing | +| Return | Source | Your action | +| ----------- | -------------------------------------- | ---------------------------- | +| `'pending'` | Unsent text (never transmitted to PTY) | Do nothing | +| `'flushed'` | Text already sent to PTY | Send `\x7f` backspace to PTY | +| `false` | Nothing to remove | Do nothing | -The cascade: pending text first, then flushed text, then auto-detect buffer text (handles tab completion). This means backspace "just works" through any combination of typed, flushed, and tab-completed text. +The cascade is pending text first, then explicitly tracked flushed text. The addon never infers editable input during ordinary typing or Backspace because full-screen terminal UIs can render status text after a prompt glyph. For Tab completion, call `detectBufferText()` after the completion response is rendered; for session restoration, call `setFlushed()`. ### Flushed Text "Flushed" = sent to PTY but echo hasn't arrived yet. Happens during tab switches and tab completion. -| Method | Description | -|--------|-------------| +| Method | Description | +| ---------------------------------- | -------------------------------------------------------------------------------------------- | | `setFlushed(count, text, render?)` | Mark text as flushed. Pass `render=false` during tab-switch restore (buffer not loaded yet). | -| `getFlushed()` | Returns `{ count, text }`. | -| `clearFlushed()` | Clear flushed state when server echo arrives. | +| `getFlushed()` | Returns `{ count, text }`. | +| `clearFlushed()` | Clear flushed state when server echo arrives. | + +`restoreDraft({ pendingText, flushedText }, false)` is the preferred reload +and session-switch rehydration API. It clears transient composition and cached +prompt coordinates without rendering against the outgoing terminal frame. ### Buffer Detection Scan the terminal for text that exists after the prompt but wasn't typed through the overlay. -| Method | Description | -|--------|-------------| -| `detectBufferText()` | Scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. | -| `resetBufferDetection()` | Re-enable detection. | -| `suppressBufferDetection()` | Block detection until next `clear()`. Use for sessions with UI framework text after the prompt. | -| `undoDetection()` | Undo last detection — clears flushed state, re-enables detection. For tab completion retry. | +| Method | Description | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `detectBufferText()` | Explicitly scan and return detected text (or `null`). Sets it as flushed. Guarded: runs once per `clear()` cycle. | +| `resetBufferDetection()` | Re-enable detection. | +| `suppressBufferDetection()` | Block detection until next `clear()`. Use for sessions with UI framework text after the prompt. | +| `undoDetection()` | Undo last detection — clears flushed state, re-enables detection. For tab completion retry. | ### Rendering -| Method | Description | -|--------|-------------| -| `rerender()` | Force re-render. Call after buffer reloads, screen redraws, resizes, reconnects. | -| `refreshFont()` | Re-cache font properties from terminal. Call after font size or theme changes. | +| Method | Description | +| --------------------------- | ----------------------------------------------------------------------------------------------- | +| `rerender()` | Force re-render. Call after buffer reloads, screen redraws, resizes, reconnects. | +| `refreshFont()` | Re-cache font properties from terminal. Call after font size or theme changes. | +| `setViewportPinned(pinned)` | Keep a pending draft visible at the bottom while xterm scrollback is away from the live prompt. | ### Prompt Utilities -| Method | Description | -|--------|-------------| -| `findPrompt()` | Find prompt position. Returns `{ row, col }` or `null`. | +| Method | Description | +| ------------------ | -------------------------------------------------------- | +| `findPrompt()` | Find prompt position. Returns `{ row, col }` or `null`. | | `readPromptText()` | Read text after prompt marker. Returns string or `null`. | ### State -| Property | Type | Description | -|----------|------|-------------| -| `pendingText` | `string` | Unacknowledged text (read-only) | -| `hasPending` | `boolean` | `true` if overlay has any content | -| `state` | `ZerolagInputState` | Full snapshot: pendingText, flushedLength, flushedText, visible, promptPosition | +| Property | Type | Description | +| ------------- | ------------------- | ------------------------------------------------------------------------------- | +| `pendingText` | `string` | Unacknowledged text (read-only) | +| `hasPending` | `boolean` | `true` if overlay has any content | +| `state` | `ZerolagInputState` | Full snapshot: pendingText, flushedLength, flushedText, visible, promptPosition | ### Options @@ -311,7 +323,9 @@ zerolag.rerender(); terminal.options.fontSize = 18; zerolag.refreshFont(); -function onReconnect() { zerolag.rerender(); } +function onReconnect() { + zerolag.rerender(); +} ``` --- diff --git a/packages/xterm-zerolag-input/src/index.ts b/packages/xterm-zerolag-input/src/index.ts index c16416d9..98428e25 100644 --- a/packages/xterm-zerolag-input/src/index.ts +++ b/packages/xterm-zerolag-input/src/index.ts @@ -1,10 +1,11 @@ export { ZerolagInputAddon } from './zerolag-input-addon.js'; export type { - XtermTerminal, - XtermAddon, - ZerolagInputOptions, - ZerolagInputState, - PromptFinder, - PromptPosition, - CellDimensions, + XtermTerminal, + XtermAddon, + ZerolagInputOptions, + ZerolagInputState, + ZerolagInputDraftState, + PromptFinder, + PromptPosition, + CellDimensions, } from './types.js'; diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts index 08dd0050..10fadab0 100644 --- a/packages/xterm-zerolag-input/src/types.ts +++ b/packages/xterm-zerolag-input/src/types.ts @@ -130,6 +130,8 @@ export interface ZerolagInputOptions { export interface ZerolagInputState { /** Characters typed but not yet acknowledged by the server */ pendingText: string; + /** Current uncommitted IME candidate text */ + compositionText: string; /** Number of characters flushed to PTY but echo not yet received */ flushedLength: number; /** Text content of the flushed portion */ @@ -140,6 +142,20 @@ export interface ZerolagInputState { promptPosition: PromptPosition | null; } +/** + * Serializable editable input state. + * + * Transient IME composition is deliberately excluded: a browser cannot resume + * an operating-system composition session after reload. Consumers should fold + * any visible composition candidate into `pendingText` before persisting it. + */ +export interface ZerolagInputDraftState { + /** Text that has not been sent to the terminal process. */ + pendingText: string; + /** Text sent to the process that is still tracked as editable input. */ + flushedText: string; +} + /** Cell dimensions in CSS pixels. */ export interface CellDimensions { width: number; diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 5e201ba4..39f24ff7 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -1,6 +1,7 @@ import type { XtermTerminal, XtermAddon, + ZerolagInputDraftState, ZerolagInputOptions, ZerolagInputState, PromptPosition, @@ -64,6 +65,7 @@ export class ZerolagInputAddon implements XtermAddon { // Text state private _pendingText = ''; + private _compositionText = ''; private _flushedOffset = 0; private _flushedText = ''; private _bufferDetectDone = false; @@ -86,6 +88,7 @@ export class ZerolagInputAddon implements XtermAddon { private _scrollTimer: ReturnType | null = null; private _scrollHandler: (() => void) | null = null; private _scrollViewport: Element | null = null; + private _viewportPinned = false; constructor(options?: ZerolagInputOptions) { this._options = { @@ -124,13 +127,13 @@ export class ZerolagInputAddon implements XtermAddon { this._scrollHandler = () => { try { const buf = this._terminal!.buffer.active; - if (buf.viewportY !== buf.baseY) { + if (buf.viewportY !== buf.baseY && !this._viewportPinned) { this._overlay!.style.display = 'none'; if (this._scrollTimer) { clearTimeout(this._scrollTimer); this._scrollTimer = null; } - } else if (this._pendingText || this._flushedOffset > 0) { + } else if (this._pendingText || this._compositionText || this._flushedOffset > 0) { if (this._scrollTimer) clearTimeout(this._scrollTimer); this._scrollTimer = setTimeout(() => { this._scrollTimer = null; @@ -176,7 +179,6 @@ export class ZerolagInputAddon implements XtermAddon { * Call this when the user types a character (charCode >= 32, length === 1). */ addChar(char: string): void { - if (!this._pendingText && !this._flushedOffset) this._detectBufferText(); this._pendingText += char; this._render(); } @@ -186,18 +188,48 @@ export class ZerolagInputAddon implements XtermAddon { */ appendText(text: string): void { if (!text) return; - if (!this._pendingText && !this._flushedOffset) this._detectBufferText(); this._pendingText += text; this._render(); } + /** + * Replace the transient IME candidate shown after committed pending text. + * Candidate updates are replacements, not appends: mobile keyboards rewrite + * the whole composing word as suggestions change. + */ + setCompositionText(text: string): void { + this._compositionText = text; + if (text || this._pendingText || this._flushedOffset > 0) { + this._render(); + } else { + this._hide(); + } + } + + /** + * Atomically replace the transient IME candidate with finalized input. + */ + commitComposition(text: string): void { + this._compositionText = ''; + if (text) this._pendingText += text; + if (this._pendingText || this._flushedOffset > 0) { + this._render(); + } else { + this._hide(); + } + } + + /** Remove an unfinished IME candidate without changing committed input. */ + clearComposition(): void { + this.commitComposition(''); + } + /** * Remove the last character from the overlay. * * Cascade order: * 1. Remove from `pendingText` if non-empty → returns `'pending'` * 2. Decrement `flushedOffset` if pending is empty but flushed exists → returns `'flushed'` - * 3. Try `detectBufferText()` if both are empty, then decrement → returns `'flushed'` * * @returns The source of the removed character, or `false` if nothing to remove. * @@ -229,20 +261,6 @@ export class ZerolagInputAddon implements XtermAddon { return 'flushed'; } - // Both empty — try detecting text already on the prompt line - // (handles tab completion, arrow-key edits, etc.) - this._detectBufferText(); - if (this._flushedOffset > 0) { - this._flushedOffset--; - this._flushedText = this._flushedText.slice(0, -1); - if (this._flushedOffset > 0) { - this._render(); - } else { - this._hide(); - } - return 'flushed'; - } - return false; } @@ -252,6 +270,7 @@ export class ZerolagInputAddon implements XtermAddon { */ clear(): void { this._pendingText = ''; + this._compositionText = ''; this._flushedOffset = 0; this._flushedText = ''; this._bufferDetectDone = false; @@ -260,6 +279,33 @@ export class ZerolagInputAddon implements XtermAddon { this._hide(); } + /** + * Restore serialized editable input atomically. + * + * Pass `render=false` while a different session's terminal frame is still + * visible. A later `rerender()` will locate the prompt in the current frame, + * avoiding a stale prompt-position cache during asynchronous tab switches. + */ + restoreDraft(draft: ZerolagInputDraftState, render = true): void { + this._pendingText = typeof draft?.pendingText === 'string' ? draft.pendingText : ''; + this._compositionText = ''; + this._flushedText = typeof draft?.flushedText === 'string' ? draft.flushedText : ''; + this._flushedOffset = this._flushedText.length; + this._bufferDetectDone = false; + this._lastRenderKey = ''; + this._lastPromptPos = null; + + if (!render) { + this._hide(); + return; + } + if (this.hasPending) { + this._render(); + } else { + this._hide(); + } + } + // ─── Flushed text tracking ──────────────────────────────────────── /** @@ -297,7 +343,7 @@ export class ZerolagInputAddon implements XtermAddon { clearFlushed(): void { this._flushedOffset = 0; this._flushedText = ''; - if (this._pendingText) { + if (this._pendingText || this._compositionText) { this._render(); } else { this._hide(); @@ -312,12 +358,32 @@ export class ZerolagInputAddon implements XtermAddon { * that move the prompt. */ rerender(): void { - if (this._pendingText || this._flushedOffset > 0) { + if (this._pendingText || this._compositionText || this._flushedOffset > 0) { this._lastRenderKey = ''; this._render(); } } + /** + * Keep the pending draft pinned to the visible terminal rows while xterm + * scrollback is away from the bottom. Consumers should enable this only + * while a separate input surface (for example a phone keyboard) stays active. + */ + setViewportPinned(pinned: boolean): void { + const next = pinned === true; + if (this._viewportPinned === next) return; + this._viewportPinned = next; + this._lastRenderKey = ''; + + if (!this._terminal || !this._overlay || !this.hasPending) return; + const buf = this._terminal.buffer.active; + if (!next && buf.viewportY !== buf.baseY) { + this._overlay.style.display = 'none'; + return; + } + this._render(); + } + /** * Re-read font properties from the terminal and re-render. * Call after font size changes, theme changes, etc. @@ -325,7 +391,7 @@ export class ZerolagInputAddon implements XtermAddon { refreshFont(): void { this._cacheFont(); this._lastRenderKey = ''; - if (this._pendingText || this._flushedOffset > 0) this._render(); + if (this._pendingText || this._compositionText || this._flushedOffset > 0) this._render(); } // ─── Buffer detection ───────────────────────────────────────────── @@ -391,7 +457,7 @@ export class ZerolagInputAddon implements XtermAddon { this._options.prompt = finder; this._lastPromptPos = null; this._lastRenderKey = ''; - if (this._pendingText || this._flushedOffset > 0) this._render(); + if (this._pendingText || this._compositionText || this._flushedOffset > 0) this._render(); } // ─── Prompt utilities ───────────────────────────────────────────── @@ -425,15 +491,21 @@ export class ZerolagInputAddon implements XtermAddon { return this._pendingText; } - /** Whether there is any overlay content (pending or flushed). */ + /** Current uncommitted IME candidate text. */ + get compositionText(): string { + return this._compositionText; + } + + /** Whether there is any overlay content (pending, composing, or flushed). */ get hasPending(): boolean { - return this._pendingText.length > 0 || this._flushedOffset > 0; + return this._pendingText.length > 0 || this._compositionText.length > 0 || this._flushedOffset > 0; } /** Read-only state snapshot. */ get state(): ZerolagInputState { return { pendingText: this._pendingText, + compositionText: this._compositionText, flushedLength: this._flushedOffset, flushedText: this._flushedText, visible: this._overlay !== null && this._overlay.style.display !== 'none', @@ -505,31 +577,46 @@ export class ZerolagInputAddon implements XtermAddon { private _render(): void { if (!this._terminal || !this._overlay) return; - if (!this._pendingText && !(this._flushedOffset > 0)) { + if (!this._pendingText && !this._compositionText && !(this._flushedOffset > 0)) { this._overlay.style.display = 'none'; return; } try { const buf = this._terminal.buffer.active; + const terminalRows = Math.max(1, this._terminal.rows); + const pinnedToScrolledViewport = this._viewportPinned && buf.viewportY !== buf.baseY; // Hide overlay when scrolled up — prompt is at bottom, not in viewport - if (buf.viewportY !== buf.baseY) { + if (buf.viewportY !== buf.baseY && !pinnedToScrolledViewport) { this._overlay.style.display = 'none'; return; } - // Re-scan for prompt on every render (full-screen redraws can move it) - const prompt = this.findPrompt(); - if (prompt) { - // When flushed text exists, lock column to prevent jitter from - // redraws that temporarily shift the prompt marker. Allow row changes. - if (this._lastPromptPos && this._flushedOffset > 0) { - this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col }; - } else { - this._lastPromptPos = prompt; + if (pinnedToScrolledViewport) { + if (this._lastPromptPos) { + this._lastPromptPos = { + row: terminalRows - 1, + col: this._lastPromptPos.col, + }; + } + } else { + // Re-scan for prompt on every render (full-screen redraws can move it) + const prompt = this.findPrompt(); + if (prompt) { + // When flushed text exists, lock column to prevent jitter from + // redraws that temporarily shift the prompt marker. Allow row changes. + if (this._lastPromptPos && this._flushedOffset > 0) { + this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col }; + } else { + this._lastPromptPos = prompt; + } + } else if (!this._lastPromptPos) { + this._overlay.style.display = 'none'; + return; } - } else if (!this._lastPromptPos) { + } + if (!this._lastPromptPos) { this._overlay.style.display = 'none'; return; } @@ -547,10 +634,10 @@ export class ZerolagInputAddon implements XtermAddon { const startCol = activePrompt.col + offset; // Build display text: flushed chars + pending chars - let displayText = this._pendingText; + let displayText = this._pendingText + this._compositionText; if (this._flushedOffset > 0) { if (this._flushedText && this._flushedText.length === this._flushedOffset) { - displayText = this._flushedText + this._pendingText; + displayText = this._flushedText + this._pendingText + this._compositionText; } else { // Fallback: read flushed chars from terminal buffer const absRow = buf.viewportY + activePrompt.row; @@ -558,60 +645,67 @@ export class ZerolagInputAddon implements XtermAddon { if (line) { const lineText = line.translateToString(true); const flushedChars = lineText.slice(startCol, startCol + this._flushedOffset); - displayText = flushedChars + this._pendingText; + displayText = flushedChars + this._pendingText + this._compositionText; } } } - // Skip redundant re-renders — include text content to detect - // same-length changes (e.g., setFlushed with different text) - const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`; - if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; - this._lastRenderKey = renderKey; - - // Split into visual lines by column width (CJK wide chars = 2 cols) + // Split explicit draft line breaks and soft-wrap each resulting row by + // terminal cell width (CJK wide chars = 2 cols). const firstLineCols = Math.max(1, totalCols - startCol); const chars = [...displayText]; // proper Unicode iteration const lines: string[] = []; - let ci = 0; - // First line: remaining columns after prompt - { - let lineStr = ''; - let lineCols = 0; - while (ci < chars.length) { - const cw = charCellWidth(this._terminal, chars[ci]); - if (lineCols + cw > firstLineCols) break; - lineStr += chars[ci]; - lineCols += cw; - ci++; + let lineStr = ''; + let lineCols = 0; + let lineCapacity = firstLineCols; + for (let ci = 0; ci < chars.length; ci++) { + const char = chars[ci]; + if (char === '\r' || char === '\n') { + if (char === '\r' && chars[ci + 1] === '\n') ci++; + lines.push(lineStr); + lineStr = ''; + lineCols = 0; + lineCapacity = totalCols; + continue; } - lines.push(lineStr); - } - // Subsequent lines: full terminal width - while (ci < chars.length) { - let lineStr = ''; - let lineCols = 0; - while (ci < chars.length) { - const cw = charCellWidth(this._terminal, chars[ci]); - if (lineCols + cw > totalCols) break; - lineStr += chars[ci]; - lineCols += cw; - ci++; + + const charCols = charCellWidth(this._terminal, char); + if (lineCols + charCols > lineCapacity) { + lines.push(lineStr); + lineStr = ''; + lineCols = 0; + lineCapacity = totalCols; } - lines.push(lineStr); + lineStr += char; + lineCols += charCols; } + lines.push(lineStr); + + // Keep the newest draft row visible at the bottom of the terminal. As + // the draft grows it expands upward; deleting rows lets it shrink back + // toward the prompt. Drafts taller than the viewport retain their tail. + const hiddenLineCount = Math.max(0, lines.length - terminalRows); + const visibleLines = lines.slice(hiddenLineCount); + const visibleStartCol = hiddenLineCount > 0 ? 0 : startCol; + const renderRow = Math.max(0, Math.min(activePrompt.row, terminalRows - visibleLines.length)); + + // Skip redundant re-renders — include the resolved geometry so a + // keyboard resize still repositions identical text. + const renderKey = `${displayText}:${visibleStartCol}:${renderRow}:${activePrompt.col}:${totalCols}:${terminalRows}:${this._flushedOffset}`; + if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; + this._lastRenderKey = renderKey; const cursorColor = this._options.cursorColor ?? this._terminal.options.theme?.cursor ?? DEFAULT_CURSOR; renderOverlay(this._overlay, { - lines, - startCol, + lines: visibleLines, + startCol: visibleStartCol, totalCols, cellW, cellH, charTop, charHeight, - promptRow: activePrompt.row, + promptRow: renderRow, font: this._font, showCursor: this._options.showCursor, cursorColor, diff --git a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts index 74063761..d14ca6e2 100644 --- a/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts +++ b/packages/xterm-zerolag-input/test/zerolag-input-addon.test.ts @@ -79,6 +79,122 @@ describe('ZerolagInputAddon', () => { expect(addon.pendingText).toBe(''); expect(addon.hasPending).toBe(false); }); + + it('renders explicit line breaks as separate terminal rows', () => { + const { addon, mock } = tracked(); + + addon.appendText('first\nsecond'); + + const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + expect(addon.pendingText).toBe('first\nsecond'); + expect(overlay.children).toHaveLength(3); // two rows and the cursor + expect(overlay.children[0].textContent).toBe('first'); + expect(overlay.children[1].textContent).toBe('second'); + }); + + it('pins a growing multiline draft to the bottom and shrinks it after delete', () => { + const lines = Array.from({ length: 24 }, () => ''); + lines[22] = '$ '; + const mock = createMockTerminal({ + buffer: { lines }, + cols: 20, + rows: 24, + cellHeight: 10, + }); + const addon = new ZerolagInputAddon({ + prompt: { type: 'character', char: '$', offset: 2 }, + }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + + addon.appendText('one\ntwo\nthree'); + + const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + expect(overlay.style.top).toBe('210px'); + expect( + Array.from(overlay.children) + .slice(0, 3) + .map((element) => element.textContent) + ).toEqual(['one', 'two', 'three']); + + for (let i = 0; i < 6; i++) addon.removeChar(); + + expect(addon.pendingText).toBe('one\ntwo'); + expect(overlay.style.top).toBe('220px'); + expect( + Array.from(overlay.children) + .slice(0, 2) + .map((element) => element.textContent) + ).toEqual(['one', 'two']); + }); + + it('keeps only the visible tail when a draft exceeds the terminal height', () => { + const lines = ['', '', '', '$ ']; + const mock = createMockTerminal({ + buffer: { lines }, + cols: 20, + rows: 4, + cellHeight: 10, + }); + const addon = new ZerolagInputAddon({ + prompt: { type: 'character', char: '$', offset: 2 }, + }); + mock.terminal.loadAddon(addon); + cleanups.push(() => { + addon.dispose(); + mock.cleanup(); + }); + + addon.appendText('a\nb\nc\nd\ne\nf'); + + const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + expect(overlay.style.top).toBe('0px'); + expect( + Array.from(overlay.children) + .slice(0, 4) + .map((element) => element.textContent) + ).toEqual(['c', 'd', 'e', 'f']); + }); + }); + + describe('composition text', () => { + it('renders changing IME candidates without committing them to pending text', () => { + const { addon, mock } = tracked(); + addon.appendText('first '); + + addon.setCompositionText('sec'); + addon.setCompositionText('second'); + + const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + expect(addon.pendingText).toBe('first '); + expect(addon.compositionText).toBe('second'); + expect(overlay.textContent).toContain('first second'); + }); + + it('commits the finalized composition atomically', () => { + const { addon } = tracked(); + addon.appendText('first '); + addon.setCompositionText('second'); + + addon.commitComposition('second'); + + expect(addon.pendingText).toBe('first second'); + expect(addon.compositionText).toBe(''); + expect(addon.hasPending).toBe(true); + }); + + it('clear removes an unfinished composition', () => { + const { addon } = tracked(); + addon.setCompositionText('candidate'); + + addon.clear(); + + expect(addon.compositionText).toBe(''); + expect(addon.state.visible).toBe(false); + }); }); describe('removeChar', () => { @@ -122,14 +238,21 @@ describe('ZerolagInputAddon', () => { expect(addon.hasPending).toBe(false); }); - it('detects buffer text and removes from it when both empty', () => { + it('does not infer editable text from the terminal buffer', () => { const { addon } = tracked(['$ hello']); - // Both pending and flushed are empty, but buffer has text + const source = addon.removeChar(); - expect(source).toBe('flushed'); - // "hello" (5 chars) detected, then one removed = 4 - expect(addon.getFlushed().count).toBe(4); - expect(addon.getFlushed().text).toBe('hell'); + + expect(source).toBe(false); + expect(addon.getFlushed()).toEqual({ count: 0, text: '' }); + }); + + it('removes explicitly detected completion text as flushed input', () => { + const { addon } = tracked(['$ hello']); + expect(addon.detectBufferText()).toBe('hello'); + + expect(addon.removeChar()).toBe('flushed'); + expect(addon.getFlushed()).toEqual({ count: 4, text: 'hell' }); }); it('returns false on empty prompt with no buffer text', () => { @@ -222,6 +345,32 @@ describe('ZerolagInputAddon', () => { expect(s1.pendingText).toBe('a'); expect(s2.pendingText).toBe('ab'); }); + + it('restores an editable draft without rendering against a stale terminal frame', () => { + const { addon, mock } = tracked(['$ stale session']); + + addon.restoreDraft( + { + pendingText: ' pending', + flushedText: 'already sent', + }, + false + ); + + expect(addon.state).toMatchObject({ + pendingText: ' pending', + compositionText: '', + flushedLength: 'already sent'.length, + flushedText: 'already sent', + visible: false, + promptPosition: null, + }); + + mock.setLines(['$ current session']); + addon.rerender(); + expect(addon.state.visible).toBe(true); + expect(addon.state.promptPosition).not.toBeNull(); + }); }); describe('prompt detection', () => { @@ -295,22 +444,19 @@ describe('ZerolagInputAddon', () => { expect(addon.getFlushed().count).toBe(0); }); - it('suppressBufferDetection also blocks implicit detection in addChar', () => { + it('suppressBufferDetection keeps explicit detection disabled while typing', () => { const { addon } = tracked(['$ ink status bar']); addon.suppressBufferDetection(); - // addChar calls _detectBufferText internally on first keystroke addon.addChar('x'); - // Should have only 'x' pending, NOT the buffer text as flushed expect(addon.pendingText).toBe('x'); expect(addon.getFlushed().count).toBe(0); }); - it('suppressBufferDetection also blocks detection in removeChar cascade', () => { + it('suppressBufferDetection keeps explicit detection disabled before backspace', () => { const { addon } = tracked(['$ buffer text']); addon.suppressBufferDetection(); - // removeChar step 3 calls _detectBufferText — should be blocked expect(addon.removeChar()).toBe(false); expect(addon.getFlushed().count).toBe(0); }); @@ -426,6 +572,25 @@ describe('ZerolagInputAddon', () => { expect(() => addon.refreshFont()).not.toThrow(); expect(addon.hasPending).toBe(true); }); + + it('can pin a pending draft to the viewport while terminal history is scrolled', () => { + const { addon, mock } = tracked(['$ draft']); + addon.appendText('draft'); + const overlay = mock.terminal.element.querySelector('.xterm-screen')!.lastElementChild as HTMLDivElement; + + mock.terminal.buffer.active.baseY = 8; + mock.terminal.buffer.active.viewportY = 2; + mock.terminal.element.querySelector('.xterm-viewport')!.dispatchEvent(new Event('scroll')); + expect(addon.state.visible).toBe(false); + + addon.setViewportPinned(true); + expect(addon.state.visible).toBe(true); + expect(overlay.textContent).toContain('draft'); + + addon.setViewportPinned(false); + expect(addon.state.visible).toBe(false); + expect(addon.pendingText).toBe('draft'); + }); }); describe('tab-switch save/restore pattern', () => { @@ -705,23 +870,21 @@ describe('ZerolagInputAddon', () => { }); }); - describe('addChar implicit buffer detection', () => { - it('first keystroke detects existing buffer text as flushed', () => { + describe('typing with pre-existing buffer content', () => { + it('does not adopt existing buffer text on the first keystroke', () => { const { addon } = tracked(['$ existing']); - // First addChar should trigger _detectBufferText + addon.addChar('!'); expect(addon.pendingText).toBe('!'); - expect(addon.getFlushed().count).toBe(8); // 'existing' - expect(addon.getFlushed().text).toBe('existing'); + expect(addon.getFlushed()).toEqual({ count: 0, text: '' }); }); - it('second keystroke does NOT re-detect', () => { + it('does not adopt existing buffer text on appendText', () => { const { addon } = tracked(['$ existing']); - addon.addChar('a'); - addon.addChar('b'); - // Should NOT detect again — flushed from first char remains - expect(addon.pendingText).toBe('ab'); - expect(addon.getFlushed().count).toBe(8); // still 'existing' + + addon.appendText('pasted'); + expect(addon.pendingText).toBe('pasted'); + expect(addon.getFlushed()).toEqual({ count: 0, text: '' }); }); }); }); diff --git a/scripts/build.mjs b/scripts/build.mjs index 830d5ee1..08c5bd48 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -67,6 +67,8 @@ appendFileSync( // 4. Minify frontend assets run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); +run('minify terminal-input-state.js', 'npx esbuild dist/web/public/terminal-input-state.js --minify --outfile=dist/web/public/terminal-input-state.js --allow-overwrite'); +run('minify terminal-input-controller.js', 'npx esbuild dist/web/public/terminal-input-controller.js --minify --outfile=dist/web/public/terminal-input-controller.js --allow-overwrite'); run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); @@ -93,6 +95,8 @@ console.log('\n[build] content-hash cache busting'); 'notification-manager.js', 'keyboard-accessory.js', 'input-cjk.js', + 'terminal-input-state.js', + 'terminal-input-controller.js', 'sanitize-html.js', 'app.js', 'terminal-ui.js', diff --git a/scripts/check-public-assets.mjs b/scripts/check-public-assets.mjs index a9b5daeb..54ddc885 100644 --- a/scripts/check-public-assets.mjs +++ b/scripts/check-public-assets.mjs @@ -10,7 +10,7 @@ const publicRoot = resolve(repoRoot, 'src/web/public'); const prettierBin = resolve(repoRoot, 'node_modules/.bin/prettier'); const checkedExtensions = new Set(['.js', '.css', '.html', '.json']); -function collectTextAssets(dir) { +export function collectTextAssets(dir) { const files = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); @@ -25,42 +25,49 @@ function collectTextAssets(dir) { return files; } -function findNullByte(buffer) { +export function findNullByte(buffer) { for (let i = 0; i < buffer.length; i += 1) { if (buffer[i] === 0) return i; } return -1; } -const files = collectTextAssets(publicRoot); -const failures = []; +function main() { + const files = collectTextAssets(publicRoot); + const failures = []; -for (const file of files) { - const rel = relative(repoRoot, file); - const data = readFileSync(file); - const nullByteIndex = findNullByte(data); - if (nullByteIndex !== -1) { - failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`); - } + for (const file of files) { + const rel = relative(repoRoot, file); + const data = readFileSync(file); + const nullByteIndex = findNullByte(data); + if (nullByteIndex !== -1) { + failures.push(`${rel}: contains literal NUL byte at offset ${nullByteIndex}`); + } - if (extname(file) === '.js') { - try { - execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' }); - } catch (err) { - failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`); + if (extname(file) === '.js') { + try { + execFileSync(process.execPath, ['--check', file], { cwd: repoRoot, stdio: 'pipe' }); + } catch (err) { + failures.push(`${rel}: JavaScript syntax check failed\n${String(err.stderr || err.message).trim()}`); + } } } -} -try { - execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' }); -} catch (err) { - failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`); -} + try { + execFileSync(prettierBin, ['--check', ...files], { cwd: repoRoot, stdio: 'pipe' }); + } catch (err) { + failures.push(`Prettier public asset check failed\n${String(err.stdout || err.stderr || err.message).trim()}`); + } + + if (failures.length > 0) { + console.error(failures.join('\n\n')); + process.exitCode = 1; + return; + } -if (failures.length > 0) { - console.error(failures.join('\n\n')); - process.exit(1); + console.log(`Public asset checks passed (${files.length} files).`); } -console.log(`Public asset checks passed (${files.length} files).`); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/src/git-repository-browser.ts b/src/git-repository-browser.ts new file mode 100644 index 00000000..5acc1811 --- /dev/null +++ b/src/git-repository-browser.ts @@ -0,0 +1,760 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import { basename, isAbsolute, relative, resolve } from 'node:path'; + +const GIT_METADATA_LIMIT = 2 * 1024 * 1024; +const GIT_DIFF_LIMIT = 2 * 1024 * 1024; +const FILE_PREVIEW_LIMIT = 1024 * 1024; +const GIT_TIMEOUT_MS = 10_000; +const COMMIT_LIMIT = 30; + +interface GitCommandResult { + stdout: Buffer; + stderr: Buffer; + code: number; + truncated: boolean; +} + +interface RunGitOptions { + allowedExitCodes?: number[]; + maxBytes?: number; + truncate?: boolean; +} + +export interface GitWorktreeScope { + id: string; + path: string; + name: string; + branch: string | null; + head: string; + current: boolean; + main: boolean; + locked: boolean; +} + +export interface GitRepositoryDiscovery { + repositoryRoot: string; + currentScopeId: string; + worktrees: GitWorktreeScope[]; +} + +export interface GitChange { + path: string; + oldPath?: string; + code: string; + status: 'modified' | 'added' | 'deleted' | 'renamed' | 'copied' | 'untracked' | 'conflicted'; + staged: boolean; + unstaged: boolean; + additions: number | null; + deletions: number | null; + binary: boolean; +} + +export interface GitCommitSummary { + hash: string; + shortHash: string; + author: string; + authoredAt: string; + subject: string; +} + +export interface GitRepositoryOverview { + available: boolean; + repositoryRoot: string | null; + selectedScopeId: string | null; + worktrees: GitWorktreeScope[]; + changes: GitChange[]; + commits: GitCommitSummary[]; +} + +export interface GitCommitDetails extends GitCommitSummary { + changes: GitChange[]; +} + +export interface GitDiffDetail { + path: string; + oldPath?: string; + commit: string | null; + label: string; + patch: string; + beforeContent: string | null; + afterContent: string | null; + beforeExists: boolean; + afterExists: boolean; + binary: boolean; + truncated: boolean; + additions: number; + deletions: number; +} + +interface GitScopeResolution { + repository: GitRepositoryDiscovery; + scope: GitWorktreeScope; +} + +interface FileSnapshot { + exists: boolean; + binary: boolean; + truncated: boolean; + content: string | null; +} + +export class GitRepositoryScopeError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitRepositoryScopeError'; + } +} + +async function runGit(cwd: string, args: string[], options: RunGitOptions = {}): Promise { + const allowedExitCodes = options.allowedExitCodes ?? [0]; + const maxBytes = options.maxBytes ?? GIT_METADATA_LIMIT; + const truncate = options.truncate ?? false; + + return new Promise((resolvePromise, reject) => { + const child = spawn('git', ['-c', 'core.quotepath=false', '-c', 'color.ui=false', ...args], { + cwd, + env: { + ...process.env, + GIT_OPTIONAL_LOCKS: '0', + LC_ALL: 'C', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let truncated = false; + let limitError: Error | null = null; + let timedOut = false; + let settled = false; + + const append = (chunks: Buffer[], chunk: Buffer, currentBytes: number): number => { + const remaining = Math.max(0, maxBytes - currentBytes); + if (remaining > 0) chunks.push(chunk.subarray(0, remaining)); + if (chunk.length > remaining) { + if (truncate) { + truncated = true; + child.kill(); + } else { + limitError = new Error(`Git output exceeded ${maxBytes} bytes`); + child.kill(); + } + } + return currentBytes + Math.min(chunk.length, remaining); + }; + + child.stdout.on('data', (chunk: Buffer) => { + stdoutBytes = append(stdout, Buffer.from(chunk), stdoutBytes); + }); + child.stderr.on('data', (chunk: Buffer) => { + stderrBytes = append(stderr, Buffer.from(chunk), stderrBytes); + }); + + const timer = setTimeout(() => { + timedOut = true; + child.kill(); + }, GIT_TIMEOUT_MS); + timer.unref?.(); + + child.once('error', (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(err); + }); + + child.once('close', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (timedOut) { + reject(new Error(`Git command timed out after ${GIT_TIMEOUT_MS}ms`)); + return; + } + if (limitError) { + reject(limitError); + return; + } + + const result: GitCommandResult = { + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + code: code ?? (truncated ? 0 : 1), + truncated, + }; + if (!truncated && !allowedExitCodes.includes(result.code)) { + const detail = result.stderr.toString('utf8').trim(); + reject(new Error(detail || `Git exited with code ${result.code}`)); + return; + } + resolvePromise(result); + }); + }); +} + +function scopeId(path: string): string { + return createHash('sha256').update(path).digest('hex').slice(0, 16); +} + +function parseWorktrees(output: string, currentRoot: string): GitWorktreeScope[] { + return output + .split('\0\0') + .filter(Boolean) + .map((record, index) => { + const fields = record.split('\0'); + const values = new Map(); + let locked = false; + for (const field of fields) { + const separator = field.indexOf(' '); + const key = separator === -1 ? field : field.slice(0, separator); + const value = separator === -1 ? '' : field.slice(separator + 1); + values.set(key, value); + if (key === 'locked') locked = true; + } + const path = values.get('worktree') || ''; + const branchRef = values.get('branch'); + return { + id: scopeId(path), + path, + name: basename(path) || path, + branch: branchRef?.replace(/^refs\/heads\//, '') || null, + head: values.get('HEAD') || '', + current: resolve(path) === resolve(currentRoot), + main: index === 0, + locked, + }; + }) + .filter((scope) => Boolean(scope.path)); +} + +export async function discoverGitRepository(workingDir: string): Promise { + try { + const rootResult = await runGit(workingDir, ['rev-parse', '--show-toplevel'], { + allowedExitCodes: [0, 128], + }); + if (rootResult.code !== 0) return null; + const currentRoot = rootResult.stdout.toString('utf8').trim(); + if (!currentRoot) return null; + + const worktreeResult = await runGit(currentRoot, ['worktree', 'list', '--porcelain', '-z']); + const worktrees = parseWorktrees(worktreeResult.stdout.toString('utf8'), currentRoot); + if (worktrees.length === 0) return null; + const currentScope = worktrees.find((scope) => scope.current) ?? worktrees[0]; + return { + repositoryRoot: worktrees[0].path, + currentScopeId: currentScope.id, + worktrees, + }; + } catch { + return null; + } +} + +async function resolveScope(workingDir: string, requestedScope?: string): Promise { + const repository = await discoverGitRepository(workingDir); + if (!repository) return null; + const scope = + !requestedScope || requestedScope === 'current' + ? repository.worktrees.find((candidate) => candidate.id === repository.currentScopeId) + : repository.worktrees.find((candidate) => candidate.id === requestedScope); + if (!scope) { + throw new GitRepositoryScopeError('Repository worktree scope not found'); + } + return { repository, scope }; +} + +export async function resolveRepositoryBrowseRoot(workingDir: string, requestedScope?: string): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + return resolution?.scope.path ?? null; +} + +function parseStatusCode(code: string): GitChange['status'] { + if (code === '??') return 'untracked'; + if (code.includes('U')) return 'conflicted'; + if (code.includes('R')) return 'renamed'; + if (code.includes('C')) return 'copied'; + if (code.includes('D')) return 'deleted'; + if (code.includes('A')) return 'added'; + return 'modified'; +} + +function shortStatusCode(status: GitChange['status']): string { + switch (status) { + case 'untracked': + return '?'; + case 'conflicted': + return 'U'; + case 'renamed': + return 'R'; + case 'copied': + return 'C'; + case 'deleted': + return 'D'; + case 'added': + return 'A'; + default: + return 'M'; + } +} + +function parseStatus(output: string): GitChange[] { + const records = output.split('\0'); + const changes: GitChange[] = []; + for (let i = 0; i < records.length; i++) { + const record = records[i]; + if (!record) continue; + + let xy = '..'; + let path = ''; + let oldPath: string | undefined; + if (record.startsWith('1 ')) { + const fields = record.split(' '); + xy = fields[1] || '..'; + path = fields.slice(8).join(' '); + } else if (record.startsWith('2 ')) { + const fields = record.split(' '); + xy = fields[1] || '..'; + path = fields.slice(9).join(' '); + oldPath = records[++i] || undefined; + } else if (record.startsWith('? ')) { + xy = '??'; + path = record.slice(2); + } else if (record.startsWith('u ')) { + const fields = record.split(' '); + xy = 'UU'; + path = fields.slice(10).join(' '); + } else { + continue; + } + + const status = parseStatusCode(xy); + changes.push({ + path, + ...(oldPath ? { oldPath } : {}), + code: shortStatusCode(status), + status, + staged: xy[0] !== '.' && xy[0] !== '?', + unstaged: xy[1] !== '.' || xy === '??', + additions: null, + deletions: null, + binary: false, + }); + } + return changes; +} + +function parseNumstat( + output: string +): Map { + const stats = new Map(); + const records = output.split('\0'); + for (let i = 0; i < records.length; i++) { + const record = records[i]; + if (!record) continue; + const firstTab = record.indexOf('\t'); + const secondTab = record.indexOf('\t', firstTab + 1); + if (firstTab === -1 || secondTab === -1) continue; + const added = record.slice(0, firstTab); + const deleted = record.slice(firstTab + 1, secondTab); + let path = record.slice(secondTab + 1); + if (!path) { + // Rename/copy numstat records place old and new names in the next fields. + i += 2; + path = records[i] || ''; + } + if (!path) continue; + const binary = added === '-' || deleted === '-'; + stats.set(path, { + additions: binary ? null : Number.parseInt(added, 10) || 0, + deletions: binary ? null : Number.parseInt(deleted, 10) || 0, + binary, + }); + } + return stats; +} + +async function hasHead(cwd: string): Promise { + const result = await runGit(cwd, ['rev-parse', '--verify', 'HEAD'], { + allowedExitCodes: [0, 128], + }); + return result.code === 0; +} + +async function getChanges(cwd: string): Promise { + const statusResult = await runGit(cwd, ['status', '--porcelain=v2', '-z', '--untracked-files=normal', '--renames']); + const changes = parseStatus(statusResult.stdout.toString('utf8')); + if (changes.length === 0 || !(await hasHead(cwd))) return changes; + + const statResult = await runGit(cwd, ['diff', '--no-ext-diff', '--numstat', '-z', 'HEAD', '--']); + const stats = parseNumstat(statResult.stdout.toString('utf8')); + for (const change of changes) { + const stat = stats.get(change.path); + if (!stat) continue; + change.additions = stat.additions; + change.deletions = stat.deletions; + change.binary = stat.binary; + } + return changes.sort((a, b) => a.path.localeCompare(b.path)); +} + +function parseCommitRecord(record: string): GitCommitSummary | null { + const [hash, shortHash, author, authoredAt, ...subjectParts] = record.split('\x1f'); + if (!hash || !shortHash) return null; + return { + hash, + shortHash, + author: author || '', + authoredAt: authoredAt || '', + subject: subjectParts.join('\x1f'), + }; +} + +async function getRecentCommits(cwd: string): Promise { + if (!(await hasHead(cwd))) return []; + const result = await runGit(cwd, [ + 'log', + `-${COMMIT_LIMIT}`, + '--date=iso-strict', + '--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s%x00', + ]); + return result.stdout + .toString('utf8') + .split('\0') + .map(parseCommitRecord) + .filter((commit): commit is GitCommitSummary => commit !== null); +} + +export async function getGitRepositoryOverview( + workingDir: string, + requestedScope?: string +): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + if (!resolution) { + return { + available: false, + repositoryRoot: null, + selectedScopeId: null, + worktrees: [], + changes: [], + commits: [], + }; + } + + const [changes, commits] = await Promise.all([ + getChanges(resolution.scope.path), + getRecentCommits(resolution.scope.path), + ]); + return { + available: true, + repositoryRoot: resolution.repository.repositoryRoot, + selectedScopeId: resolution.scope.id, + worktrees: resolution.repository.worktrees, + changes, + commits, + }; +} + +function parseNameStatus(output: string): GitChange[] { + const fields = output.split('\0').filter(Boolean); + const changes: GitChange[] = []; + for (let i = 0; i < fields.length; ) { + const rawCode = fields[i++]; + const code = rawCode[0] || 'M'; + let oldPath: string | undefined; + let path = fields[i++] || ''; + if (code === 'R' || code === 'C') { + oldPath = path; + path = fields[i++] || ''; + } + const status = parseStatusCode(`${code}.`); + changes.push({ + path, + ...(oldPath ? { oldPath } : {}), + code: shortStatusCode(status), + status, + staged: true, + unstaged: false, + additions: null, + deletions: null, + binary: false, + }); + } + return changes; +} + +function assertCommitHash(commit: string): void { + if (!/^[a-f0-9]{40,64}$/i.test(commit)) { + throw new GitRepositoryScopeError('Invalid commit identifier'); + } +} + +async function ensureCommit(cwd: string, commit: string): Promise { + assertCommitHash(commit); + const result = await runGit(cwd, ['cat-file', '-e', `${commit}^{commit}`], { + allowedExitCodes: [0, 128], + }); + if (result.code !== 0) { + throw new GitRepositoryScopeError('Commit not found'); + } +} + +async function getCommitChanges(cwd: string, commit: string): Promise { + await ensureCommit(cwd, commit); + const result = await runGit(cwd, [ + 'diff-tree', + '--root', + '--no-commit-id', + '--name-status', + '-r', + '-z', + '-M', + commit, + ]); + return parseNameStatus(result.stdout.toString('utf8')); +} + +export async function getGitCommitDetails( + workingDir: string, + requestedScope: string | undefined, + commit: string +): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + if (!resolution) throw new GitRepositoryScopeError('Session is not inside a Git repository'); + await ensureCommit(resolution.scope.path, commit); + const [summaryResult, changes] = await Promise.all([ + runGit(resolution.scope.path, [ + 'show', + '-s', + '--date=iso-strict', + '--pretty=format:%H%x1f%h%x1f%an%x1f%aI%x1f%s', + commit, + ]), + getCommitChanges(resolution.scope.path, commit), + ]); + const summary = parseCommitRecord(summaryResult.stdout.toString('utf8')); + if (!summary) throw new GitRepositoryScopeError('Commit metadata is unavailable'); + return { ...summary, changes }; +} + +function normalizeRepositoryPath(root: string, filePath: string): string { + if (!filePath || filePath.includes('\0') || isAbsolute(filePath)) { + throw new GitRepositoryScopeError('Invalid repository file path'); + } + const normalized = filePath.replace(/\\/g, '/').replace(/^\.\/+/, ''); + const absolute = resolve(root, normalized); + const fromRoot = relative(resolve(root), absolute); + if ( + !fromRoot || + fromRoot === '..' || + fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || + isAbsolute(fromRoot) + ) { + throw new GitRepositoryScopeError('Repository file path is outside the selected worktree'); + } + return normalized; +} + +function bufferLooksBinary(buffer: Buffer): boolean { + const sniffLength = Math.min(buffer.length, 8192); + for (let i = 0; i < sniffLength; i++) { + if (buffer[i] === 0) return true; + } + return false; +} + +async function readWorkingTreeFile(root: string, filePath: string): Promise { + const normalized = normalizeRepositoryPath(root, filePath); + const absolute = resolve(root, normalized); + try { + const [realRoot, realFile] = await Promise.all([fs.realpath(root), fs.realpath(absolute)]); + const fromRoot = relative(realRoot, realFile); + if ( + fromRoot === '..' || + fromRoot.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || + isAbsolute(fromRoot) + ) { + throw new GitRepositoryScopeError('Repository file resolves outside the selected worktree'); + } + const stat = await fs.stat(realFile); + if (!stat.isFile()) return { exists: false, binary: false, truncated: false, content: null }; + if (stat.size > FILE_PREVIEW_LIMIT) { + return { exists: true, binary: false, truncated: true, content: null }; + } + const content = await fs.readFile(realFile); + const binary = bufferLooksBinary(content); + return { + exists: true, + binary, + truncated: false, + content: binary ? null : content.toString('utf8'), + }; + } catch (err) { + if (err instanceof GitRepositoryScopeError) throw err; + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') { + return { exists: false, binary: false, truncated: false, content: null }; + } + throw err; + } +} + +async function readGitBlob(cwd: string, revision: string, filePath: string): Promise { + const object = `${revision}:${filePath}`; + const exists = await runGit(cwd, ['cat-file', '-e', object], { + allowedExitCodes: [0, 128], + }); + if (exists.code !== 0) return { exists: false, binary: false, truncated: false, content: null }; + + const sizeResult = await runGit(cwd, ['cat-file', '-s', object]); + const size = Number.parseInt(sizeResult.stdout.toString('utf8').trim(), 10); + if (Number.isFinite(size) && size > FILE_PREVIEW_LIMIT) { + return { exists: true, binary: false, truncated: true, content: null }; + } + const contentResult = await runGit(cwd, ['show', '--no-textconv', object], { + maxBytes: FILE_PREVIEW_LIMIT + 1, + }); + const binary = bufferLooksBinary(contentResult.stdout); + return { + exists: true, + binary, + truncated: false, + content: binary ? null : contentResult.stdout.toString('utf8'), + }; +} + +function countPatchChanges(patch: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + for (const line of patch.split('\n')) { + if (line.startsWith('+++') || line.startsWith('---')) continue; + if (line.startsWith('+')) additions++; + if (line.startsWith('-')) deletions++; + } + return { additions, deletions }; +} + +function synthesizeUntrackedPatch(filePath: string, content: string): string { + const lines = content.split('\n'); + if (content.endsWith('\n')) lines.pop(); + if (lines.length === 0) { + return [ + `diff --git a/${filePath} b/${filePath}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${filePath}`, + ].join('\n'); + } + const body = lines.map((line) => `+${line}`).join('\n'); + return [ + `diff --git a/${filePath} b/${filePath}`, + 'new file mode 100644', + '--- /dev/null', + `+++ b/${filePath}`, + `@@ -0,0 +1,${lines.length} @@`, + body, + ].join('\n'); +} + +export async function getGitDiffDetail( + workingDir: string, + requestedScope: string | undefined, + filePath: string, + commit?: string +): Promise { + const resolution = await resolveScope(workingDir, requestedScope); + if (!resolution) throw new GitRepositoryScopeError('Session is not inside a Git repository'); + const cwd = resolution.scope.path; + const normalizedPath = normalizeRepositoryPath(cwd, filePath); + + let oldPath: string | undefined; + let before: FileSnapshot; + let after: FileSnapshot; + let patchResult: GitCommandResult; + let label: string; + + if (commit) { + await ensureCommit(cwd, commit); + const changes = await getCommitChanges(cwd, commit); + const change = changes.find((candidate) => candidate.path === normalizedPath); + oldPath = change?.oldPath; + const parentResult = await runGit(cwd, ['rev-parse', '--verify', `${commit}^`], { + allowedExitCodes: [0, 128], + }); + const parent = parentResult.code === 0 ? parentResult.stdout.toString('utf8').trim() : null; + before = parent + ? await readGitBlob(cwd, parent, oldPath || normalizedPath) + : { exists: false, binary: false, truncated: false, content: null }; + after = await readGitBlob(cwd, commit, normalizedPath); + patchResult = await runGit( + cwd, + [ + 'show', + '--format=', + '--no-ext-diff', + '--no-color', + '--find-renames', + '--unified=3', + commit, + '--', + ...(oldPath ? [oldPath] : []), + normalizedPath, + ], + { maxBytes: GIT_DIFF_LIMIT, truncate: true } + ); + label = `${commit.slice(0, 8)} · ${normalizedPath}`; + } else { + const changes = await getChanges(cwd); + const change = changes.find((candidate) => candidate.path === normalizedPath); + oldPath = change?.oldPath; + const repositoryHasHead = await hasHead(cwd); + before = repositoryHasHead + ? await readGitBlob(cwd, 'HEAD', oldPath || normalizedPath) + : { exists: false, binary: false, truncated: false, content: null }; + after = await readWorkingTreeFile(cwd, normalizedPath); + patchResult = repositoryHasHead + ? await runGit( + cwd, + [ + 'diff', + '--no-ext-diff', + '--no-color', + '--find-renames', + '--unified=3', + 'HEAD', + '--', + ...(oldPath ? [oldPath] : []), + normalizedPath, + ], + { maxBytes: GIT_DIFF_LIMIT, truncate: true } + ) + : { stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), code: 0, truncated: false }; + if (patchResult.stdout.length === 0 && !before.exists && after.content !== null) { + patchResult = { + ...patchResult, + stdout: Buffer.from(synthesizeUntrackedPatch(normalizedPath, after.content)), + }; + } + label = `Working tree · ${normalizedPath}`; + } + + const patch = patchResult.stdout.toString('utf8'); + const counts = countPatchChanges(patch); + return { + path: normalizedPath, + ...(oldPath ? { oldPath } : {}), + commit: commit || null, + label, + patch, + beforeContent: before.content, + afterContent: after.content, + beforeExists: before.exists, + afterExists: after.exists, + binary: before.binary || after.binary || /Binary files|GIT binary patch/.test(patch), + truncated: patchResult.truncated || before.truncated || after.truncated, + additions: counts.additions, + deletions: counts.deletions, + }; +} diff --git a/src/hooks-config.ts b/src/hooks-config.ts index ffc8b840..ca8b0a2f 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -16,7 +16,7 @@ * `stop`, `teammate_idle`, `task_completed` * * Hook categories: `Notification` (3 matchers), `Stop` (1), `TeammateIdle` (1), - * `TaskCompleted` (1) + * `TaskCompleted` (1), `PostToolUse` (1 self-contained background Bash rewake) * * @dependencies types (HookEventType), config/auth-config (HOOK_TIMEOUT_MS) * @consumedby web/server (session creation), session-cli-builder (env setup) @@ -40,6 +40,85 @@ import { HOOK_TIMEOUT_MS } from './config/auth-config.js'; * are independent; the map self-prunes when a path's chain goes idle. */ const settingsWriteLocks = new Map>(); +const BACKGROUND_WAKE_MARKER = 'CODEMAN_BACKGROUND_REWAKE_V1'; +const BACKGROUND_WAKE_TIMEOUT_SECONDS = 6 * 60 * 60; + +/** + * Inline Node helper for Claude Code's `asyncRewake` hook. + * + * A background Bash tool returns immediately with a task ID, then Claude writes + * its completion as a queue-operation in the transcript. Watching that durable + * record avoids injecting terminal input (which could submit a user's draft). + * The helper is embedded in settings via `node -e`, so it has no script path + * that can go stale after an install or plugin-cache cleanup. + */ +export function generateBackgroundWakeScript(): string { + return [ + "const fs = require('node:fs');", + `const ${BACKGROUND_WAKE_MARKER} = true;`, + 'let input = {};', + "try { input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); } catch { process.exit(0); }", + 'function findTaskId(value) {', + " const idKeys = new Set(['taskId', 'task_id', 'shellId', 'shell_id', 'backgroundTaskId', 'background_task_id']);", + ' const stack = [value];', + ' const seen = new Set();', + ' while (stack.length > 0) {', + ' const current = stack.pop();', + " if (!current || typeof current !== 'object' || seen.has(current)) continue;", + ' seen.add(current);', + ' for (const [key, nested] of Object.entries(current)) {', + " if (idKeys.has(key) && typeof nested === 'string' && /^[A-Za-z0-9_-]+$/.test(nested)) return nested;", + " if (nested && typeof nested === 'object') stack.push(nested);", + ' }', + ' }', + " const serialized = JSON.stringify(value ?? '');", + ' const messageMatch = serialized.match(/Command running in background with ID:\\s*([A-Za-z0-9_-]+)/i);', + ' if (messageMatch) return messageMatch[1];', + ' const pathMatch = serialized.match(/[\\\\/]tasks[\\\\/]([A-Za-z0-9_-]+)\\.output/i);', + ' return pathMatch ? pathMatch[1] : null;', + '}', + 'const taskId = findTaskId(input.tool_response);', + "const transcriptPath = typeof input.transcript_path === 'string' ? input.transcript_path : '';", + 'if (!taskId || !transcriptPath) process.exit(0);', + 'let position = 0;', + 'try { position = Math.max(0, fs.statSync(transcriptPath).size - 262144); } catch { process.exit(0); }', + "let carry = '';", + 'function inspect(text) {', + ' for (const line of text.split(/\\r?\\n/)) {', + ' if (!line.includes(taskId)) continue;', + ' let entry;', + ' try { entry = JSON.parse(line); } catch { continue; }', + " if (entry.type !== 'queue-operation' || typeof entry.content !== 'string') continue;", + " if (!entry.content.includes('' + taskId + '')) continue;", + ' const status = entry.content.match(/(completed|failed|killed|error)<\\/status>/i);', + ' if (!status) continue;', + ' const output = entry.content.match(/([^<]+)<\\/output-file>/i);', + " const location = output ? ' Read ' + output[1] + ' and' : '';", + " console.error('Background command ' + taskId + ' ' + status[1].toLowerCase() + '.' + location + ' continue the task.');", + ' process.exit(2);', + ' }', + '}', + 'function poll() {', + ' try {', + ' const size = fs.statSync(transcriptPath).size;', + " if (size < position) { position = 0; carry = ''; }", + ' if (size > position) {', + ' const length = Math.min(size - position, 1048576);', + ' const buffer = Buffer.allocUnsafe(length);', + " const fd = fs.openSync(transcriptPath, 'r');", + ' const bytes = fs.readSync(fd, buffer, 0, length, position);', + ' fs.closeSync(fd);', + ' position += bytes;', + " carry = (carry + buffer.subarray(0, bytes).toString('utf8')).slice(-262144);", + ' inspect(carry);', + ' }', + ' } catch {}', + ' setTimeout(poll, 1000);', + '}', + 'poll();', + ].join('\n'); +} + function withSettingsLock(path: string, fn: () => Promise): Promise { const prev = settingsWriteLocks.get(path) ?? Promise.resolve(); const run = prev.then(fn, fn); // run after the prior writer, regardless of its outcome @@ -112,10 +191,91 @@ export function generateHooksConfig(): { hooks: Record } { hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: HOOK_TIMEOUT_MS }], }, ], + PostToolUse: [ + { + matcher: 'Bash', + hooks: [ + { + type: 'command', + command: 'node', + args: ['-e', generateBackgroundWakeScript()], + asyncRewake: true, + timeout: BACKGROUND_WAKE_TIMEOUT_SECONDS, + }, + ], + }, + ], }, }; } +function isCodemanHookHandler(value: unknown): boolean { + try { + const serialized = JSON.stringify(value); + return serialized.includes('/api/hook-event') || serialized.includes(BACKGROUND_WAKE_MARKER); + } catch { + return false; + } +} + +/** + * Replace only Codeman-owned command handlers while preserving user events, + * matcher entries, and sibling handlers in mixed entries. + */ +function mergeCodemanHooks(existingValue: unknown, generated: Record): Record { + const existing = + existingValue && typeof existingValue === 'object' && !Array.isArray(existingValue) + ? (existingValue as Record) + : {}; + const merged: Record = {}; + + for (const eventName of new Set([...Object.keys(existing), ...Object.keys(generated)])) { + const existingEntries = Array.isArray(existing[eventName]) ? (existing[eventName] as unknown[]) : []; + const generatedEntries = generated[eventName]; + if (!generatedEntries) { + merged[eventName] = existingEntries; + continue; + } + + const entries: unknown[] = []; + let insertedGenerated = false; + for (const entry of existingEntries) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + if (!isCodemanHookHandler(entry)) entries.push(entry); + continue; + } + + const record = entry as Record; + if (!Array.isArray(record.hooks)) { + if (isCodemanHookHandler(record)) { + if (!insertedGenerated) { + entries.push(...generatedEntries); + insertedGenerated = true; + } + } else { + entries.push(entry); + } + continue; + } + + const retainedHandlers = record.hooks.filter((handler) => !isCodemanHookHandler(handler)); + const removedCodemanHandler = retainedHandlers.length !== record.hooks.length; + if (removedCodemanHandler && !insertedGenerated) { + entries.push(...generatedEntries); + insertedGenerated = true; + } + if (retainedHandlers.length > 0 || !removedCodemanHandler) { + entries.push(retainedHandlers.length === record.hooks.length ? entry : { ...record, hooks: retainedHandlers }); + } + } + + if (!insertedGenerated) entries.push(...generatedEntries); + merged[eventName] = entries; + } + + return merged; +} + /** * Remove a subset of env keys from .claude/settings.local.json.env if present. * Used during the disk→tmux-setenv migration: when the caller is actively setting @@ -237,29 +397,31 @@ export async function writeHooksConfig(casePath: string): Promise { } const hooksConfig = generateHooksConfig(); - const merged = { ...existing, ...hooksConfig }; + const merged = { + ...existing, + hooks: mergeCodemanHooks(existing.hooks, hooksConfig.hooks), + }; await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n'); }); } /** - * Self-heal a case's hooks block so the COD-91 unconditional hook-secret gate keeps - * accepting its hook events. + * Self-heal a case's Codeman-owned hooks block. * * `writeHooksConfig` only runs when a case is first CREATED. Cases created before the * X-Codeman-Hook-Secret header was added (COD-54, 2026-06-10) keep hook curls in their * settings.local.json that POST to /api/hook-event WITHOUT the secret — which, once the - * gate requires it unconditionally (COD-91), silently 401 on a password-protected - * install. This refreshes the hooks block so those stale curls regain the header. + * gate requires it unconditionally (COD-91), silently 401 on a password-protected install. + * Older Codeman blocks also lack the background Bash async-rewake hook. Refresh either + * stale shape on launch so existing cases gain both current behaviors. * * Deliberately surgical: regenerates ONLY when settings.local.json already contains - * Codeman's own hook curls (they target `/api/hook-event`) that lack the secret header. - * No-op when the file/hooks are absent (we never impose hooks on a user who removed - * them), when the hooks aren't ours, or when the secret is already present — so it never - * clobbers a user's customizations and is cheap enough to call on every Claude spawn. + * Codeman's own hook curls (they target `/api/hook-event`) and they are stale. No-op + * when the file/hooks are absent (we never impose hooks on a user who removed them) or + * when the hooks aren't ours, so it is cheap enough to call on every Claude spawn. */ -export async function refreshStaleHookSecret(casePath: string): Promise { +export async function refreshStaleCodemanHooks(casePath: string): Promise { const settingsPath = join(casePath, '.claude', 'settings.local.json'); if (!existsSync(settingsPath)) return; await withSettingsLock(settingsPath, async () => { @@ -274,8 +436,13 @@ export async function refreshStaleHookSecret(casePath: string): Promise { // The generated curl carries this header literal (see generateHooksConfig); its // absence on our own hooks means they predate COD-54 and need regenerating. const hasSecret = hooksJson.includes('X-Codeman-Hook-Secret'); - if (!isOurs || hasSecret) return; - const merged = { ...existing, ...generateHooksConfig() }; + const hasBackgroundWake = hooksJson.includes(BACKGROUND_WAKE_MARKER); + if (!isOurs || (hasSecret && hasBackgroundWake)) return; + const generated = generateHooksConfig(); + const merged = { + ...existing, + hooks: mergeCodemanHooks(existing.hooks, generated.hooks), + }; await writeFile(settingsPath, JSON.stringify(merged, null, 2) + '\n'); }); } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index d6e58d4e..519a5eca 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -132,6 +132,31 @@ export interface PaneCaptureOptions { maxCaptureBytes?: number; } +/** A stable physical-row slice of a tmux pane's retained history. */ +export interface PaneHistoryPage { + /** ANSI-styled physical rows separated by CRLF, excluding the visible pane. */ + buffer: string; + /** Inclusive absolute row offset from the oldest retained history row. */ + start: number; + /** Exclusive absolute row offset from the oldest retained history row. */ + end: number; + /** Retained history rows at capture time, excluding the visible pane. */ + total: number; + hasMoreBefore: boolean; + hasMoreAfter: boolean; + /** Changes when the pane or oldest retained history changes. */ + origin: string; +} + +export interface PaneHistoryPageOptions { + /** Maximum physical tmux rows to return. */ + limit: number; + /** Fetch the page ending immediately before this absolute history row. */ + before?: number; + /** Fetch the page beginning at this absolute history row. */ + after?: number; +} + /** * Terminal multiplexer interface. * @@ -193,6 +218,9 @@ export interface TerminalMultiplexer extends EventEmitter { /** Update the display name of a session */ updateSessionName(sessionId: string, name: string): boolean; + /** Update the persisted working directory of a session */ + updateSessionWorkingDir(sessionId: string, workingDir: string): boolean; + /** Mark session as attached/detached */ setAttached(sessionId: string, attached: boolean): void; @@ -271,4 +299,7 @@ export interface TerminalMultiplexer extends EventEmitter { * Pass `{ fullHistory: true }` to capture the entire scrollback (COD-47). */ captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null; + + /** Capture one bounded physical-row page from the active pane's retained history. */ + captureActivePaneHistoryPage?(muxName: string, opts: PaneHistoryPageOptions): PaneHistoryPage | null; } diff --git a/src/session.ts b/src/session.ts index 98e05940..701f3eb9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -99,6 +99,19 @@ export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.j export type ResizeViewportType = 'mobile' | 'tablet' | 'desktop'; +/** + * Orders terminal output within one in-memory Session. + * + * Offsets use JavaScript string (UTF-16) units so the browser can slice an + * overlapping SSE batch with the same values without a byte conversion. + */ +export interface TerminalCursor { + stream: string; + generation: number; + start: number; + end: number; +} + /** Line buffer flush interval (100ms) - forces processing of partial lines */ const LINE_BUFFER_FLUSH_INTERVAL = 100; @@ -289,7 +302,7 @@ export interface ClaudeMessage { */ export class Session extends EventEmitter { readonly id: string; - readonly workingDir: string; + workingDir: string; readonly createdAt: number; readonly mode: SessionMode; @@ -310,6 +323,9 @@ export class Session extends EventEmitter { private _respawnBlocked = false; // Use BufferAccumulator for hot-path buffers to reduce GC pressure private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE); + private readonly _terminalStreamId = uuidv4(); + private _terminalGeneration = 0; + private _terminalCursorEnd = 0; private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE); private _errorBuffer: string = ''; private _lastActivityAt: number; @@ -658,6 +674,15 @@ export class Session extends EventEmitter { return this._terminalBuffer.length; } + get terminalCursor(): TerminalCursor { + return { + stream: this._terminalStreamId, + generation: this._terminalGeneration, + start: Math.max(0, this._terminalCursorEnd - this._terminalBuffer.length), + end: this._terminalCursorEnd, + }; + } + get textOutput(): string { return this._textOutput.value; } @@ -689,6 +714,24 @@ export class Session extends EventEmitter { this._owner = username; } + /** + * Synchronize Codeman's workspace metadata with an already-running local + * session. This intentionally does not attempt to change the child process cwd. + */ + setWorkingDir(workingDir: string): void { + if (workingDir !== this.workingDir) { + this.workingDir = workingDir; + this._bashToolParser.setWorkingDir(workingDir); + if (!isExternalCliMode(this.mode)) { + this._ralphTracker.setWorkingDir(workingDir); + } + } + if (this._muxSession) { + this._muxSession.workingDir = workingDir; + } + this._mux?.updateSessionWorkingDir(this.id, workingDir); + } + // Adopt a Claude conversation ID observed from an external source (e.g. hook // payload). In interactive PTY mode Claude CLI emits no JSON to stdout, so // `_handleJsonMessage` never sees `session_id`; hooks are the only signal @@ -1408,10 +1451,10 @@ export class Session extends EventEmitter { }); } - // BufferAccumulator handles auto-trimming when max size exceeded - this._terminalBuffer.append(data); + // BufferAccumulator handles auto-trimming when max size exceeded. + const cursor = this._appendTerminalBuffer(data); this._lastActivityAt = Date.now(); - this.emit('terminal', data); + this.emit('terminal', data, cursor); this.emit('output', data); } @@ -1546,7 +1589,7 @@ export class Session extends EventEmitter { // Clean the buffer - remove mux init junk before actual content // Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH), // clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace - this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')); + this._replaceTerminalBuffer(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')); // Signal client to refresh this.emit('clearTerminal'); } @@ -1901,7 +1944,7 @@ export class Session extends EventEmitter { if (!isRestored) { setTimeout(() => { if (this.ptyProcess) { - this._terminalBuffer.clear(); + this._clearTerminalBuffer(); this.ptyProcess.write('clear\n'); } }, 100); @@ -2115,7 +2158,7 @@ export class Session extends EventEmitter { private _resetBuffers(): void { this._status = 'busy'; - this._terminalBuffer.clear(); + this._clearTerminalBuffer(); this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; @@ -2592,12 +2635,10 @@ export class Session extends EventEmitter { /** * Live WebSocket connections that have announced a desktop viewport for this - * session. While at least one is registered, small-viewport (mobile/tablet) - * resizes are ignored so a phone glancing at the session can't reflow the - * PTY under an active desktop view. Claims are connection-scoped: ws-routes - * registers them on a desktop-typed resize and releases them on socket - * close, so a mobile-only session (no desktop connected) keeps full control - * of its own size — including narrowing below the spawn default. + * session. Presence and activity are deliberately separate: reconnecting a + * background desktop may register here, but only explicit interaction/input + * refreshes _lastDesktopActivityAt. This prevents an automatic page restore + * from taking sizing control away from an active phone. * * Deliberate tradeoff: claims are WS-only because only a socket has a * liveness signal. A desktop degraded to the stateless HTTP resize fallback @@ -2609,7 +2650,7 @@ export class Session extends EventEmitter { /** * A desktop sizing claim only blocks small-viewport resizes while the - * desktop is RECENTLY ACTIVE (claim registration or typed input within this + * desktop is RECENTLY ACTIVE (explicit sizing takeover or typed input within this * window). An abandoned-but-connected desktop tab (left open at home, screen * locked) must not hold a phone's view hostage: without this, the phone * renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux @@ -2617,19 +2658,15 @@ export class Session extends EventEmitter { */ private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000; - /** Last evidence of a live desktop user (claim registered / typed input). */ + /** Last evidence of explicit desktop interaction or typed input. */ private _lastDesktopActivityAt = 0; - /** Last desktop-typed dimensions, for re-asserting after a mobile override. */ - private _lastDesktopDims: { cols: number; rows: number } | null = null; - /** True while a small viewport reflowed the pane past an idle desktop claim. */ private _mobileSizeOverride = false; - /** Register a live desktop sizing claim (see _desktopSizeClaims). */ + /** Register live desktop presence without treating a reconnect as user activity. */ claimDesktopSizing(token: symbol): void { this._desktopSizeClaims.add(token); - this._lastDesktopActivityAt = Date.now(); } /** Release a desktop sizing claim when its connection goes away. */ @@ -2637,50 +2674,48 @@ export class Session extends EventEmitter { this._desktopSizeClaims.delete(token); } - /** - * Record desktop user activity (typed input over a claim-holding socket). - * If a phone reflowed the pane while the desktop was idle, the desktop - * layout is restored — "whoever is actively using the session wins". - */ - noteDesktopActivity(): void { - this._lastDesktopActivityAt = Date.now(); - if (this._mobileSizeOverride && this._lastDesktopDims) { - this._mobileSizeOverride = false; - this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' }); - } - } - /** * Resizes the PTY terminal dimensions. * Skips the resize if dimensions haven't changed to avoid triggering * unnecessary Ink full-screen redraws (visible flicker on tab switch). * * Arbitration: while a desktop connection holds a sizing claim AND has been - * active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports + * active within DESKTOP_CLAIM_IDLE_MS, passive resizes from small viewports * (mobile/tablet) are ignored — shrink AND grow would both reflow the - * desktop view. Once the desktop goes idle, a phone may take the pane (the - * desktop re-asserts its size on its next typed input via - * noteDesktopActivity). Without a desktop connected, small viewports - * control the PTY size freely. + * desktop view. A trusted interaction can explicitly take control. Once the + * desktop goes idle, a phone may also take the pane passively (the desktop + * re-asserts its size on its next interaction). Without a desktop connected, + * small viewports control the PTY size freely. * * @param cols - Number of columns (width in characters) * @param rows - Number of rows (height in lines) */ - resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType; force?: boolean } = {}): void { + resize( + cols: number, + rows: number, + options: { viewportType?: ResizeViewportType; force?: boolean; takeControl?: boolean } = {} + ): void { + const isDesktopViewport = options.viewportType === 'desktop'; const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet'; - if (options.viewportType === 'desktop') { - this._lastDesktopDims = { cols, rows }; - this._lastDesktopActivityAt = Date.now(); + const restoresMobileOverride = isDesktopViewport && this._mobileSizeOverride && options.takeControl === true; + if (isDesktopViewport) { + // Passive desktop restores must not displace a phone that explicitly + // took control of the shared PTY. + if (this._mobileSizeOverride && !options.takeControl) return; + if (options.takeControl) this._lastDesktopActivityAt = Date.now(); this._mobileSizeOverride = false; } + if (isSmallViewport && options.takeControl) { + this._mobileSizeOverride = true; + } if (isSmallViewport && this._desktopSizeClaims.size > 0) { - if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) { + if (!options.takeControl && Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) { return; } this._mobileSizeOverride = true; } const dimsChanged = cols !== this._ptyCols || rows !== this._ptyRows; - if (this.ptyProcess && (dimsChanged || options.force)) { + if (this.ptyProcess && (dimsChanged || options.force || restoresMobileOverride)) { this._ptyCols = cols; this._ptyRows = rows; if (this._mux && this._muxSession) { @@ -2863,7 +2898,7 @@ export class Session extends EventEmitter { assignTask(taskId: string): void { this._currentTaskId = taskId; this._status = 'busy'; - this._terminalBuffer.clear(); + this._clearTerminalBuffer(); this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; @@ -2889,7 +2924,7 @@ export class Session extends EventEmitter { } clearBuffers(): void { - this._terminalBuffer.clear(); + this._clearTerminalBuffer(); this._textOutput.clear(); this._errorBuffer = ''; this._messages = []; @@ -2897,4 +2932,28 @@ export class Session extends EventEmitter { this._ralphTracker.clear(); this._taskCache.clear(); } + + private _appendTerminalBuffer(data: string): TerminalCursor { + const start = this._terminalCursorEnd; + this._terminalBuffer.append(data); + this._terminalCursorEnd += data.length; + return { + stream: this._terminalStreamId, + generation: this._terminalGeneration, + start, + end: this._terminalCursorEnd, + }; + } + + private _replaceTerminalBuffer(data: string): void { + this._terminalBuffer.set(data); + this._terminalGeneration++; + this._terminalCursorEnd = data.length; + } + + private _clearTerminalBuffer(): void { + this._terminalBuffer.clear(); + this._terminalGeneration++; + this._terminalCursorEnd = 0; + } } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index ca29478e..80b1734f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -23,6 +23,7 @@ import { EventEmitter } from 'node:events'; import { execSync, exec } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { promisify } from 'node:util'; const execAsync = promisify(exec); @@ -77,6 +78,8 @@ import type { CreateSessionOptions, RespawnPaneOptions, PaneCaptureOptions, + PaneHistoryPage, + PaneHistoryPageOptions, } from './mux-interface.js'; import { decideReconnect, @@ -100,6 +103,8 @@ import { DEFAULT_TMUX_HISTORY_LIMIT, DEFAULT_TERMINAL_BUFFER_MAX_BYTES } from '. * capture must be allowed to exceed the final payload size. */ const FULL_HISTORY_CAPTURE_SLACK_BYTES = 8 * 1024 * 1024; +const HISTORY_PAGE_MAX_BUFFER_BYTES = 4 * 1024 * 1024; +const HISTORY_ORIGIN_SAMPLE_ROWS = 3; /** Delay after tmux session creation — enough for detached tmux to be queryable */ const TMUX_CREATION_WAIT_MS = 100; @@ -187,8 +192,100 @@ const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/; */ const PANE_LIST_SEP = '|'; -/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */ -const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`; +/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneDiscoveryList}. */ +const PANE_LIST_FORMAT = ['#{session_name}', '#{pane_pid}', '#{@codeman-mode}', '#{pane_current_path}'].join( + PANE_LIST_SEP +); + +const SESSION_MODES = new Set(['claude', 'shell', 'opencode', 'codex', 'gemini']); + +export interface PaneDiscovery { + pid: number; + workingDir: string | undefined; + mode?: SessionMode; +} + +/** + * Parse live pane metadata. Current output includes a durable session-mode + * option before the path. Legacy three-field output remains supported so panes + * created before the option was introduced can be migrated in place. + */ +export function parsePaneDiscoveryList(output: string): Map { + const result = new Map(); + for (const line of output.split('\n')) { + if (!line) continue; + const firstSep = line.indexOf(PANE_LIST_SEP); + if (firstSep === -1) continue; + const secondSep = line.indexOf(PANE_LIST_SEP, firstSep + 1); + const thirdSep = secondSep === -1 ? -1 : line.indexOf(PANE_LIST_SEP, secondSep + 1); + const name = line.slice(0, firstSep); + const pidText = secondSep === -1 ? line.slice(firstSep + 1) : line.slice(firstSep + 1, secondSep); + const pid = parseInt(pidText, 10); + const modeText = thirdSep === -1 ? undefined : line.slice(secondSep + 1, thirdSep); + const mode = modeText && SESSION_MODES.has(modeText as SessionMode) ? (modeText as SessionMode) : undefined; + const workingDir = + secondSep === -1 + ? undefined + : thirdSep === -1 + ? line.slice(secondSep + 1) || undefined + : line.slice(thirdSep + 1) || undefined; + if (name && !Number.isNaN(pid)) { + result.set(name, { pid, workingDir, ...(mode ? { mode } : {}) }); + } + } + return result; +} + +/** + * Infer the provider from a legacy pane's live process command. This is only a + * migration fallback for sessions created before `@codeman-mode`; new panes + * restore from explicit tmux metadata instead. + */ +export function inferSessionModeFromProcessCommand(command: string): SessionMode | undefined { + const tokens = String(command) + .replace(/\0/g, ' ') + .trim() + .split(/\s+/) + .map( + (token) => + token + .replace(/^["']|["']$/g, '') + .split('/') + .pop() + ?.toLowerCase() || '' + ); + + if (tokens.includes('codex')) return 'codex'; + if (tokens.includes('opencode')) return 'opencode'; + if (tokens.includes('gemini')) return 'gemini'; + if (tokens.includes('claude')) return 'claude'; + + const executable = tokens[0]; + if (['sh', 'bash', 'zsh', 'fish', 'pwsh', 'pwsh.exe', 'powershell.exe'].includes(executable)) { + return 'shell'; + } + return undefined; +} + +function inferSessionModeFromPaneProcess(pid: number): SessionMode | undefined { + try { + const command = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + const inferred = inferSessionModeFromProcessCommand(command); + if (inferred) return inferred; + } catch { + // procfs is Linux-only; macOS and other hosts use ps below. + } + + try { + const command = execSync(`ps -p ${pid} -o command=`, { + encoding: 'utf8', + timeout: EXEC_TIMEOUT_MS, + }); + return inferSessionModeFromProcessCommand(command); + } catch { + return undefined; + } +} /** * 构建 pane 启动前的 nofile 修复命令。 @@ -212,15 +309,8 @@ export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT): */ export function parsePaneList(output: string): Map { const result = new Map(); - for (const line of output.split('\n')) { - if (!line) continue; - const sep = line.indexOf(PANE_LIST_SEP); - if (sep === -1) continue; - const name = line.slice(0, sep); - const pid = parseInt(line.slice(sep + 1), 10); - if (name && !Number.isNaN(pid)) { - result.set(name, pid); - } + for (const [name, pane] of parsePaneDiscoveryList(output)) { + result.set(name, pane.pid); } return result; } @@ -501,6 +591,32 @@ export function normalizeScrollbackEol(buffer: string): string { return buffer.replace(/\r?\n/g, '\r\n'); } +export function resolveHistoryPageRange( + historySize: number, + options: PaneHistoryPageOptions +): Omit { + const total = Math.max(0, Math.trunc(Number.isFinite(historySize) ? historySize : 0)); + const limit = Math.max(1, Math.trunc(Number.isFinite(options.limit) ? options.limit : 1)); + + let start: number; + let end: number; + if (Number.isFinite(options.after)) { + start = Math.max(0, Math.min(total, Math.trunc(options.after as number))); + end = Math.min(total, start + limit); + } else { + end = Number.isFinite(options.before) ? Math.max(0, Math.min(total, Math.trunc(options.before as number))) : total; + start = Math.max(0, end - limit); + } + + return { + start, + end, + total, + hasMoreBefore: start > 0, + hasMoreAfter: end < total, + }; +} + export function formatPaneSnapshot( lines: string[], geometry: { cols: number; rows: number; cursorX: number; cursorY: number } @@ -553,6 +669,13 @@ function isValidPath(path: string): boolean { return SAFE_PATH_PATTERN.test(path); } +function resolveDiscoveredWorkingDir(workingDir: string | undefined): string { + if (workingDir?.startsWith('/') && isValidPath(workingDir)) { + return workingDir; + } + return process.cwd(); +} + // =========================================================================== // Single-socket architecture: ALL Codeman sessions live on one dedicated tmux // socket (`tmux -L codeman`), isolated from the user's default tmux server. @@ -640,6 +763,10 @@ export function buildCodexCommand(config?: CodexConfig): string { parts.push('--dangerously-bypass-approvals-and-sandbox'); } + if (config?.animations !== undefined) { + parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`); + } + if (config?.model) { const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined; if (safeModel) parts.push('--model', safeModel); @@ -1396,6 +1523,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return tmuxCommand(this.tmuxSocket); } + private persistSessionMode(muxName: string, mode: SessionMode): boolean { + if (!isValidMuxName(muxName)) return false; + try { + execSync(`${this.tmux()} set-option -t "${muxName}" @codeman-mode "${mode}"`, { + timeout: EXEC_TIMEOUT_MS, + stdio: 'ignore', + }); + return true; + } catch { + return false; + } + } + // Load saved sessions from disk (NEVER called in test mode) private loadSessions(): void { if (IS_TEST_MODE) return; @@ -1725,6 +1865,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } catch { /* Non-critical */ } + this.persistSessionMode(muxName, mode); // For OpenCode: set sensitive env vars and config via tmux setenv // (not visible in ps output or tmux history, inherited by panes) @@ -1922,6 +2063,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }).catch(() => { /* Non-critical — keeps existing tmux history-limit */ }); + this.persistSessionMode(muxName, mode); } // Resolve CLI binary directory based on mode @@ -2247,6 +2389,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return true; } + updateSessionWorkingDir(sessionId: string, workingDir: string): boolean { + const session = this.sessions.get(sessionId); + if (!session) { + return false; + } + session.workingDir = workingDir; + this.saveSessions(); + return true; + } + /** * Reconcile tracked sessions with actual running tmux sessions. */ @@ -2263,18 +2415,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const alive: string[] = []; const dead: string[] = []; const discovered: string[] = []; + let metadataChanged = false; // Single batched query against the one socket Codeman owns. With a single // socket a session's location is a constant, so there is no per-session // socket tag to reconcile and no cross-socket ambiguity that could mark a // live session dead (the root cause of vanished/duplicate tabs). - let active: Map; + let active: Map; try { const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); - active = parsePaneList(output); + active = parsePaneDiscoveryList(output); } catch (err) { console.error('[TmuxManager] Failed to list tmux panes:', err); active = new Map(); @@ -2282,10 +2435,31 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Check tracked sessions against the live pane list. for (const [sessionId, session] of this.sessions) { - const pid = active.get(session.muxName); - if (pid !== undefined) { + const pane = active.get(session.muxName); + if (pane !== undefined) { alive.push(sessionId); - if (pid !== session.pid) session.pid = pid; + if (pane.pid !== session.pid) session.pid = pane.pid; + const recoveredMode = + pane.mode ?? + (sessionId.startsWith('restored-') ? inferSessionModeFromPaneProcess(pane.pid) : undefined) ?? + session.mode; + if (recoveredMode && recoveredMode !== session.mode) { + session.mode = recoveredMode; + metadataChanged = true; + } + if (recoveredMode && !pane.mode) { + this.persistSessionMode(session.muxName, recoveredMode); + } + const recoveredWorkingDir = resolveDiscoveredWorkingDir(pane.workingDir); + if ( + !session.remote && + !session.docker && + session.workingDir === process.cwd() && + recoveredWorkingDir !== session.workingDir + ) { + session.workingDir = recoveredWorkingDir; + metadataChanged = true; + } } else { dead.push(sessionId); this.sessions.delete(sessionId); @@ -2302,7 +2476,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { knownMuxNames.add(session.muxName); } - for (const [sessionName, pid] of active) { + for (const [sessionName, pane] of active) { if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) continue; // Only admit names that pass the safe-name pattern. A foreign process on the // shared `tmux -L codeman` socket could create a `codeman-…` session whose name @@ -2316,23 +2490,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, ''); const sessionId = `restored-${fragment}`; + const recoveredMode = pane.mode ?? inferSessionModeFromPaneProcess(pane.pid) ?? 'claude'; const session: MuxSession = { sessionId, muxName: sessionName, - pid, + pid: pane.pid, createdAt: Date.now(), - workingDir: process.cwd(), - mode: 'claude', + workingDir: resolveDiscoveredWorkingDir(pane.workingDir), + mode: recoveredMode, attached: false, name: `Restored: ${sessionName}`, }; this.sessions.set(sessionId, session); + if (!pane.mode) this.persistSessionMode(sessionName, recoveredMode); knownMuxNames.add(sessionName); discovered.push(sessionId); - console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); + console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pane.pid})`); } - if (dead.length > 0 || discovered.length > 0) { + if (dead.length > 0 || discovered.length > 0 || metadataChanged) { this.saveSessions(); } @@ -3079,6 +3255,102 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * Capture a bounded physical-row slice of tmux history. + * + * Page coordinates are absolute offsets from the oldest retained history + * row. Unlike full-history restoration this deliberately omits `-J`: one + * captured tmux row must remain one page coordinate so adjacent pages can be + * composed without byte or soft-wrap ambiguity. + */ + capturePaneHistoryPage( + muxName: string, + paneTarget: string | undefined, + opts: PaneHistoryPageOptions + ): PaneHistoryPage | null { + if (IS_TEST_MODE) return null; + const target = resolveTmuxPaneTarget(muxName, paneTarget); + if (!target) { + console.error('[TmuxManager] Invalid pane target in capturePaneHistoryPage:', { + muxName, + paneTarget, + }); + return null; + } + + try { + const historyText = execSync(`${this.tmux()} display-message -p -t ${shellescape(target)} '#{history_size}'`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + const historySize = parseInt(historyText, 10); + if (!Number.isSafeInteger(historySize) || historySize < 0) return null; + + const range = resolveHistoryPageRange(historySize, opts); + let buffer = ''; + if (range.end > range.start) { + const startLine = range.start - historySize; + const endLine = range.end - historySize - 1; + const captured = execSync( + `${this.tmux()} capture-pane -p -e -S ${startLine} -E ${endLine} -t ${shellescape(target)}`, + { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + maxBuffer: HISTORY_PAGE_MAX_BUFFER_BYTES, + } + ); + // tmux terminates stdout with one newline in addition to the captured + // row separators. Remove exactly that terminator so trailing blank rows + // still count toward the page's physical-row coordinates. + buffer = normalizeScrollbackEol(captured.endsWith('\n') ? captured.slice(0, -1) : captured); + } + + let originSample = ''; + if (historySize > 0) { + const sampleEndLine = -Math.max(1, historySize - HISTORY_ORIGIN_SAMPLE_ROWS + 1); + const captured = execSync( + `${this.tmux()} capture-pane -p -e -S -${historySize} -E ${sampleEndLine} -t ${shellescape(target)}`, + { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + maxBuffer: 64 * 1024, + } + ); + originSample = captured; + } + const origin = createHash('sha256').update(target).update('\0').update(originSample).digest('hex').slice(0, 24); + + return { buffer, origin, ...range }; + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code === 'ENOBUFS') { + console.error('[TmuxManager] History page exceeded its bounded capture buffer'); + } else { + console.error('[TmuxManager] Failed to capture pane history page:', err); + } + return null; + } + } + + captureActivePaneHistoryPage(muxName: string, opts: PaneHistoryPageOptions): PaneHistoryPage | null { + if (IS_TEST_MODE) return null; + if (!isValidMuxName(muxName)) { + console.error('[TmuxManager] Invalid session name in captureActivePaneHistoryPage:', muxName); + return null; + } + + try { + const output = execSync(`${this.tmux()} list-panes -t ${shellescape(muxName)} -F '#{pane_id}:#{pane_active}'`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + const target = resolveActivePaneTarget(output); + return target ? this.capturePaneHistoryPage(muxName, target, opts) : null; + } catch (err) { + console.error('[TmuxManager] Failed to resolve active pane for history capture:', err); + return null; + } + } + /** * Start piping pane output to a file using tmux pipe-pane. * Only pipes output direction (-O) to avoid echoing input. @@ -3199,6 +3471,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * Check if tmux is available on the system. */ static isTmuxAvailable(): boolean { + // Tests construct the in-memory no-op manager and must not depend on the + // host executable or on child-process permission inside the test runner. + if (IS_TEST_MODE) return true; try { execSync('which tmux', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); return true; diff --git a/src/transcript-watcher.ts b/src/transcript-watcher.ts index 8eb01644..8d44865c 100644 --- a/src/transcript-watcher.ts +++ b/src/transcript-watcher.ts @@ -40,6 +40,7 @@ interface TranscriptContentBlock { text?: string; name?: string; input?: Record; + tool_use_id?: string; content?: string; is_error?: boolean; } @@ -328,10 +329,7 @@ export class TranscriptWatcher extends EventEmitter { this.handleResultEntry(entry); break; case 'user': - // User message means new turn, reset some state - this.state.isComplete = false; - this.state.hasError = false; - this.state.errorMessage = null; + this.handleUserEntry(entry); break; case 'system': // System messages are informational @@ -360,23 +358,42 @@ export class TranscriptWatcher extends EventEmitter { this.state.currentTool = block.name; this.emit('transcript:tool_start', block.name); } else if (block.type === 'tool_result') { - // Tool completed - const wasError = block.is_error === true; - const toolName = this.state.currentTool; - this.state.toolExecuting = false; - this.state.currentTool = null; - if (toolName) { - this.emit('transcript:tool_end', toolName, wasError); - } - if (wasError && block.content) { - this.state.hasError = true; - this.state.errorMessage = String(block.content).slice(0, 200); - } + this.handleToolResult(block); } } } } + private handleUserEntry(entry: TranscriptEntry): void { + // A user-authored prompt starts a turn, while Claude tool results also use + // user entries. Reset turn state first, then close any completed tool. + this.state.isComplete = false; + this.state.hasError = false; + this.state.errorMessage = null; + + const content = entry.message?.content; + if (!Array.isArray(content)) return; + for (const block of content) { + if (block.type === 'tool_result') { + this.handleToolResult(block); + } + } + } + + private handleToolResult(block: TranscriptContentBlock): void { + const wasError = block.is_error === true; + const toolName = this.state.currentTool; + this.state.toolExecuting = false; + this.state.currentTool = null; + if (toolName) { + this.emit('transcript:tool_end', toolName, wasError); + } + if (wasError && block.content) { + this.state.hasError = true; + this.state.errorMessage = String(block.content).slice(0, 200); + } + } + private handleResultEntry(entry: TranscriptEntry): void { // Result entry indicates completion this.state.isComplete = true; diff --git a/src/types/session.ts b/src/types/session.ts index 8305759c..9fca7001 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -298,6 +298,8 @@ export interface CodexConfig { resumeSessionId?: string; /** Bypass approval prompts (passes --dangerously-bypass-approvals-and-sandbox) */ dangerouslyBypassApprovals?: boolean; + /** Enable Codex's decorative TUI animations. Disable to reduce remote terminal redraws. */ + animations?: boolean; /** Browser rendering strategy for Codex sessions. Hybrid TUI is the only supported mode. */ renderMode?: CodexRenderMode; } diff --git a/src/web/instance-shutdown.ts b/src/web/instance-shutdown.ts new file mode 100644 index 00000000..dc1c7155 --- /dev/null +++ b/src/web/instance-shutdown.ts @@ -0,0 +1,190 @@ +/** + * @fileoverview Detects and stops the supervisor that owns the running Codeman process. + * + * A supervised Codeman service uses an automatic restart policy. Exiting the Node + * process directly would therefore restart it, so shutdown must target the exact + * systemd/launchd unit that owns this PID. Unsupervised dev/manual processes use + * WebServer.stop() instead. + */ + +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +const LAUNCHD_LABEL = 'com.codeman.web'; +const CODEMAN_SYSTEMD_UNIT_RE = /^codeman(?:[-.@][A-Za-z0-9_.@-]+)?\.service$/; + +export type ResolvedShutdownStrategy = + | { kind: 'manual'; apiName: 'manual' } + | { + kind: 'systemd'; + apiName: 'systemd-user' | 'systemd-system'; + scope: 'user' | 'system'; + unit: string; + } + | { + kind: 'launchd'; + apiName: 'launchd-user' | 'launchd-system'; + target: string; + } + | { kind: 'unsupported'; reason: string }; + +interface ShutdownProbe { + platform: NodeJS.Platform; + pid: number; + uid: number | undefined; + cgroupText?: string | null; + systemdMainPid?: (scope: 'user' | 'system', unit: string) => number | null; + launchdPrint?: (target: string) => string | null; +} + +export function parseCodemanSystemdMembership(cgroupText: string): { unit: string; scope: 'user' | 'system' } | null { + for (const line of cgroupText.split(/\r?\n/)) { + const path = line.slice(line.lastIndexOf(':') + 1); + const segments = path.split('/').filter(Boolean); + let unit: string | undefined; + for (let index = segments.length - 1; index >= 0; index--) { + if (CODEMAN_SYSTEMD_UNIT_RE.test(segments[index])) { + unit = segments[index]; + break; + } + } + if (unit === undefined) continue; + const scope = path.includes('/user.slice/') || path.includes('/user@') ? 'user' : 'system'; + return { unit, scope }; + } + return null; +} + +export function parseLaunchdPid(output: string | null): number | null { + const match = output?.match(/^\s*pid\s*=\s*(\d+)\s*$/m); + if (!match) return null; + const pid = Number(match[1]); + return Number.isSafeInteger(pid) && pid > 0 ? pid : null; +} + +export function resolveInstanceShutdownStrategy(probe: ShutdownProbe): ResolvedShutdownStrategy { + if (probe.platform === 'linux') { + const membership = probe.cgroupText ? parseCodemanSystemdMembership(probe.cgroupText) : null; + if (!membership) return { kind: 'manual', apiName: 'manual' }; + // A preview launched from a service-owned shell can share its cgroup. Stop + // the unit only when this process is the unit's actual MainPID. + if (probe.systemdMainPid?.(membership.scope, membership.unit) !== probe.pid) { + return { kind: 'manual', apiName: 'manual' }; + } + if (membership.scope === 'system' && probe.uid !== 0) { + return { + kind: 'unsupported', + reason: `Codeman is managed by system service ${membership.unit}; stopping it requires administrator access`, + }; + } + return { + kind: 'systemd', + apiName: membership.scope === 'user' ? 'systemd-user' : 'systemd-system', + scope: membership.scope, + unit: membership.unit, + }; + } + + if (probe.platform === 'darwin') { + const printTarget = probe.launchdPrint; + if (!printTarget) return { kind: 'manual', apiName: 'manual' }; + + if (probe.uid !== undefined) { + const userTarget = `gui/${probe.uid}/${LAUNCHD_LABEL}`; + if (parseLaunchdPid(printTarget(userTarget)) === probe.pid) { + return { kind: 'launchd', apiName: 'launchd-user', target: userTarget }; + } + } + + const systemTarget = `system/${LAUNCHD_LABEL}`; + if (parseLaunchdPid(printTarget(systemTarget)) === probe.pid) { + if (probe.uid !== 0) { + return { + kind: 'unsupported', + reason: 'Codeman is managed by a system LaunchDaemon; stopping it requires administrator access', + }; + } + return { kind: 'launchd', apiName: 'launchd-system', target: systemTarget }; + } + } + + return { kind: 'manual', apiName: 'manual' }; +} + +function tryLaunchdPrint(target: string): string | null { + try { + return execFileSync('launchctl', ['print', target], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + }); + } catch { + return null; + } +} + +function trySystemdMainPid(scope: 'user' | 'system', unit: string): number | null { + try { + const args = + scope === 'user' + ? ['--user', 'show', unit, '--property', 'MainPID', '--value'] + : ['show', unit, '--property', 'MainPID', '--value']; + const output = execFileSync('systemctl', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + timeout: 2000, + }).trim(); + const pid = Number(output); + return /^\d+$/.test(output) && Number.isSafeInteger(pid) && pid > 0 ? pid : null; + } catch { + return null; + } +} + +export function detectInstanceShutdownStrategy(): ResolvedShutdownStrategy { + let cgroupText: string | null = null; + if (process.platform === 'linux') { + try { + cgroupText = readFileSync('/proc/self/cgroup', 'utf8'); + } catch { + // Non-systemd Linux and restricted containers are manual processes. + } + } + + return resolveInstanceShutdownStrategy({ + platform: process.platform, + pid: process.pid, + uid: process.getuid?.(), + cgroupText, + systemdMainPid: process.platform === 'linux' ? trySystemdMainPid : undefined, + launchdPrint: process.platform === 'darwin' ? tryLaunchdPrint : undefined, + }); +} + +export function startSupervisorShutdown( + strategy: Exclude +): ChildProcess { + const { command, args } = buildSupervisorShutdownCommand(strategy); + const child = spawn(command, args, { detached: true, stdio: 'ignore' }); + child.unref(); + return child; +} + +export function buildSupervisorShutdownCommand( + strategy: Exclude +): { command: 'systemctl' | 'launchctl'; args: string[] } { + let command: 'systemctl' | 'launchctl'; + let args: string[]; + + if (strategy.kind === 'systemd') { + command = 'systemctl'; + args = + strategy.scope === 'user' + ? ['--user', '--no-block', 'stop', strategy.unit] + : ['--no-block', 'stop', strategy.unit]; + } else { + command = 'launchctl'; + args = ['bootout', strategy.target]; + } + + return { command, args }; +} diff --git a/src/web/ports/event-port.ts b/src/web/ports/event-port.ts index 1298dfb7..a3d937cb 100644 --- a/src/web/ports/event-port.ts +++ b/src/web/ports/event-port.ts @@ -3,12 +3,12 @@ * Route modules that need to notify the frontend depend on this port. */ -import type { BackgroundTask } from '../../session.js'; +import type { BackgroundTask, TerminalCursor } from '../../session.js'; export interface EventPort { broadcast(event: string, data: unknown): void; sendPushNotifications(event: string, data: Record): void; - batchTerminalData(sessionId: string, data: string): void; + batchTerminalData(sessionId: string, data: string, cursor?: TerminalCursor): void; broadcastSessionStateDebounced(sessionId: string): void; batchTaskUpdate(sessionId: string, task: BackgroundTask): void; getSseClientCount(): number; diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index 18cbe603..779a9102 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -14,3 +14,4 @@ export type { InfraPort, ScheduledRun } from './infra-port.js'; export type { AuthPort } from './auth-port.js'; export type { OrchestratorPort } from './orchestrator-port.js'; export type { CronPort } from './cron-port.js'; +export type { InstanceControlPort, InstanceShutdownResult, InstanceShutdownStrategy } from './instance-control-port.js'; diff --git a/src/web/ports/instance-control-port.ts b/src/web/ports/instance-control-port.ts new file mode 100644 index 00000000..8d2fb226 --- /dev/null +++ b/src/web/ports/instance-control-port.ts @@ -0,0 +1,20 @@ +/** + * @fileoverview Process-level Codeman instance controls exposed to system routes. + */ + +export type InstanceShutdownStrategy = 'manual' | 'systemd-user' | 'systemd-system' | 'launchd-user' | 'launchd-system'; + +export type InstanceShutdownResult = + | { + accepted: true; + strategy: InstanceShutdownStrategy; + alreadyScheduled: boolean; + } + | { + accepted: false; + reason: string; + }; + +export interface InstanceControlPort { + requestInstanceShutdown(): Promise; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..7c5d19fb 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -41,10 +41,12 @@ * @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler) * @dependency voice-input.js (VoiceInput, DeepgramProvider) * @dependency notification-manager.js (NotificationManager class) - * @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap) + * @dependency keyboard-accessory.js (MobileTerminalControls, FocusTrap) + * @dependency terminal-input-state.js (TerminalInputStateStore) + * @dependency terminal-input-controller.js (TerminalInputController) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay) - * @loadorder 6 of 15 — loaded after keyboard-accessory.js, before terminal-ui.js + * @loadorder 6 of 16 — loaded after terminal-input-controller.js, before terminal-ui.js */ // Codeman App - Tab-based Terminal UI @@ -465,10 +467,11 @@ class CodemanApp { this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID) ? crypto.randomUUID() : 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36); - // Per-TAB nonce for the WS registry key (COD-137). _loadReliableState() + // Per-tab nonce for the SSE/WS handoff and WS registry (COD-137). + // _loadReliableState() // later replaces _clientId with the browser-wide localStorage identity - // (shared by every tab/window of this profile), so the WS upgrade sends - // `clientId:nonce` instead — a same-tab reconnect still supersedes its own + // (shared by every tab/window of this profile), so terminal transports append + // this original page-local value — a same-tab reconnect still supersedes its own // socket, but two tabs on one session coexist instead of evicting each // other in a 4010 ping-pong. Input frames keep the bare clientId for seq // dedup. @@ -494,8 +497,16 @@ class CodemanApp { this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive + this._serverStartedAt = null; // process epoch from init/status snapshots + this._serverReloadRequested = false; + this._serverRestartRecovery = false; + try { + this._serverRestartRecovery = + sessionStorage.getItem(SERVER_RESTART_RECOVERY_KEY) === '1'; + } catch { + /* sessionStorage can be unavailable in hardened browser contexts */ + } this._selectGeneration = 0; // cancel stale selectSession loads - this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47) this.terminalLoadStates = new Map(); // Map this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers @@ -505,6 +516,9 @@ class CodemanApp { this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation + this._instanceShutdownState = 'idle'; // idle | confirming | submitting | accepted + this._instanceShutdownFocusTrap = null; + this._instanceShutdownRequested = false; this.muxSessions = []; // Screen sessions for process monitor // Ralph loop/todo state per session @@ -547,6 +561,8 @@ class CodemanApp { this.teammateTerminals = new Map(); // Map this.terminalBufferCache = new Map(); // Map — client-side cache for instant tab re-visits (max 20) + this._warmTerminalCache = new WarmTerminalCache(); + this._warmTerminalExpiryTimer = null; this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel @@ -588,7 +604,19 @@ class CodemanApp { this.fileBrowserFilter = ''; this.fileBrowserAllExpanded = false; this.fileBrowserDragListeners = null; + this.fileBrowserView = 'files'; + this.fileBrowserRepositoryData = null; + this.fileBrowserScopeId = 'current'; + this.fileBrowserSessionId = null; + this.fileBrowserLoadGeneration = 0; + this.fileBrowserAbortController = null; + this.fileBrowserCommitCache = new Map(); + this.fileBrowserExpandedCommit = null; + this.fileBrowserAutoRefreshTimer = null; this.filePreviewContent = ''; + this.fileDiffData = null; + this.fileDiffMode = 'compact'; + this.fileDiffLoadGeneration = 0; // Toast container cache (methods in panels-ui.js) this._toastContainer = null; @@ -613,12 +641,30 @@ class CodemanApp { // Terminal write batching with DEC 2026 sync support this.pendingWrites = []; this.writeFrameScheduled = false; + this._terminalWritesPaused = false; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll this.syncWaitTimeout = null; // Timeout for incomplete sync blocks this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes this._loadBufferQueue = null; // queued SSE events during buffer load this._bufferLoadSeq = 0; this._bufferLoadOwner = null; + this._terminalHistoryReplayCover = null; + this._terminalHistoryReplayCoverOwner = null; + this._terminalHistoryReplayCoverHasContent = false; + this._terminalHistoryReplayCoverComplete = false; + this._terminalHistoryReplayCoverCompleteAt = 0; + this._terminalHistoryReplayCoverVersion = 0; + this._terminalHistoryReplayCoverCheckScheduled = false; + this._terminalHistoryReplayFencePending = false; + this._terminalHistoryReplayQuietUntil = 0; + this._terminalTransportFreezeSessionId = null; + this._terminalTransportFreezeOwner = null; + this._terminalTransportFreezeSeq = 0; + this._terminalFrameReconcileSeq = 0; + this._terminalFrameReconcilePending = null; + this._terminalFrameReconcilePromise = null; + this._terminalHistoryPaging = new Map(); // sessionId -> bounded tmux history-page window + this._terminalHistoryPageSeq = 0; // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; @@ -642,6 +688,11 @@ class CodemanApp { this.maxReconnectAttempts = 10; this.isOnline = navigator.onLine; + // Editable input is one session-scoped state record, kept separate from + // submitted delivery records below. Terminal/CJK modules are view adapters; + // this store owns normalization, handoff transitions, and persistence. + this._inputState = new TerminalInputStateStore(); + // Reliable, durable input delivery (replaces the old best-effort queue). // Every input byte is recorded with a stable clientId + a monotonic // per-session seq, persisted to localStorage, and only dropped once the @@ -663,16 +714,24 @@ class CodemanApp { this._reliableSweepTimer = setInterval(() => this._redeliverSweep(), 2000); // Flush the durable queue synchronously when the page is hidden/closed — // debounced persistence may have a pending write we mustn't lose on reload. - window.addEventListener('pagehide', () => this._persistReliableNow()); + window.addEventListener('pagehide', () => { + this._captureActiveSessionDraft(); + this._inputState.persistNow(); + this._persistReliableNow(); + }); document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') this._persistReliableNow(); + if (document.visibilityState === 'hidden') { + this._captureActiveSessionDraft(); + this._inputState.persistNow(); + this._persistReliableNow(); + } }); // Local echo overlay — DOM overlay positioned at the visible ❯ prompt // (not at buffer.cursorY, which reflects Ink's internal cursor position) this._localEchoOverlay = null; // created after terminal.open() this._localEchoEnabled = false; // true when setting on + session active - this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps + this._restoringFlushedState = false; // true during selectSession buffer load - protects restored draft state // Accessibility: Focus trap for modals this.activeFocusTrap = null; @@ -790,10 +849,14 @@ class CodemanApp { KeyboardHandler.init(); SwipeHandler.init(); VoiceInput.init(); - KeyboardAccessoryBar.init(); - // Apply keyboard bar mode from settings + // One setting controls both mobile terminal-control surfaces: the + // keyboard-hidden menu pad and the keyboard-open accessory bar. const _kbSettings = this.loadAppSettingsFromStorage(); - if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended'); + const _kbDefaults = this.getDefaultSettings(); + MobileTerminalControls.configureFeedback(_kbSettings, _kbDefaults); + MobileTerminalControls.init( + MobileTerminalControls.resolveEnabled(_kbSettings, _kbDefaults) + ); this.applyHeaderVisibilitySettings(); this.restorePlanUsageChip(); this.applySkin(); @@ -994,6 +1057,7 @@ class CodemanApp { // Escape - close panels and modals (different logic: no preventDefault, no return) if (e.key === 'Escape') { + this.cancelInstanceShutdown(); this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); @@ -1071,18 +1135,37 @@ class CodemanApp { // SSE Connection // ═══════════════════════════════════════════════════════════════ + _scheduleWarmTerminalExpiry() { + this._clearTimer('_warmTerminalExpiryTimer'); + const delay = this._warmTerminalCache.nextExpiryDelay(); + if (delay === null) return; + this._warmTerminalExpiryTimer = setTimeout(() => { + this._warmTerminalExpiryTimer = null; + this._updateSseSubscription(); + }, Math.max(1, delay)); + } + /** - * POST a live subscription update so the server filters terminal events - * to the given session(s) for this client. Fire-and-forget — failures - * are non-fatal because we'll still get every event we don't want - * (just at higher cost), and the next reconnect carries the filter via - * the SSE query string. + * Keep the active terminal plus a short, bounded recent-session range on the + * live stream. This makes a quick switch-back a snapshot + delta operation + * while expiry and byte caps prevent background sessions consuming bandwidth + * indefinitely. */ + _terminalSseSubscriptionIds() { + return this._warmTerminalCache.ids(); + } + + _terminalStreamClientId() { + return this._clientId ? `${this._clientId}:${this._wsTabNonce}` : ''; + } + _updateSseSubscription(sessionId) { try { + if (sessionId) this._warmTerminalCache.activate(sessionId); + const sessions = this._terminalSseSubscriptionIds(); const body = JSON.stringify({ - clientId: this._clientId, - sessions: sessionId ? [sessionId] : null, + clientId: this._terminalStreamClientId(), + sessions, }); fetch('/api/events/subscribe', { method: 'POST', @@ -1090,9 +1173,21 @@ class CodemanApp { body, keepalive: true, }).catch(() => { /* non-fatal */ }); + this._scheduleWarmTerminalExpiry(); } catch { /* non-fatal */ } } + _dropWarmTerminalSession(sessionId, discardSnapshot = false) { + if (!sessionId) return; + this._warmTerminalCache.remove(sessionId); + if (discardSnapshot) { + this._xtermSnapshots?.delete(sessionId); + this.terminalBufferCache.delete(sessionId); + try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {} + } + this._updateSseSubscription(); + } + // ══════════════════════════════════════════════════════════════════════ // Session detach / undock (beta/session-detach) // @@ -1316,7 +1411,7 @@ class CodemanApp { if (!session) { this._showSoloSessionGone(); return; } // Force re-select (handleInit cleared terminal state above). this.activeSessionId = null; - this.selectSession(this.soloSessionId); + this.selectSession(this.soloSessionId, { takeControl: false }); const name = this.getSessionName(session) || 'Session'; const titleEl = document.getElementById('soloSessionTitle'); if (titleEl) { titleEl.textContent = name; titleEl.style.display = ''; } @@ -1345,6 +1440,8 @@ class CodemanApp { } connectSSE() { + if (this._instanceShutdownRequested) return; + // Check if browser is offline if (!navigator.onLine) { this.setConnectionStatus('offline'); @@ -1373,12 +1470,12 @@ class CodemanApp { this.setConnectionStatus('reconnecting'); } - // Build URL with stable client ID and (if known) the active-session - // filter so the server only streams session:terminal events for the - // session we're rendering. Lifecycle/metadata events are sent globally - // regardless of filter (server side). - const _sseParams = new URLSearchParams({ clientId: this._clientId }); - if (this.activeSessionId) _sseParams.set('sessions', this.activeSessionId); + // Build the URL with a stable client ID and an explicit terminal filter. + // An empty value means no terminal events (welcome screen or active WS); + // lifecycle/metadata events still arrive globally. + const _sseParams = new URLSearchParams({ clientId: this._terminalStreamClientId() }); + const _warmSessionIds = this._terminalSseSubscriptionIds(); + _sseParams.set('sessions', _warmSessionIds.join(',')); this.eventSource = new EventSource(`/api/events?${_sseParams.toString()}`); // Store all event listeners for cleanup on reconnect @@ -1403,6 +1500,14 @@ class CodemanApp { this.setConnectionStatus('connected'); }; this.eventSource.onerror = () => { + if (this._instanceShutdownRequested) { + this.eventSource?.close(); + this.eventSource = null; + return; + } + if (!this._wsReady && this.activeSessionId) { + this._freezeTerminalForTransportLoss?.(this.activeSessionId); + } this.reconnectAttempts++; if (this.reconnectAttempts >= this.maxReconnectAttempts) { this.setConnectionStatus('disconnected'); @@ -1573,6 +1678,9 @@ class CodemanApp { const session = data.session || data; const oldSession = this.sessions.get(session.id); const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId); + const workingDirectoryChanged = Boolean( + oldSession && session.workingDir && oldSession.workingDir !== session.workingDir + ); this.sessions.set(session.id, session); this.renderSessionTabs(); this.updateCost(); @@ -1592,6 +1700,11 @@ class CodemanApp { this.updateConnectionLines(); }); } + const locallySavingWorkingDirectory = + this._pendingWorkingDirectoryChange?.sessionId === session.id; + if (workingDirectoryChanged && session.id === this.activeSessionId && !locallySavingWorkingDirectory) { + void this.syncFileBrowserSession?.(session.id, { force: true }); + } } _onSessionDeleted(data) { @@ -1632,7 +1745,10 @@ class CodemanApp { } _onSessionTerminal(data) { + if (!data?.id || typeof data.data !== 'string') return; + if (this._serverReloadRequested) return; if (data.id === this.activeSessionId) { + this._deferTerminalHistoryReplayCover?.(data.id); if (data.data.length > 32768) _crashDiag.log(`TERMINAL: ${(data.data.length/1024).toFixed(0)}KB`); // Hard cap: track total bytes queued in render buffers (pendingWrites + @@ -1654,7 +1770,11 @@ class CodemanApp { return; } - this.batchTerminalWrite(data.data); + this.batchTerminalWrite(data.data, data.cursor); + } else if (this._warmTerminalCache.isWarm(data.id)) { + if (!this._warmTerminalCache.append(data.id, data.data)) { + this._dropWarmTerminalSession(data.id, true); + } } } @@ -1840,6 +1960,9 @@ class CodemanApp { _bindResponseViewerInteractions(body) { if (!body || body.dataset.rvBound === '1') return; body.dataset.rvBound = '1'; + body.addEventListener('dblclick', (ev) => { + void this._copyResponseViewerChunk(body, ev); + }); body.addEventListener('click', async (ev) => { // One-click copy: lift the raw source from the sibling
.
       const copyBtn = ev.target.closest('.rv-copy-btn');
@@ -1869,6 +1992,51 @@ class CodemanApp {
     });
   }
 
+  /**
+   * Copy one semantic response chunk. Transcript source is stored directly on
+   * the owning DOM node so Markdown rendering cannot change clipboard content.
+   */
+  async _copyResponseViewerChunk(body, ev) {
+    const target = ev?.target?.closest ? ev.target : ev?.target?.parentElement;
+    if (!body || !target?.closest) return false;
+    if (
+      target.closest(
+        'button, a, input, textarea, select, [contenteditable="true"], [data-no-reply-copy]'
+      )
+    ) {
+      return false;
+    }
+
+    const message = target.closest('.rv-message');
+    const sourceElement = message && body.contains(message) ? message : body;
+    const text = sourceElement._codemanCopyText;
+    if (typeof text !== 'string' || !text) return false;
+
+    ev.preventDefault?.();
+    ev.stopPropagation?.();
+    const ok = await this._copyText(text);
+    if (ok) {
+      sourceElement.classList.remove('rv-copy-feedback');
+      // Restart the confirmation animation on intentional repeated copies.
+      void sourceElement.offsetWidth;
+      sourceElement.classList.add('rv-copy-feedback');
+      clearTimeout(sourceElement._rvCopyFeedbackTimer);
+      sourceElement._rvCopyFeedbackTimer = setTimeout(() => {
+        sourceElement.classList.remove('rv-copy-feedback');
+      }, 700);
+      if (typeof MobileTerminalControls !== 'undefined') {
+        MobileTerminalControls.feedback?.('copy');
+      }
+    }
+    this.showToast(
+      ok
+        ? window.codemanT?.('Copied to clipboard') || 'Copied to clipboard'
+        : 'Copy failed',
+      ok ? 'success' : 'error'
+    );
+    return ok;
+  }
+
   /**
    * Copy text to the clipboard. Prefers the async Clipboard API (secure
    * contexts); falls back to a hidden-textarea + execCommand path so copy
@@ -1914,23 +2082,10 @@ class CodemanApp {
       // Source 1: Transcript JSONL (best quality — clean structured text from Claude)
       const res = await fetch(`/api/sessions/${this.activeSessionId}/last-response`);
       const data = (await res.json())?.data ?? {};
-      let lastResponse = data.text || '';
-
-      // Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
-      // Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
-      // shell sessions have no transcript source at all; for TUI modes
-      // (codex/opencode/gemini) it yields repaint garbage, so a clear
-      // placeholder beats a messy screen dump there.
-      const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
-      if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
-        const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
-        const termData = (await termRes.json())?.data ?? {};
-        if (termData.terminalBuffer) {
-          lastResponse = this._cleanTerminalBuffer(termData.terminalBuffer);
-        }
-      }
+      const lastResponse = data.text || '';
 
       const body = document.getElementById('responseViewerBody');
+      body._codemanCopyText = lastResponse;
       if (lastResponse) {
         body.innerHTML = this._renderMarkdown(lastResponse);
         this._bindResponseViewerInteractions(body);
@@ -1966,6 +2121,7 @@ class CodemanApp {
       const title = document.getElementById('responseViewerTitle');
       if (!body) return;
 
+      body._codemanCopyText = '';
       if (messages.length === 0) {
         body.textContent = 'No conversation history available';
         return;
@@ -1980,6 +2136,7 @@ class CodemanApp {
         const div = document.createElement('div');
         const isUser = msg.role === 'user';
         div.className = 'rv-message ' + (isUser ? 'rv-msg-user' : 'rv-msg-assistant');
+        div._codemanCopyText = typeof msg.text === 'string' ? msg.text : '';
 
         const role = document.createElement('div');
         role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
@@ -2006,10 +2163,15 @@ class CodemanApp {
     }
   }
 
-  async _onSessionNeedsRefresh() {
+  async _onSessionNeedsRefresh(data) {
     // Server sends this after SSE backpressure clears — terminal data was dropped,
     // so reload the buffer to recover from any display corruption.
+    if (data?.id && data.id !== this.activeSessionId) {
+      this._dropWarmTerminalSession(data.id, true);
+      return;
+    }
     if (!this.activeSessionId || !this.terminal) return;
+    this._terminalHistoryPaging.delete(this.activeSessionId);
     // Skip if buffer load already in progress — avoids competing clear+rewrite cycles
     if (this._isLoadingBuffer) return;
     try {
@@ -2034,7 +2196,12 @@ class CodemanApp {
   }
 
   async _onSessionClearTerminal(data) {
+    if (data.id !== this.activeSessionId) {
+      this._dropWarmTerminalSession(data.id, true);
+      return;
+    }
     if (data.id === this.activeSessionId) {
+      this._terminalHistoryPaging.delete(data.id);
       // Skip if selectSession is already loading the buffer — clearTerminal arriving
       // during buffer load would clear the terminal mid-write, causing visible flicker
       // and a race between two concurrent chunkedTerminalWrite calls (especially on mobile
@@ -2295,15 +2462,16 @@ class CodemanApp {
     this._updateConnectionIndicator();
 
     const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
-    // Pass a per-TAB identity on the upgrade URL so the server's connection
-    // registry scopes the per-session limit by connection (COD-137): a same-tab
+    // Pass the same per-page identity used by SSE so the server can atomically
+    // hand terminal delivery between transports. It also scopes the WS
+    // registry's per-session limit by connection (COD-137): a same-tab
     // reconnect supersedes its own socket instead of consuming a new slot and
     // tripping a spurious 4008, while two tabs of the same browser (which share
     // the localStorage clientId) each keep their own socket. The bare clientId
     // still rides the input frames for seq dedup. Omitted if clientId is
     // unavailable (server then treats the upgrade as anonymous — still admitted
     // up to the limit).
-    const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
+    const cid = this._terminalStreamClientId();
     const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
     const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
     const ws = new WebSocket(url);
@@ -2317,11 +2485,32 @@ class CodemanApp {
         this._wsState = 'connected';
         this._wsReconnectAttempts = 0;
         this._updateConnectionIndicator();
+        // Codex commonly emits several stateful full-screen redraws immediately
+        // after a tab's WS transport attaches. Keep the stable target frame over
+        // those bytes while xterm parses them, then reveal only the settled pane.
+        this._deferTerminalHistoryReplayCover?.(sessionId);
+        // WS now owns the active terminal stream; keep SSE only for warm
+        // inactive sessions so the same bytes are not sent twice.
+        this._updateSseSubscription();
         // Send a typed resize over the fresh socket: syncs PTY dims after
         // (re)connects AND registers the desktop sizing claim server-side —
         // selectSession's earlier resizes ran before this WS existed, so they
         // went over HTTP, which never claims (see ws-routes sizingToken).
-        this.sendResize(sessionId)?.catch?.(() => {});
+        const session = this.sessions?.get?.(sessionId);
+        const reconcileMobileCodex =
+          session?.mode === 'codex' &&
+          typeof MobileDetection !== 'undefined' &&
+          MobileDetection.isTouchDevice?.();
+        if (reconcileMobileCodex) {
+          void this._requestTerminalFrameReconcile?.({
+            captureWhenUnchanged: true,
+            reason: 'ws-attach',
+            resizeOptions: {},
+            settleMs: TUI_REDRAW_SETTLE_MS,
+          });
+        } else {
+          this.sendResize(sessionId)?.catch?.(() => {});
+        }
         this._startMobileResizeRetry(sessionId);
         // Flush any durably-queued input over the fresh socket (covers frames a
         // prior half-open socket silently dropped, and input typed while offline).
@@ -2338,7 +2527,7 @@ class CodemanApp {
         const msg = JSON.parse(event.data);
         if (msg.t === 'o') {
           // Terminal output — route through the same batching pipeline as SSE
-          this._onSessionTerminal({ id: sessionId, data: msg.d });
+          this._onSessionTerminal({ id: sessionId, data: msg.d, cursor: msg.cursor });
         } else if (msg.t === 'c') {
           this._onSessionClearTerminal({ id: sessionId });
         } else if (msg.t === 'r') {
@@ -2374,6 +2563,14 @@ class CodemanApp {
       );
 
       const stillActive = this.activeSessionId === sessionId;
+      if (stillActive && plan.action !== 'give-up') {
+        this._freezeTerminalForTransportLoss?.(sessionId);
+      }
+      if (stillActive) {
+        // Re-add the active session to SSE before retrying the socket so output
+        // remains live throughout the fallback window.
+        this._updateSseSubscription();
+      }
       if (plan.action === 'give-up') {
         this._wsState = stillActive ? 'fallback' : 'disconnected';
         this._updateConnectionIndicator();
@@ -2425,11 +2622,33 @@ class CodemanApp {
     this._wsState = 'disconnected';
     this._stopMobileResizeRetry();
     if (this._ws) {
-      this._ws.onclose = null; // Prevent re-entrant cleanup
-      this._ws.close();
+      const ws = this._ws;
+      ws.onclose = null; // Prevent re-entrant cleanup
       this._ws = null;
       this._wsSessionId = null;
       this._wsReady = false;
+      const close = () => {
+        ws.removeEventListener?.('message', onHandoffAck);
+        clearTimeout(handoffTimer);
+        try { ws.close(); } catch {}
+      };
+      const onHandoffAck = (event) => {
+        try {
+          if (JSON.parse(event.data)?.t === 'ha') close();
+        } catch {}
+      };
+      let handoffTimer = 0;
+      if (ws.readyState === 1 && typeof ws.addEventListener === 'function') {
+        ws.addEventListener('message', onHandoffAck);
+        handoffTimer = setTimeout(close, WS_HANDOFF_TIMEOUT_MS);
+        try {
+          ws.send('{"t":"h"}');
+        } catch {
+          close();
+        }
+      } else {
+        close();
+      }
     }
   }
 
@@ -2478,6 +2697,15 @@ class CodemanApp {
    */
   _sendInputAsync(sessionId, input, opts) {
     if (!sessionId || !input) return;
+    if (this._shouldReconcileTerminalAction?.(sessionId, input)) {
+      if (typeof KeyboardHandler !== 'undefined') {
+        KeyboardHandler._beginTerminalFrameCover?.({ restart: true, arm: true });
+      }
+      void this._requestTerminalFrameReconcile?.({
+        reason: 'dialogue-selection',
+        settleMs: TUI_ACTION_REDRAW_SETTLE_MS,
+      });
+    }
     this._reliableSend(sessionId, input, opts?.useMux === true);
   }
 
@@ -2786,6 +3014,69 @@ class CodemanApp {
     }
   }
 
+  // ---- durable editable drafts (localStorage; separate from submitted input) --
+
+  _setSessionDraft(sessionId, candidate, persist = true) {
+    return this._inputState.set(sessionId, candidate, { persist });
+  }
+
+  _captureActiveSessionDraft() {
+    const sessionId = this.activeSessionId;
+    const overlay = this._localEchoOverlay;
+    if (!sessionId || !overlay) return null;
+    const state = overlay.state || {};
+    let cjkText = '';
+    try {
+      cjkText = typeof CjkInput !== 'undefined' ? CjkInput.getPendingText?.() || '' : '';
+    } catch {
+      /* the optional textarea may not be initialized yet */
+    }
+    return this._inputState.capture(sessionId, {
+      pendingText: state.pendingText || '',
+      compositionText: state.compositionText || '',
+      flushedText: state.flushedText || '',
+      cjkText,
+    });
+  }
+
+  _restoreSessionDraft(sessionId, render = true, options = {}) {
+    if (
+      options.preserveLiveComposition &&
+      sessionId === this.activeSessionId &&
+      this._localEchoOverlay?.compositionText
+    ) {
+      _crashDiag.log('DRAFT_RESTORE_SKIP_ACTIVE_COMPOSITION');
+      if (render) this._localEchoOverlay.rerender();
+      return this._inputState.has(sessionId);
+    }
+    const draft = this._inputState.get(sessionId) || {
+      pendingText: '',
+      flushedText: '',
+      cjkText: '',
+    };
+    if (this._localEchoOverlay?.restoreDraft) {
+      this._localEchoOverlay.restoreDraft(
+        {
+          pendingText: draft.pendingText,
+          flushedText: draft.flushedText,
+        },
+        render
+      );
+    }
+    try {
+      if (typeof CjkInput !== 'undefined') {
+        CjkInput.restorePendingText?.(draft.cjkText);
+      }
+    } catch {
+      /* optional CJK input is unavailable */
+    }
+    return this._inputState.has(sessionId);
+  }
+
+  _clearSessionDraft(sessionId) {
+    this._inputState.clear(sessionId);
+  }
+
   // Pure render of the header connection indicator: reads only `this.*` state,
   // touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the
   // writer applies. When hidden (display:'none') the other three are normalized to
@@ -2939,6 +3230,9 @@ class CodemanApp {
     this.terminalBuffers.clear();
     this.terminalBufferCache.clear();
     this._xtermSnapshots?.clear();
+    this._warmTerminalCache.clear();
+    this._terminalHistoryPaging.clear();
+    this._clearTimer('_warmTerminalExpiryTimer');
     this.projectInsights.clear();
     this.teams.clear();
     this.teamTasks.clear();
@@ -2958,6 +3252,8 @@ class CodemanApp {
     this._isLoadingBuffer = false;
     this._loadBufferQueue = null;
     this._bufferLoadOwner = null;
+    this._terminalFrameReconcileSeq = (this._terminalFrameReconcileSeq || 0) + 1;
+    this._terminalFrameReconcilePending = null;
     // Abort any in-flight chunkedTerminalWrite (SSE reconnect reloads buffers)
     this._chunkedWriteGen = (this._chunkedWriteGen || 0) + 1;
     // Preserve local echo overlay text across SSE reconnect — just hide until
@@ -3020,9 +3316,112 @@ class CodemanApp {
     }
   }
 
+  /**
+   * Classify an init snapshot without conflating transport reconnects with page
+   * initialization. A changed process epoch means the server may now serve
+   * different hashed assets, so preserve drafts and replace this stale page.
+   */
+  _handleServerInitEpoch(serverStartedAt) {
+    if (!Number.isSafeInteger(serverStartedAt) || serverStartedAt <= 0) return 'legacy';
+    if (this._serverReloadRequested) return 'reload';
+
+    const previous = this._serverStartedAt;
+    this._serverStartedAt = serverStartedAt;
+    if (!Number.isSafeInteger(previous) || previous <= 0) return 'initial';
+    if (previous === serverStartedAt) return 'reconnect';
+
+    if (!this._serverReloadRequested) {
+      this._serverReloadRequested = true;
+      this._serverRestartRecovery = true;
+      this._captureActiveSessionDraft?.();
+      this._inputState?.persistNow?.();
+      this._persistReliableNow?.();
+      try { sessionStorage.setItem(SERVER_RESTART_RECOVERY_KEY, '1'); } catch {}
+      window.location.reload();
+    }
+    return 'reload';
+  }
+
+  /**
+   * Reconcile authoritative session metadata after a same-process SSE reconnect
+   * while leaving the active xterm frame, scrollback, input, and warm caches
+   * untouched. Replaying terminal history here caused the visible old-to-new
+   * traversal after brief mobile network interruptions.
+   */
+  _reconcileSameServerInit(data) {
+    const incomingSessions = Array.isArray(data.sessions) ? data.sessions : [];
+    const incomingIds = new Set(incomingSessions.map((session) => session.id));
+
+    for (const sessionId of Array.from(this.sessions.keys())) {
+      if (!incomingIds.has(sessionId)) this._onSessionDeleted({ id: sessionId });
+    }
+
+    for (const session of incomingSessions) {
+      this.sessions.set(session.id, session);
+      if ((session.ralphLoop || session.ralphTodos) && !this.ralphClosedSessions.has(session.id)) {
+        this.ralphStates.set(session.id, {
+          loop: session.ralphLoop || null,
+          todos: session.ralphTodos || [],
+        });
+      }
+    }
+
+    if (Array.isArray(data.sessionOrder) && data.sessionOrder.length) {
+      try { localStorage.setItem('codeman-session-order', JSON.stringify(data.sessionOrder)); } catch {}
+    }
+    this.syncSessionOrder();
+
+    this.respawnStatus = data.respawnStatus || {};
+    if (data.globalStats) this.globalStats = data.globalStats;
+
+    const scheduledRuns = Array.isArray(data.scheduledRuns) ? data.scheduledRuns : [];
+    this.totalCost = incomingSessions.reduce((sum, session) => sum + (session.totalCost || 0), 0);
+    this.totalCost += scheduledRuns.reduce((sum, run) => sum + (run.totalCost || 0), 0);
+
+    const activeRun = scheduledRuns.find((run) => run.status === 'running');
+    if (activeRun) {
+      this.currentRun = activeRun;
+      this.showTimer();
+    }
+
+    if (data.planUsage) this.updatePlanUsageChip(data.planUsage);
+
+    if (Array.isArray(data.subagents)) {
+      const incomingAgentIds = new Set(data.subagents.map((agent) => agent.agentId));
+      for (const agentId of Array.from(this.subagents.keys())) {
+        if (!incomingAgentIds.has(agentId)) {
+          this.subagents.delete(agentId);
+          this.subagentActivity.delete(agentId);
+          this.subagentToolResults.delete(agentId);
+        }
+      }
+      for (const agent of data.subagents) {
+        const previous = this.subagents.get(agent.agentId);
+        this.subagents.set(agent.agentId, previous ? { ...previous, ...agent } : agent);
+      }
+      this.renderSubagentPanel();
+    }
+
+    if (Array.isArray(data.workflowRuns)) this.seedWorkflowRuns(data.workflowRuns);
+
+    this.updateCost();
+    this.renderSessionTabs();
+    if (this.sessions.size > 0) this.startSystemStatsPolling();
+    else this.stopSystemStatsPolling();
+  }
+
   handleInit(data) {
     // Clear the init fallback timer since we got data
     this._clearTimer('_initFallbackTimer');
+
+    const initAction = this._handleServerInitEpoch(data.serverStartedAt);
+    if (initAction === 'reload') return;
+    if (initAction === 'reconnect') {
+      this._releaseTerminalTransportFreeze?.(this.activeSessionId);
+      this._reconcileSameServerInit(data);
+      return;
+    }
+
     const gen = ++this._initGeneration;
 
     // CJK input form: controlled by user setting (with server env as override)
@@ -3184,9 +3583,9 @@ class CodemanApp {
         try { restoreId = localStorage.getItem('codeman-active-session'); } catch {}
       }
       if (restoreId && this.sessions.has(restoreId)) {
-        this.selectSession(restoreId);
+        this.selectSession(restoreId, { takeControl: false });
       } else {
-        this.selectSession(this.sessionOrder[0]);
+        this.selectSession(this.sessionOrder[0], { takeControl: false });
       }
     }
   }
@@ -3621,6 +4020,9 @@ class CodemanApp {
 
   handleSessionTabClick(event, sessionId) {
     event?.preventDefault?.();
+    // Desktop activation must clear any mobile inline layout left by a prior
+    // emulated/touch viewport before measuring the terminal container.
+    KeyboardHandler.resetForDesktopViewport?.();
     // On touch with the keyboard hidden, blur the tapped tab so switching
     // sessions doesn't pop the on-screen keyboard. Focus policy itself lives
     // in selectSession via _shouldFocusTerminalForTabSwitch().
@@ -3904,6 +4306,18 @@ class CodemanApp {
   }
 
   _cleanupPreviousSession(newSessionId) {
+    const outgoingSessionId = this.activeSessionId;
+    const outgoingOverlayState = this._localEchoOverlay?.state || null;
+    let outgoingCjkText = '';
+    try {
+      outgoingCjkText =
+        typeof CjkInput !== 'undefined' ? CjkInput.getPendingText?.() || '' : '';
+    } catch {
+      /* optional CJK input is unavailable */
+    }
+    const deferredOutput =
+      (this.pendingWrites?.length ? this.pendingWrites.join('') : '') +
+      (this.flickerFilterBuffer || '');
     // Snapshot the OUTGOING session's xterm rendered state (viewport + scrollback +
     // colors/attrs) before the terminal gets cleared/reset. Lets us restore the
     // exact view on switch-back rather than replaying codex's byte stream, which
@@ -3920,7 +4334,7 @@ class CodemanApp {
       this._xtermSnapshots
     ) {
       try {
-        const snapshot = this._serializeAddon.serialize({ scrollback: 1000 });
+        const snapshot = this._serializeAddon.serialize({ scrollback: TERMINAL_SNAPSHOT_SCROLLBACK });
         if (this._isUsableXtermSnapshot(snapshot)) {
           // Delete-before-set so re-touching a session moves it to the end of
           // the Map's insertion order — otherwise eviction is FIFO and can drop
@@ -3949,6 +4363,18 @@ class CodemanApp {
       }
     }
 
+    // Output that reached the render budget queue but not xterm is not part of
+    // the serialized snapshot. Preserve it as the first warm delta instead of
+    // dropping it with the outgoing session's pending write state below.
+    if (
+      outgoingSessionId &&
+      deferredOutput &&
+      this._warmTerminalCache.isWarm(outgoingSessionId) &&
+      !this._warmTerminalCache.append(outgoingSessionId, deferredOutput)
+    ) {
+      this._dropWarmTerminalSession(outgoingSessionId, true);
+    }
+
     // Close WebSocket for previous session (new one opens after buffer load)
     this._disconnectWs();
 
@@ -4004,34 +4430,34 @@ class CodemanApp {
     // Track flushed length so _render() offsets the overlay correctly even before
     // the PTY echo arrives in the terminal buffer.
     if (this.activeSessionId) {
-      const echoText = this._localEchoOverlay?.pendingText || '';
-      // Include buffer-detected flushed text (from Tab completion, etc.)
-      // so it's preserved across tab switches.
-      const existingFlushed = this._localEchoOverlay?.getFlushed()?.count || 0;
-      const existingFlushedText = this._localEchoOverlay?.getFlushed()?.text || '';
-      if (echoText) {
-        this._sendInputAsync(this.activeSessionId, echoText);
-      }
-      const totalOffset = existingFlushed + echoText.length;
-      if (totalOffset > 0) {
-        if (!this._flushedOffsets) this._flushedOffsets = new Map();
-        if (!this._flushedTexts) this._flushedTexts = new Map();
-        this._flushedOffsets.set(this.activeSessionId, totalOffset);
-        this._flushedTexts.set(this.activeSessionId, existingFlushedText + echoText);
+      const handoff = this._inputState.handoff(this.activeSessionId, {
+        pendingText: outgoingOverlayState?.pendingText || '',
+        compositionText: outgoingOverlayState?.compositionText || '',
+        // Include buffer-detected flushed text (from Tab completion, etc.)
+        // so it remains editable across tab switches.
+        flushedText: outgoingOverlayState?.flushedText || '',
+        cjkText: outgoingCjkText,
+      });
+      if (handoff.flushText) {
+        this._sendInputAsync(this.activeSessionId, handoff.flushText);
       }
     }
     this._localEchoOverlay?.clear();
+    this._terminalInputController?.reset?.();
     // Prevent _detectBufferText() from picking up Claude's Ink UI text
     // (status bar, model info, etc.) as "user input" on fresh sessions.
     // Only sessions with prior flushed text (from tab-switch-away) need detection.
     // After the user's first Enter, clear() resets _bufferDetectDone = false,
     // re-enabling detection for tab completion and other legitimate cases.
-    if (this._localEchoOverlay && !this._flushedOffsets?.has(newSessionId)) {
+    if (this._localEchoOverlay && !this._inputState.hasFlushed(newSessionId)) {
       this._localEchoOverlay.suppressBufferDetection();
     }
   }
 
   _resetTerminalForReplay() {
+    if (typeof KeyboardHandler !== 'undefined') {
+      KeyboardHandler._armTerminalFrameCover?.();
+    }
     this.terminal.reset();
     this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
   }
@@ -4043,6 +4469,11 @@ class CodemanApp {
     return typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
   }
 
+  /**
+   * Select and render a session.
+   * Automatic restore/reconnect callers must pass takeControl:false so a
+   * background viewport cannot bypass server-side resize arbitration.
+   */
   async selectSession(sessionId, options = {}) {
     // If this session is popped out into its own window, raise that window
     // instead of showing it inline (focus-on-click for detached tabs). If we
@@ -4052,10 +4483,28 @@ class CodemanApp {
       if (this._raiseDetached(sessionId)) return;
     }
     const forceReload = options?.forceReload === true;
-    if (this.activeSessionId === sessionId && !forceReload) return;
+    const takeControl = options?.takeControl !== false;
+    if (this.activeSessionId === sessionId && !forceReload) {
+      this._restoreTerminalFromWebview?.(sessionId, { takeControl });
+      return;
+    }
+    this._terminalFrameReconcileSeq = (this._terminalFrameReconcileSeq || 0) + 1;
+    this._terminalFrameReconcilePending = null;
+    if (
+      this.activeSessionId &&
+      this.activeSessionId !== sessionId &&
+      typeof KeyboardHandler !== 'undefined'
+    ) {
+      KeyboardHandler._beginTerminalFrameCover?.({
+        includeShell: true,
+        restart: true,
+        arm: true,
+      });
+    }
     if (this.activeSessionId === sessionId && forceReload) {
       this.terminalBufferCache?.delete(sessionId);
       this._xtermSnapshots?.delete(sessionId);
+      this._terminalHistoryPaging?.delete(sessionId);
       try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
       this._clearTimer('syncWaitTimeout');
       this.pendingWrites = [];
@@ -4080,7 +4529,11 @@ class CodemanApp {
     console.log(`[CRASH-DIAG] selectSession START: ${sessionId.slice(0,8)}`);
 
     const selectGen = ++this._selectGeneration;
+    this._beginTerminalHistoryReplayCover?.(selectGen);
     this._setTerminalLoadState(sessionId, selectGen, 'resizing');
+    const targetWasWarm = this._warmTerminalCache.isWarm(sessionId);
+    this._pauseTerminalWrites();
+    await this._waitForTerminalParserFence();
 
     if (selectGen !== this._selectGeneration) {
       this._clearTerminalLoadState(sessionId, selectGen);
@@ -4092,12 +4545,16 @@ class CodemanApp {
 
     this._cleanupPreviousSession(sessionId);
     this.activeSessionId = sessionId;
+    // Gate destination output before attach/resize can yield to the event loop.
+    // A fresh or restored TUI may emit immediately; those bytes must reconcile
+    // against its snapshot instead of entering the shared xterm parser directly.
+    const bufferLoadOwner = this._beginBufferLoad(selectGen);
+    this._resumeTerminalWrites();
+    this.syncFileBrowserSession?.(sessionId);
     try { localStorage.setItem('codeman-active-session', sessionId); } catch {}
-    // Narrow SSE filter to the active session — server stops streaming
-    // session:terminal events for other sessions to this client. Cuts
-    // SSE traffic ~Nx for N concurrent sessions. Fire-and-forget; on the
-    // rare race where server doesn't know our clientId yet, the next
-    // selectSession or reconnect catches up.
+    // Subscribe to the active session plus the bounded warm range. The server
+    // withholds terminal events for every other session, while recent sessions
+    // retain just enough delta output for an instant switch-back.
     this._updateSseSubscription(sessionId);
     this.hideWelcome();
     // Clear idle hooks on view, but keep action hooks until user interacts
@@ -4115,13 +4572,7 @@ class CodemanApp {
     // the async buffer load.  Without this, the offset is 0 during the
     // fetch() gap: backspace is swallowed, and typing a space covers the
     // canvas text with an opaque overlay showing only the new char.
-    if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
-      this._localEchoOverlay.setFlushed(
-        this._flushedOffsets.get(sessionId),
-        this._flushedTexts?.get(sessionId) || '',
-        false  // render=false: buffer not loaded yet
-      );
-    }
+    this._restoreSessionDraft(sessionId, false);
 
     // Glow the newly-active tab
     const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
@@ -4175,7 +4626,7 @@ class CodemanApp {
 
     // Load terminal buffer for this session
     // Show cached content instantly while fetching fresh data in background.
-    // Use tail mode for faster initial load (128KB is enough for recent visible content).
+    // Non-warm tab switches use the bounded terminal tail; warm revisits skip it.
     //
     // Protect flushed state during buffer load: terminal.write() can trigger
     // xterm.js onData responses (DA, OSC, etc.) that would otherwise clear
@@ -4187,10 +4638,10 @@ class CodemanApp {
     // Without this, SSE events arriving during the fetch() gap compete with
     // the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
     // chunkedTerminalWrite also sets this, but we need it before the fetch too.
-    const bufferLoadOwner = this._beginBufferLoad(selectGen);
     // COD-144: track whether the load painted nothing (empty fetch + no cache).
     // For that just-created-session case we flush (not discard) queued SSE events.
     let bufferWasEmpty = false;
+    let snapshotCursor = null;
     try {
       // Fit terminal to container BEFORE writing any buffer data.
       // If the browser was resized while viewing another session, the terminal
@@ -4204,7 +4655,15 @@ class CodemanApp {
       // a small region with empty rows below the status bar.
       // sendResize is a no-op on the server when dims haven't changed, so
       // calling it every tab switch is cheap.
-      const dimsChanged = await this.sendResize(sessionId, { forceHttp: true }).catch(() => false);
+      const forceDesktopResize =
+        forceReload &&
+        (typeof MobileDetection === 'undefined' ||
+          !MobileDetection.getDeviceType ||
+          MobileDetection.getDeviceType() === 'desktop');
+      const dimsChanged = await this.sendResize(sessionId, {
+        force: forceDesktopResize,
+        ...(takeControl ? { takeControl: true } : {}),
+      }).catch(() => false);
       if (this._isStaleSelect(selectGen)) {
         this._clearTerminalLoadState(sessionId, selectGen);
         return;
@@ -4222,9 +4681,11 @@ class CodemanApp {
       // Try in-memory first (fast); fall back to localStorage so snapshots
       // survive tab discards / browser reloads.
       let snapshot = this._xtermSnapshots?.get(sessionId);
+      let snapshotWasInMemory = !!snapshot;
       if (snapshot && !this._isUsableXtermSnapshot(snapshot)) {
         this._xtermSnapshots?.delete(sessionId);
         snapshot = null;
+        snapshotWasInMemory = false;
       }
       if (!snapshot) {
         try {
@@ -4243,8 +4704,10 @@ class CodemanApp {
         }
       }
       const sessionIsBusy = session && (session.status === 'busy' || session.status === 'working');
+      const canWarmRestore = targetWasWarm && snapshotWasInMemory;
       let restoredSnapshot = false;
-      if (snapshot && !sessionIsBusy && session?.mode !== 'shell') {
+      let usedWarmRestore = false;
+      if (snapshot && (canWarmRestore || !sessionIsBusy) && session?.mode !== 'shell') {
         _crashDiag.log(`SNAPSHOT_RESTORE: ${(snapshot.length/1024).toFixed(0)}KB`);
         this._setTerminalLoadState(sessionId, selectGen, 'replaying');
         this._resetTerminalForReplay();
@@ -4253,27 +4716,53 @@ class CodemanApp {
           this._clearTerminalLoadState(sessionId, selectGen);
           return;
         }
-        this.scrollToLastNonEmptyLine();
+        this.terminal.scrollToBottom();
         _crashDiag.log('SNAPSHOT_RESTORE_DONE');
-        // Snapshot restore is only first paint. Inactive tabs intentionally
-        // unsubscribe from high-volume terminal output, so they can miss bytes
-        // emitted while away. Keep going and replace the snapshot with the
-        // canonical live tmux pane frame from /terminal.
+        // A warm in-memory snapshot is followed by only the bounded terminal
+        // delta collected while away. Persisted/non-warm snapshots still use
+        // the canonical terminal endpoint to reconcile missed output.
         restoredSnapshot = true;
+        usedWarmRestore = canWarmRestore;
+      }
+
+      if (usedWarmRestore) {
+        const warmDelta = this._warmTerminalCache.consume(sessionId);
+        if (warmDelta === null) {
+          usedWarmRestore = false;
+        } else if (warmDelta.length > 0) {
+          _crashDiag.log(`WARM_DELTA: ${(warmDelta.length/1024).toFixed(0)}KB`);
+          await this.chunkedTerminalWrite(
+            warmDelta,
+            TERMINAL_CHUNK_SIZE,
+            bufferLoadOwner,
+            { followBottom: true }
+          );
+          if (this._isStaleSelect(selectGen)) {
+            this._clearTerminalLoadState(sessionId, selectGen);
+            return;
+          }
+        }
       }
 
-      // Instant cache restore for IDLE sessions only.
+      // Instant byte-cache restore for IDLE sessions without a warm xterm view.
       // For busy sessions, the cache is always stale — writing it first causes a
       // jarring double-render: stale content appears, then the terminal flashes
       // blank and rewrites with fresh data. Skip the cache and write the fresh
       // buffer once for a single clean transition.
       const cachedBuffer = this.terminalBufferCache.get(sessionId);
       let clearedForBusy = false;
-      if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
+      if (usedWarmRestore) {
+        _crashDiag.log('WARM_RESTORE_DONE');
+      } else if (cachedBuffer && !sessionIsBusy && !restoredSnapshot) {
         _crashDiag.log(`CACHE_WRITE: ${(cachedBuffer.length/1024).toFixed(0)}KB`);
         this._setTerminalLoadState(sessionId, selectGen, 'replaying');
         this._resetTerminalForReplay();
-        await this.chunkedTerminalWrite(cachedBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
+        await this.chunkedTerminalWrite(
+          cachedBuffer,
+          TERMINAL_CHUNK_SIZE,
+          bufferLoadOwner,
+          { followBottom: true }
+        );
         if (this._isStaleSelect(selectGen)) {
           this._clearTerminalLoadState(sessionId, selectGen);
           return;
@@ -4292,7 +4781,7 @@ class CodemanApp {
       // dimensions (a real SIGWINCH → Ink redraw); a same-size tab switch sent
       // no resize, so waiting would just add latency. Shell sessions never need
       // it, so terminal content can appear immediately when switching shells.
-      if (session?.mode !== 'shell' && dimsChanged) {
+      if (!usedWarmRestore && session?.mode !== 'shell' && dimsChanged) {
         await new Promise((resolve) => setTimeout(resolve, TUI_REDRAW_SETTLE_MS));
         if (this._isStaleSelect(selectGen)) {
           this._clearTerminalLoadState(sessionId, selectGen);
@@ -4300,84 +4789,192 @@ class CodemanApp {
         }
       }
 
-      this._setTerminalLoadState(sessionId, selectGen, 'fetching');
-      _crashDiag.log('FETCH_START');
-      // The FIRST buffer load after a page load requests the full tmux scrollback
-      // (?full=1, COD-47) so history that scrolled off the server's byte buffer
-      // comes back after a reload. Tab switches keep the fast ?tail= frame path.
-      const useFullHistory = this._initialFullBufferLoad === true;
-      this._initialFullBufferLoad = false;
-      const res = await fetch(
-        useFullHistory
-          ? `/api/sessions/${sessionId}/terminal?full=1`
-          : `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
-      );
-      if (this._isStaleSelect(selectGen)) {
-        this._clearTerminalLoadState(sessionId, selectGen);
-        return;
-      }
-      const data = (await res.json())?.data ?? {};
-      _crashDiag.log(`FETCH_DONE: ${data.terminalBuffer ? (data.terminalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} truncated=${data.truncated}`);
-
-      if (data.terminalBuffer) {
-        // Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
-        // On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
-        // very visible, causing the terminal to flash blank then repaint.
-        // A snapshot restore or a busy-clear leaves the terminal showing
-        // something other than the cache, so the fetched buffer must be
-        // replayed even when it byte-matches the cache.
-        const needsRewrite =
-          restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer;
-        if (needsRewrite) {
-          _crashDiag.log(`REWRITE: ${(data.terminalBuffer.length/1024).toFixed(0)}KB`);
-          this._setTerminalLoadState(sessionId, selectGen, 'replaying');
-          this._resetTerminalForReplay();
-          // Show truncation indicator if buffer was cut
-          if (data.truncated) {
-            this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
-          }
-          // Use chunked write for large buffers to avoid UI jank
-          await this.chunkedTerminalWrite(data.terminalBuffer, TERMINAL_CHUNK_SIZE, bufferLoadOwner);
-          if (this._isStaleSelect(selectGen)) {
+      if (!usedWarmRestore) {
+        this._setTerminalLoadState(sessionId, selectGen, 'fetching');
+        _crashDiag.log('FETCH_START');
+        // Cold loads request only the newest physical tmux history page. Older
+        // pages are fetched on upward scroll; the current pane is requested in
+        // parallel so first paint never waits for history.
+        const historyUrl =
+          `/api/sessions/${sessionId}/terminal?historyPage=1` +
+          `&lines=${TERMINAL_HISTORY_PAGE_LINES}&format=stream`;
+        const latestUrl =
+          `/api/sessions/${sessionId}/terminal?latest=1` +
+          `&tail=${TERMINAL_LATEST_FRAME_SIZE}&format=stream`;
+        const latestResponsePromise = fetch(latestUrl);
+        const historyResponsePromise = fetch(historyUrl);
+        // Attach a rejection handler immediately while the latest frame is
+        // awaited; the original promise still rejects when consumed below.
+        void historyResponsePromise.catch(() => {});
+        let paintedLatestFrame = false;
+        let latestFrame = null;
+        try {
+          const latestResponse = await latestResponsePromise;
+          latestFrame = await this._readTerminalSnapshotResponse(latestResponse, {
+            paint: false,
+            isCancelled: () => this._isStaleSelect(selectGen),
+          });
+          if (latestFrame.aborted || this._isStaleSelect(selectGen)) {
             this._clearTerminalLoadState(sessionId, selectGen);
             return;
           }
-          // Ensure terminal is scrolled to bottom after buffer load
-          this.terminal.scrollToBottom();
+          if (latestFrame.terminalBuffer) {
+            _crashDiag.log(`LATEST_FRAME: ${(latestFrame.terminalBuffer.length/1024).toFixed(0)}KB`);
+            this._setTerminalLoadState(sessionId, selectGen, 'replaying');
+            this._resetTerminalForReplay();
+            await this.chunkedTerminalWrite(
+              latestFrame.terminalBuffer,
+              TERMINAL_CHUNK_SIZE,
+              bufferLoadOwner,
+              { followBottom: true }
+            );
+            if (this._isStaleSelect(selectGen)) {
+              this._clearTerminalLoadState(sessionId, selectGen);
+              return;
+            }
+            this.terminal.scrollToBottom();
+            await this._waitForTerminalPaint();
+            if (this._isStaleSelect(selectGen)) {
+              this._clearTerminalLoadState(sessionId, selectGen);
+              return;
+            }
+            if (this._replaceTerminalHistoryReplayCover(selectGen, { onlyIfEmpty: true })) {
+              if (typeof KeyboardHandler !== 'undefined') {
+                KeyboardHandler._discardTerminalFrameCover?.();
+              }
+            }
+            paintedLatestFrame = true;
+          }
+        } catch (err) {
+          console.warn('Failed to load latest terminal frame; continuing with history:', err);
         }
 
-        // Update cache (cap at 20 entries)
-        this.terminalBufferCache.set(sessionId, data.terminalBuffer);
-        if (this.terminalBufferCache.size > 20) {
-          // Evict oldest entry (first key in Map iteration order)
-          const oldest = this.terminalBufferCache.keys().next().value;
-          this.terminalBufferCache.delete(oldest);
+        const res = await historyResponsePromise;
+        if (this._isStaleSelect(selectGen)) {
+          this._clearTerminalLoadState(sessionId, selectGen);
+          return;
+        }
+        const data = await this._readTerminalSnapshotResponse(res, {
+          paint: false,
+          isCancelled: () => this._isStaleSelect(selectGen),
+        });
+        if (data.aborted || this._isStaleSelect(selectGen)) {
+          this._clearTerminalLoadState(sessionId, selectGen);
+          return;
+        }
+
+        let canonicalBuffer = data.terminalBuffer || '';
+        if (data.historyPage && !latestFrame?.terminalBuffer) {
+          // A history page excludes the visible pane by design. If its parallel
+          // latest-frame request failed, recover with the bounded legacy tail
+          // rather than exposing the last historical rows as the live prompt.
+          try {
+            const fallbackResponse = await fetch(
+              `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}&format=stream`
+            );
+            const fallback = await this._readTerminalSnapshotResponse(fallbackResponse, {
+              paint: false,
+              isCancelled: () => this._isStaleSelect(selectGen),
+            });
+            if (fallback.aborted || this._isStaleSelect(selectGen)) {
+              this._clearTerminalLoadState(sessionId, selectGen);
+              return;
+            }
+            canonicalBuffer = fallback.terminalBuffer || canonicalBuffer;
+            snapshotCursor = fallback.cursor ?? data.cursor ?? null;
+          } catch (err) {
+            console.warn('Latest-frame fallback failed; using the bounded history page:', err);
+            snapshotCursor = data.cursor ?? null;
+          }
+          this._terminalHistoryPaging.delete(sessionId);
+        } else if (data.historyPage && latestFrame?.terminalBuffer) {
+          const paging = this._installTerminalHistoryPage(sessionId, data);
+          canonicalBuffer = this._composeTerminalHistoryWindow(
+            paging?.pages || [],
+            latestFrame.terminalBuffer,
+            false
+          );
+          snapshotCursor = latestFrame.cursor ?? data.cursor ?? null;
+        } else {
+          this._terminalHistoryPaging.delete(sessionId);
+          snapshotCursor = data.cursor ?? latestFrame?.cursor ?? null;
+        }
+        _crashDiag.log(
+          `FETCH_DONE: ${canonicalBuffer ? (canonicalBuffer.length/1024).toFixed(0) + 'KB' : 'empty'} ` +
+          `truncated=${data.truncated} streamed=${data.streamed} paged=${!!data.historyPage}`
+        );
+
+        if (canonicalBuffer) {
+          // Skip rewrite if fresh buffer matches cache — avoids visible clear+rewrite flash.
+          // On slow connections (mobile 5G), the gap between clear() and chunkedWrite() is
+          // very visible, causing the terminal to flash blank then repaint.
+          // A snapshot restore or a busy-clear leaves the terminal showing
+          // something other than the cache, so the fetched buffer must be
+          // replayed even when it byte-matches the cache.
+          const needsRewrite =
+            paintedLatestFrame ||
+            restoredSnapshot ||
+            clearedForBusy ||
+            canonicalBuffer !== cachedBuffer;
+          if (needsRewrite) {
+            _crashDiag.log(`REWRITE: ${(canonicalBuffer.length/1024).toFixed(0)}KB`);
+            this._setTerminalLoadState(sessionId, selectGen, 'replaying');
+            this._resetTerminalForReplay();
+            // Show truncation indicator if buffer was cut
+            if (data.truncated && !data.historyPage) {
+              this.terminal.write('\x1b[90m... (earlier output truncated for performance) ...\x1b[0m\r\n\r\n');
+            }
+            // Use chunked write for large buffers to avoid UI jank
+            await this.chunkedTerminalWrite(
+              canonicalBuffer,
+              TERMINAL_CHUNK_SIZE,
+              bufferLoadOwner,
+              { followBottom: true }
+            );
+            if (this._isStaleSelect(selectGen)) {
+              this._clearTerminalLoadState(sessionId, selectGen);
+              return;
+            }
+            // Ensure terminal is scrolled to bottom after buffer load
+            this.terminal.scrollToBottom();
+          }
+
+          // Update cache (cap at 20 entries)
+          this.terminalBufferCache.set(sessionId, canonicalBuffer);
+          if (this.terminalBufferCache.size > 20) {
+            // Evict oldest entry (first key in Map iteration order)
+            const oldest = this.terminalBufferCache.keys().next().value;
+            this.terminalBufferCache.delete(oldest);
+          }
+        } else if (!cachedBuffer) {
+          // No fresh buffer and no cache — clear any stale content
+          this._resetTerminalForReplay();
+          bufferWasEmpty = true;
         }
-      } else if (!cachedBuffer) {
-        // No fresh buffer and no cache — clear any stale content
-        this._resetTerminalForReplay();
-        bufferWasEmpty = true;
       }
 
-      // Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
-      // _finishBufferLoad internally (discarding queued events to prevent duplicate
-      // content); if we skipped the write (cache hit or empty), call it here.
+      // Buffer load complete — unblock live SSE writes. selectSession owns this
+      // gate across every cache/fetch/replay stage, so its chunked writes leave
+      // the transaction open and we finish it exactly once here.
       // COD-144: when the load painted nothing, FLUSH the queued events instead of
       // discarding — a new session's prompt arrives only as a queued SSE event.
       if (this._isLoadingBuffer) {
-        this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
+        this._finishBufferLoad(bufferLoadOwner, {
+          snapshotCursor,
+          flushQueued: bufferWasEmpty || usedWarmRestore,
+        });
       }
       // Drop the guard so user input clears state normally
       this._restoringFlushedState = false;
 
       // Restore flushed offset and text for this session so the overlay positions
       // correctly even before the PTY echo arrives in the terminal buffer.
-      if (this._flushedOffsets?.has(sessionId) && this._localEchoOverlay) {
-        this._localEchoOverlay.setFlushed(
-          this._flushedOffsets.get(sessionId),
-          this._flushedTexts?.get(sessionId) || '',
-          false  // render=false: buffer just loaded, defer to rerender
-        );
+      if (
+        this._restoreSessionDraft(sessionId, false, {
+          preserveLiveComposition: true,
+        }) &&
+        this._localEchoOverlay
+      ) {
         // Trigger render after xterm.js finishes processing the buffer data.
         // terminal.write('', callback) fires the callback after ALL previously
         // queued writes have been parsed — so findPrompt() can find ❯ in the buffer.
@@ -4395,7 +4992,7 @@ class CodemanApp {
       // conversation. Stale Ink frames in the tailed buffer are a cosmetic
       // annoyance that disappear on the user's next keypress; data loss is not
       // acceptable. Do NOT re-introduce Ctrl+L here.
-      this.sendResize(sessionId);
+      this.sendResize(sessionId, takeControl ? { takeControl: true } : {});
 
       // Defer secondary panel updates so they don't block the main thread
       // after terminal content is already visible.
@@ -4449,8 +5046,11 @@ class CodemanApp {
         if (settings.showFileBrowser) {
           const fileBrowserPanel = this.$('fileBrowserPanel');
           if (fileBrowserPanel) {
+            const wasVisible = fileBrowserPanel.classList.contains('visible');
             fileBrowserPanel.classList.add('visible');
-            this.loadFileBrowser(sessionId);
+            if (!wasVisible || this.fileBrowserSessionId !== sessionId) {
+              this.loadFileBrowser(sessionId);
+            }
             // Attach drag listeners if not already attached
             if (!this.fileBrowserDragListeners) {
               const header = fileBrowserPanel.querySelector('.file-browser-header');
@@ -4478,24 +5078,24 @@ class CodemanApp {
 
       _crashDiag.log('FOCUS');
       if (shouldFocusTerminal && this.terminal) this.terminal.focus();
-      this.scrollToLastNonEmptyLine();
-      // If we switched INTO this tab while the soft keyboard is already up, no
-      // viewport-resize transition fires (handleViewportResize only runs
-      // onKeyboardShow on a hidden→visible change), so the newly-active
-      // session never gets that heal: fit() + scrollToBottom() + local-echo
-      // overlay rerender() + one-shot SIGWINCH. Without it the overlay renders
-      // against stale, off-bottom state and typed input stays INVISIBLE until
-      // the user manually toggles the keyboard. Replicate the heal here so
-      // local echo paints on the first keystroke after a keyboard-up tab switch.
+      this.terminal.scrollToBottom();
+      // A keyboard-up tab switch still needs focus, prompt anchoring, and local
+      // echo restoration. selectSession() already fitted and resized the target,
+      // so use the no-refit handoff instead of scheduling onKeyboardShow() again.
       if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) {
-        KeyboardHandler.onKeyboardShow();
+        KeyboardHandler.restoreTerminalInputAfterSessionSwitch?.();
+      }
+      if (typeof KeyboardHandler !== 'undefined') {
+        KeyboardHandler.onTerminalFrameReady?.();
       }
+      this._completeTerminalHistoryReplayCover(selectGen);
       this._clearTerminalLoadState(sessionId, selectGen);
       _crashDiag.log(`SELECT_DONE: ${(performance.now() - _selStart).toFixed(0)}ms`);
       console.log(`[CRASH-DIAG] selectSession DONE: ${sessionId.slice(0,8)} in ${(performance.now() - _selStart).toFixed(0)}ms`);
     } catch (err) {
       if (this._isLoadingBuffer) this._finishBufferLoad(bufferLoadOwner);
       this._restoringFlushedState = false;
+      this._discardTerminalHistoryReplayCover(selectGen);
       this._setTerminalLoadState(sessionId, selectGen, 'failed');
       console.error('Failed to load session terminal:', err);
     }
@@ -4518,10 +5118,12 @@ class CodemanApp {
     this.terminalBuffers.delete(sessionId);
     this.terminalBufferCache.delete(sessionId);
     this._xtermSnapshots?.delete(sessionId);
+    this._terminalHistoryPaging.delete(sessionId);
+    this._warmTerminalCache.remove(sessionId);
+    this._scheduleWarmTerminalExpiry();
     try { localStorage.removeItem(`codeman-xs-${sessionId}`); } catch {}
 
-    this._flushedOffsets?.delete(sessionId);
-    this._flushedTexts?.delete(sessionId);
+    this._clearSessionDraft(sessionId);
     // Drop any durably-queued input for a session that's actually gone (deleted/
     // exited). Not a lost prompt — the target no longer exists. Only reached on
     // real session removal, never on a tab switch.
@@ -4630,6 +5232,99 @@ class CodemanApp {
     }
   }
 
+  requestInstanceShutdown() {
+    if (this._instanceShutdownState === 'submitting' || this._instanceShutdownState === 'accepted') return;
+
+    MobileTerminalControls.blurTerminalInput();
+    const modal = document.getElementById('instanceShutdownModal');
+    const status = document.getElementById('instanceShutdownStatus');
+    const closeButton = document.getElementById('instanceShutdownCloseBtn');
+    const cancelButton = document.getElementById('instanceShutdownCancelBtn');
+    const confirmButton = document.getElementById('instanceShutdownConfirmBtn');
+    if (!modal || !status || !closeButton || !cancelButton || !confirmButton) return;
+
+    this._instanceShutdownState = 'confirming';
+    status.hidden = true;
+    status.classList.remove('error');
+    status.textContent = '';
+    closeButton.disabled = false;
+    cancelButton.disabled = false;
+    confirmButton.disabled = false;
+    confirmButton.textContent = 'Shut down';
+    modal.classList.add('active');
+    modal.setAttribute('aria-hidden', 'false');
+    document.body.classList.add('terminal-action-pending');
+
+    this._instanceShutdownFocusTrap?.deactivate();
+    this._instanceShutdownFocusTrap = new FocusTrap(modal);
+    this._instanceShutdownFocusTrap.activate();
+    requestAnimationFrame(() => cancelButton.focus());
+  }
+
+  cancelInstanceShutdown() {
+    if (this._instanceShutdownState === 'submitting' || this._instanceShutdownState === 'accepted') return;
+
+    const modal = document.getElementById('instanceShutdownModal');
+    modal?.classList.remove('active');
+    modal?.setAttribute('aria-hidden', 'true');
+    document.body.classList.remove('terminal-action-pending');
+    this._instanceShutdownFocusTrap?.deactivate();
+    this._instanceShutdownFocusTrap = null;
+    this._instanceShutdownState = 'idle';
+  }
+
+  async confirmInstanceShutdown() {
+    if (this._instanceShutdownState !== 'confirming') return;
+
+    const modal = document.getElementById('instanceShutdownModal');
+    const status = document.getElementById('instanceShutdownStatus');
+    const closeButton = document.getElementById('instanceShutdownCloseBtn');
+    const cancelButton = document.getElementById('instanceShutdownCancelBtn');
+    const confirmButton = document.getElementById('instanceShutdownConfirmBtn');
+    if (!modal || !status || !closeButton || !cancelButton || !confirmButton) return;
+
+    this._instanceShutdownState = 'submitting';
+    closeButton.disabled = true;
+    cancelButton.disabled = true;
+    confirmButton.disabled = true;
+    confirmButton.textContent = 'Stopping...';
+    status.hidden = false;
+    status.classList.remove('error');
+    status.textContent = 'Requesting a graceful shutdown...';
+
+    try {
+      const response = await fetch('/api/system/shutdown', { method: 'POST' });
+      const payload = await response.json().catch(() => ({}));
+      if (!response.ok) {
+        throw new Error(payload?.error || 'Codeman could not be stopped');
+      }
+
+      const result = payload?.success === true ? payload.data : payload;
+      this._instanceShutdownState = 'accepted';
+      this._instanceShutdownRequested = true;
+      status.textContent = result?.alreadyScheduled
+        ? 'Codeman shutdown is already in progress.'
+        : 'Codeman is stopping. You can close this page.';
+      confirmButton.textContent = 'Stopping...';
+      this._clearTimer('sseReconnectTimeout');
+      this.eventSource?.close();
+      this.eventSource = null;
+      this._disconnectWs();
+      modal.focus();
+    } catch (error) {
+      this._instanceShutdownState = 'confirming';
+      closeButton.disabled = false;
+      cancelButton.disabled = false;
+      confirmButton.disabled = false;
+      confirmButton.textContent = 'Try again';
+      status.hidden = false;
+      status.classList.add('error');
+      status.textContent = error?.message || 'Codeman could not be stopped';
+      this.showToast?.(status.textContent, 'error');
+      cancelButton.focus();
+    }
+  }
+
   nextSession() {
     if (this.sessionOrder.length <= 1) return;
 
@@ -4652,6 +5347,7 @@ class CodemanApp {
 
   goHome() {
     // Deselect active session and show welcome screen
+    this._cleanupPreviousSession(null);
     this.activeSessionId = null;
     try { localStorage.removeItem('codeman-active-session'); } catch {}
     this.terminal.clear();
@@ -4706,6 +5402,11 @@ class CodemanApp {
       this.terminalBufferCache.clear();
       this.terminalLoadStates.clear();
       this._xtermSnapshots?.clear();
+      this._warmTerminalCache.clear();
+      this._terminalHistoryPaging.clear();
+      this._clearTimer('_warmTerminalExpiryTimer');
+      this._inputState.clearAll({ persist: false });
+      this._inputState.persistNow();
       try {
         for (const k of Object.keys(localStorage)) {
           if (k.startsWith('codeman-xs-')) localStorage.removeItem(k);
diff --git a/src/web/public/constants.js b/src/web/public/constants.js
index 1db36064..f50b6b1e 100644
--- a/src/web/public/constants.js
+++ b/src/web/public/constants.js
@@ -14,8 +14,9 @@
  * @globals {Array} BUILTIN_RESPAWN_PRESETS - Built-in respawn configuration presets
  *
  * @dependency None (first in load order)
- * @loadorder 1 of 15 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
- *   → keyboard-accessory.js → input-cjk.js → app.js → terminal-ui.js → respawn-ui.js
+ * @loadorder 1 of 16 — constants.js → mobile-handlers.js → voice-input.js → notification-manager.js
+ *   → keyboard-accessory.js → input-cjk.js → terminal-input-state.js → terminal-input-controller.js
+ *   → app.js → terminal-ui.js → respawn-ui.js
  *   → ralph-panel.js → settings-ui.js → panels-ui.js → session-ui.js → ralph-wizard.js
  *   → api-client.js → subagent-windows.js
  */
@@ -52,13 +53,134 @@ const NOTIFICATION_LIST_CAP = 100;          // Max notifications in list
 const TITLE_FLASH_INTERVAL_MS = 1500;       // Title flash rate
 const BROWSER_NOTIF_RATE_LIMIT_MS = 3000;   // Rate limit for browser notifications
 const MOBILE_RESIZE_RETRY_MS = 30000;       // Small-viewport resize re-send while a desktop sizing claim is hot
+const WS_HANDOFF_TIMEOUT_MS = 250;           // Bound graceful WS → SSE terminal handoff
 const AUTO_CLOSE_NOTIFICATION_MS = 8000;    // Auto-close browser notifications
 const THROTTLE_DELAY_MS = 100;              // General UI throttle delay
 const TERMINAL_CHUNK_SIZE = 32 * 1024;      // 32KB chunks for terminal buffer loading
 const TERMINAL_TAIL_SIZE = 1024 * 1024;     // 1MB tail for initial load (more scrollback on tab switch)
+const TERMINAL_LATEST_FRAME_SIZE = 128 * 1024; // Bounded current-pane request shown before history replay
+const TERMINAL_HISTORY_PAGE_LINES = 400;     // Small physical-row pages keep mobile history fetches responsive
+const TERMINAL_HISTORY_WINDOW_PAGES = 6;     // Bound rendered/readable history while retaining the latest frame
+const TERMINAL_SNAPSHOT_SCROLLBACK = 10_000; // Stable switch-back history without serializing all 50k mobile lines
+const CODEX_POST_SWITCH_QUIET_MS = 180;       // Hide stateful Codex redraw bursts immediately after tab activation
+const CODEX_POST_SWITCH_MAX_HOLD_MS = 1500;   // Bound ordinary switch cover time during continuous output
+const CODEX_RESTART_RECOVERY_QUIET_MS = 500;  // Restarted Codex panes emit redraw bursts with wider inter-burst gaps
+const CODEX_RESTART_RECOVERY_MAX_HOLD_MS = 3000; // Keep the persisted frame through restored-pane replay
+const SERVER_RESTART_RECOVERY_KEY = 'codeman-server-restart-recovery';
 const SYNC_WAIT_TIMEOUT_MS = 50;            // Wait timeout for terminal sync
 const STATS_POLLING_INTERVAL_MS = 2000;     // System stats polling
 const TUI_REDRAW_SETTLE_MS = 400;           // Grace for a TUI to redraw after a real resize, before fetching its buffer
+const TUI_ACTION_REDRAW_SETTLE_MS = 180;     // Grace before capturing the pane after a decision is submitted
+
+/**
+ * Bounded cache for recently viewed terminal sessions.
+ *
+ * The active session never expires. Sessions demoted by a tab switch remain
+ * warm briefly so their live terminal deltas can be applied to the serialized
+ * xterm snapshot instead of downloading and replaying the full terminal tail.
+ */
+class WarmTerminalCache {
+  constructor({ limit = 3, ttlMs = 30_000, maxDeltaBytes = 256 * 1024 } = {}) {
+    this.limit = limit;
+    this.ttlMs = ttlMs;
+    this.maxDeltaBytes = maxDeltaBytes;
+    this.entries = new Map();
+  }
+
+  activate(sessionId, now = Date.now()) {
+    if (!sessionId) return;
+
+    for (const [id, entry] of this.entries) {
+      if (id !== sessionId && entry.expiresAt === null) {
+        entry.expiresAt = now + this.ttlMs;
+      }
+    }
+
+    const entry = this.entries.get(sessionId) || {
+      expiresAt: null,
+      chunks: [],
+      deltaBytes: 0,
+      valid: true,
+    };
+    entry.expiresAt = null;
+    this.entries.delete(sessionId);
+    this.entries.set(sessionId, entry);
+    this._prune(now);
+  }
+
+  append(sessionId, data, now = Date.now()) {
+    this._prune(now);
+    const entry = this.entries.get(sessionId);
+    if (!entry || !entry.valid || typeof data !== 'string' || data.length === 0) {
+      return !!entry?.valid;
+    }
+
+    if (entry.deltaBytes + data.length > this.maxDeltaBytes) {
+      entry.valid = false;
+      entry.chunks = [];
+      entry.deltaBytes = 0;
+      return false;
+    }
+
+    entry.chunks.push(data);
+    entry.deltaBytes += data.length;
+    return true;
+  }
+
+  consume(sessionId, now = Date.now()) {
+    this._prune(now);
+    const entry = this.entries.get(sessionId);
+    if (!entry?.valid) return null;
+
+    const data = entry.chunks.join('');
+    entry.chunks = [];
+    entry.deltaBytes = 0;
+    return data;
+  }
+
+  isWarm(sessionId, now = Date.now()) {
+    this._prune(now);
+    return this.entries.get(sessionId)?.valid === true;
+  }
+
+  ids(now = Date.now()) {
+    this._prune(now);
+    return Array.from(this.entries.keys());
+  }
+
+  nextExpiryDelay(now = Date.now()) {
+    this._prune(now);
+    let earliest = Infinity;
+    for (const entry of this.entries.values()) {
+      if (entry.expiresAt !== null) earliest = Math.min(earliest, entry.expiresAt);
+    }
+    return Number.isFinite(earliest) ? Math.max(0, earliest - now) : null;
+  }
+
+  remove(sessionId) {
+    this.entries.delete(sessionId);
+  }
+
+  clear() {
+    this.entries.clear();
+  }
+
+  _prune(now) {
+    for (const [id, entry] of this.entries) {
+      if (entry.expiresAt !== null && entry.expiresAt <= now) {
+        this.entries.delete(id);
+      }
+    }
+
+    while (this.entries.size > this.limit) {
+      const oldestInactive = Array.from(this.entries).find(([, entry]) => entry.expiresAt !== null);
+      if (!oldestInactive) break;
+      this.entries.delete(oldestInactive[0]);
+    }
+  }
+}
+
+globalThis.WarmTerminalCache = WarmTerminalCache;
 
 // Z-index base values for layered floating windows
 const ZINDEX_SUBAGENT_BASE = 1000;
diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js
index 4ea11c50..48bd7c01 100644
--- a/src/web/public/i18n.js
+++ b/src/web/public/i18n.js
@@ -68,6 +68,17 @@
     'Open attachment history': '打开附件历史',
     'File Viewer': '文件查看器',
     'Open file viewer': '打开文件查看器',
+    Repository: '代码仓库',
+    'Refresh repository': '刷新代码仓库',
+    'Expand or collapse all directories': '展开或折叠所有目录',
+    'Close file viewer': '关闭文件查看器',
+    'Repository view': '代码仓库视图',
+    'Repository worktree': '代码仓库工作树',
+    Changes: '更改',
+    'Diff view': '差异视图',
+    Compact: '紧凑',
+    Full: '完整',
+    'Close preview': '关闭预览',
     'Open Codeman across all displays': '在所有显示器上打开 {name}',
     'Ultracode / Workflow agents': 'Ultracode / Workflow 智能体',
     'Open ultracode workflow agents': '打开 Ultracode 工作流智能体',
@@ -281,12 +292,16 @@
     Input: '输入',
     'Local Echo': '本地回显',
     'CJK Input': '中日韩输入',
-    'Extended Keyboard Bar': '扩展键盘栏',
+    'Mobile Terminal Controls': '移动终端控制',
+    'Control Haptics': '控制振动',
+    'Control Sounds': '控制音效',
     'Gesture Control (beta)': '手势控制(测试版)',
     'Wheel Scrolls Local History': '滚轮滚动本地历史',
     'Instant typing feedback with local echo': '通过本地回显即时显示输入',
     'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框',
-    'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O',
+    'Esc, menu navigation, Tab, and keyboard-open shortcuts': 'Esc、菜单导航、Tab 和键盘打开时的快捷键',
+    'Brief vibration on terminal controls': '操作终端控件时短暂振动',
+    'Quiet tone on terminal controls': '操作终端控件时播放轻提示音',
 
     // CLI / model settings
     'Startup Mode': '启动模式',
@@ -665,8 +680,10 @@
       '通过覆盖层即时显示输入,同时在后台把按键转发到服务器。支持 Tab 补全、切换标签时保留输入并防止会话崩溃丢字;推荐移动端和高延迟连接使用。',
     "Show a dedicated input field below the terminal for CJK (Chinese/Japanese/Korean) IME composition. Recommended for mobile devices with Chinese input methods where xterm's native input handling may drop characters.":
       '在终端下方显示中日韩输入法专用文本框。推荐在可能因 xterm 原生输入而丢字的移动端中文输入法中使用。',
-    'Show additional buttons (Tab, Shift+Tab, Ctrl+O, Esc, Alt+Enter, left/right arrows) in the mobile keyboard accessory bar.':
-      '在移动端键盘工具栏显示附加按键(Tab、Shift+Tab、Ctrl+O、Esc、Alt+Enter、左右方向键)。',
+    'Show Esc, Up, Enter, Down, and Tab controls while the mobile keyboard is hidden, plus the full terminal key bar while it is open. Hardware volume keys are used when the browser exposes them; press both directions together for Enter.':
+      '移动键盘隐藏时显示 Esc、上、Enter、下和 Tab 控件,键盘打开时显示完整终端按键栏。浏览器支持时可使用硬件音量键;同时按下两个方向键可发送 Enter。',
+    'Vibrate briefly after a mobile terminal control is accepted.': '移动终端控件生效后短暂振动。',
+    'Play a short, quiet tone after a mobile terminal control is accepted.': '移动终端控件生效后播放短促轻柔的提示音。',
     'Scroll local history (when mouse passthrough is active)': '滚动本地历史(鼠标直通启用时)',
     'Plain wheel/trackpad pages the terminal scrollback': '使用普通滚轮/触控板翻阅终端历史',
     'Camera hand-tracking overlay (applied on reload)': '摄像头手势跟踪覆盖层(重新加载后生效)',
diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js
index 550e30b3..f7ea8170 100644
--- a/src/web/public/image-input.js
+++ b/src/web/public/image-input.js
@@ -87,15 +87,12 @@ Object.assign(CodemanApp.prototype, {
         e.preventDefault();
         self._uploadAndInsertImages(imageFiles);
       } else {
-        // No image -- route text through xterm's paste() so bracketed-paste
-        // markers (CSI 200~ ... CSI 201~) survive when the inner application
-        // has enabled bracketed-paste mode (Claude Code does). Sending text
-        // via raw sendInput() strips those markers and makes pasted input
-        // indistinguishable from typed input, weakening the CLI's
-        // prompt-injection defenses.
+        // No image: route through Codeman's session-aware paste boundary.
+        // Replayed browser buffers can miss the CLI's one-time DECSET 2004
+        // enable, so relying on xterm's local mode would omit bracket framing.
         var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
         e.preventDefault();
-        if (text && self.terminal) self.terminal.paste(text);
+        if (text) self.sendPastedText(text);
       }
     });
 
@@ -166,7 +163,7 @@ Object.assign(CodemanApp.prototype, {
     const paths = results.filter(Boolean);
     if (paths.length > 0) {
       // Insert all paths in one shot, space-separated, in selection order.
-      await this.sendInput(paths.join(' '));
+      this.insertTerminalText(paths.join(' '));
     }
 
     // Final status: successes, plus any failures / cap so nothing is silent.
diff --git a/src/web/public/index.html b/src/web/public/index.html
index 224280db..946e0188 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -6,7 +6,7 @@
        served at /session/:id (detached single-session window) without 404ing
        on relative 
   
   
+  
+  
   
   
   
diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js
index 7db12e6b..616118a2 100644
--- a/src/web/public/input-cjk.js
+++ b/src/web/public/input-cjk.js
@@ -43,13 +43,15 @@
  *
  * @dependency index.html (#cjkInput textarea)
  * @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData
- * @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js
+ * @loadorder 5.5 of 16 — loaded after keyboard-accessory.js, before terminal-input-state.js
  */
 
 // eslint-disable-next-line no-unused-vars
 const CjkInput = (() => {
   let _textarea = null;
   let _send = null;
+  let _paste = null;
+  let _draftChanged = null;
   let _initialized = false;
   let _composing = false;
   let _flushTimer = null;
@@ -138,6 +140,7 @@ const CjkInput = (() => {
     }
     _textarea.value = PHANTOM;
     _textarea.setSelectionRange(1, 1);
+    _draftChanged?.();
   }
 
   function _isEffectivelyEmpty() {
@@ -158,7 +161,11 @@ const CjkInput = (() => {
     const val = _strip(_textarea.value);
     _t(`flush ${val ? 'send len=' + val.length : 'empty'}`);
     if (val) {
-      _send(val);
+      if (/[\r\n]/.test(val)) {
+        _paste(val);
+      } else {
+        _send(val);
+      }
     }
     _resetToPhantom();
   }
@@ -192,10 +199,12 @@ const CjkInput = (() => {
   }
 
   return {
-    init({ send }) {
+    init({ send, paste, draftChanged }) {
       if (_initialized) this.destroy();
 
       _send = send;
+      _paste = typeof paste === 'function' ? paste : send;
+      _draftChanged = typeof draftChanged === 'function' ? draftChanged : null;
       _composing = false;
       _flushTimer = null;
       _textarea = document.getElementById('cjkInput');
@@ -249,6 +258,23 @@ const CjkInput = (() => {
       _textarea.addEventListener('focus', _listeners.focus);
       _textarea.addEventListener('blur', _listeners.blur);
 
+      // Clipboard text is a single semantic paste, even when it contains blank
+      // lines. Route it separately so the app can add bracketed-paste framing
+      // instead of flushing each newline as a real Enter key.
+      _listeners.paste = (e) => {
+        const text = e.clipboardData?.getData?.('text/plain') || '';
+        if (!text) return;
+        e.preventDefault();
+        _t(`paste len=${text.length}`);
+        _composing = false;
+        _cancelDebouncedFlush();
+        clearTimeout(_compositionFlushTimer);
+        _compositionFlushTimer = null;
+        _paste(text);
+        _resetToPhantom();
+      };
+      _textarea.addEventListener('paste', _listeners.paste);
+
       // ── Composition tracking (keyboard IME — works for CJK typing) ──
       _listeners.compositionstart = () => {
         _t(`compstart ${_vdesc(_textarea.value)}`);
@@ -283,7 +309,12 @@ const CjkInput = (() => {
           _cancelDebouncedFlush();
           const val = _strip(_textarea.value);
           if (val) {
-            _send(val + '\r');
+            if (/[\r\n]/.test(val)) {
+              _paste(val);
+              _send('\r');
+            } else {
+              _send(val + '\r');
+            }
           } else {
             _send('\r');
           }
@@ -342,6 +373,10 @@ const CjkInput = (() => {
 
       // ── Input event: primary path for virtual keyboards + dictation ──
       _listeners.input = (e) => {
+        // The DOM value has already changed by the time this event runs. Capture
+        // it before an immediate flush/reset so a lifecycle event in this turn
+        // cannot lose an unfinished IME or dictation update.
+        _draftChanged?.();
         _t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
         // ── Stuck-composition recovery ──
         // Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
@@ -359,6 +394,17 @@ const CjkInput = (() => {
           _composing = false;
         }
 
+        // Some Android clipboard surfaces emit no ClipboardEvent. They insert
+        // the complete value and report only inputType=insertFromPaste.
+        if (e.inputType === 'insertFromPaste') {
+          _cancelDebouncedFlush();
+          const text = _strip(_textarea.value);
+          _t(`input-paste len=${text.length}`);
+          if (text) _paste(text);
+          _resetToPhantom();
+          return;
+        }
+
         // ── Backspace / delete detection ──
         if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') {
           if (_composing) return;
@@ -428,6 +474,31 @@ const CjkInput = (() => {
       _resetToPhantom();
     },
 
+    /** Real editable text currently held behind the phantom character. */
+    getPendingText() {
+      return _initialized && _textarea ? _strip(_textarea.value) : '';
+    },
+
+    /**
+     * Restore committed draft text without trying to recreate an OS-level IME
+     * composition. The next input/Enter event treats it as ordinary text.
+     */
+    restorePendingText(text) {
+      if (!_initialized || !_textarea) return;
+      _cancelDebouncedFlush();
+      clearTimeout(_compositionFlushTimer);
+      _compositionFlushTimer = null;
+      _composing = false;
+      const restored = String(text || '');
+      if (!restored) {
+        _resetToPhantom();
+        return;
+      }
+      _textarea.value = PHANTOM + restored;
+      const end = _textarea.value.length;
+      _textarea.setSelectionRange(end, end);
+    },
+
     /** Diagnostic: recent IME event trace (ring buffer). */
     getTrace() {
       return _trace.slice();
@@ -446,6 +517,9 @@ const CjkInput = (() => {
       }
       window.cjkActive = false;
       _composing = false;
+      _send = null;
+      _paste = null;
+      _draftChanged = null;
       for (const key of Object.keys(_listeners)) delete _listeners[key];
       _initialized = false;
     },
diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js
index 68e08fd2..3d2b71ed 100644
--- a/src/web/public/keyboard-accessory.js
+++ b/src/web/public/keyboard-accessory.js
@@ -1,10 +1,14 @@
 /**
- * @fileoverview Mobile keyboard accessory bar and modal focus trap.
+ * @fileoverview Mobile keyboard controls and modal focus trap.
  *
- * Defines three exports:
+ * Defines five exports:
+ *
+ * - MobileTerminalControls (singleton object) — Product-level facade that owns
+ *   initialization, enablement, modal visibility, and shared key mappings for
+ *   both responsive control surfaces.
  *
  * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
- *   keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
+ *   keyboard on mobile: arrows, Tab, Esc, commands, paste, and dismiss.
  *   The paste button opens a dialog that handles both text paste and image attach
  *   (native picker + best-effort image paste, routed through app._uploadAndInsertImages).
  *   Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
@@ -13,12 +17,19 @@
  * - PathPicker (singleton object) — Lazy server-side file/folder browser shared
  *   by Link Existing and the extended mobile keyboard bar.
  *
+ * - MobileNavigationPad (singleton object) — Keyboard-hidden Esc/Up/Enter/Down/Tab controls
+ *   for terminal menus plus a contextual jump-to-latest action. A simultaneous Up+Down
+ *   press emits Enter, and vertical swipes on the surrounding bar emit arrow keys without
+ *   focusing xterm or opening the keyboard.
+ *
  * - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
  *   Saves and restores previously focused element on deactivate. Used by Ralph wizard
  *   and other modal dialogs.
  *
  * @globals {object} KeyboardAccessoryBar
  * @globals {object} PathPicker
+ * @globals {object} MobileNavigationPad
+ * @globals {object} MobileTerminalControls
  * @globals {class} FocusTrap
  *
  * @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
@@ -29,6 +40,31 @@
 // Codeman — Keyboard accessory bar and focus trap for modals
 // Loaded after mobile-handlers.js, before app.js
 
+const TERMINAL_CONTROL_SEQUENCES = Object.freeze({
+  up: '\x1b[A',
+  down: '\x1b[B',
+  left: '\x1b[D',
+  right: '\x1b[C',
+  enter: '\r',
+  esc: '\x1b',
+  tab: '\t',
+  shiftTab: '\x1b[Z',
+  ctrlO: '\x0f',
+  optionEnter: '\x1b\r',
+});
+
+const TERMINAL_ACCESSORY_KEY_ACTIONS = Object.freeze({
+  'scroll-up': 'up',
+  'scroll-down': 'down',
+  'arrow-left': 'left',
+  'arrow-right': 'right',
+  esc: 'esc',
+  'opt-enter': 'optionEnter',
+  tab: 'tab',
+  'shift-tab': 'shiftTab',
+  'ctrl-o': 'ctrlO',
+});
+
 // ═══════════════════════════════════════════════════════════════
 // Shared Filesystem Path Picker
 // ═══════════════════════════════════════════════════════════════
@@ -370,57 +406,34 @@ const PathPicker = {
  */
 const KeyboardAccessoryBar = {
   element: null,
-  _mode: 'simple', // 'simple' or 'extended'
+  enabled: false,
 
-  /** HTML for simple mode: arrows, commands, paste, Esc, dismiss */
-  _simpleButtons: `
-      
-      
+      
-      
-      
-      
-      
-      `,
-
-  /** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */
-  _extendedButtons: `
       
+      
       
-      
       
+      
+      
       
       
       
-      
-      
       
       
-      
-      
       
       
       
@@ -447,12 +456,12 @@ const KeyboardAccessoryBar = {
   /** Create and inject the accessory bar */
   init() {
     // Only on mobile
-    if (!MobileDetection.isTouchDevice()) return;
+    if (!MobileDetection.isTouchDevice() || this.element) return;
 
     // Create accessory bar element
     this.element = document.createElement('div');
     this.element.className = 'keyboard-accessory-bar';
-    this.element.innerHTML = this._simpleButtons;
+    this.element.innerHTML = this._buttons;
 
     // Add click handlers — preventDefault stops event from reaching terminal
     this.element.addEventListener('click', (e) => {
@@ -481,12 +490,25 @@ const KeyboardAccessoryBar = {
     }
   },
 
-  /** Switch between 'simple' and 'extended' button layouts */
-  setMode(mode) {
-    if (mode === this._mode || !this.element) return;
-    this._mode = mode;
-    this.clearConfirm();
-    this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons;
+  /** Enable or disable the keyboard-open surface of mobile terminal controls. */
+  setEnabled(enabled) {
+    this.enabled = enabled === true;
+    this.syncVisibility();
+  },
+
+  /** Match the accessory surface to keyboard and modal state. */
+  syncVisibility() {
+    if (!this.element) return;
+    const keyboardVisible =
+      (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) ||
+      document.body.classList.contains('keyboard-visible');
+    const hasSession =
+      typeof app !== 'undefined' && Boolean(app.activeSessionId) && !app.activeWebviewId;
+    const hasOpenDialog = MobileTerminalControls.hasOpenDialog();
+    const visible = this.enabled && hasSession && keyboardVisible && !hasOpenDialog;
+    this.element.classList.toggle('visible', visible);
+    document.body.classList.toggle('keyboard-accessory-visible', visible);
+    if (!visible) this.clearConfirm();
   },
 
   _confirmTimer: null,
@@ -495,35 +517,13 @@ const KeyboardAccessoryBar = {
   /** Handle accessory button actions */
   handleAction(action, btn) {
     if (typeof app === 'undefined' || !app.activeSessionId) return;
+    const terminalKey = TERMINAL_ACCESSORY_KEY_ACTIONS[action];
+    if (terminalKey) {
+      MobileTerminalControls.sendKey(terminalKey);
+      return;
+    }
 
     switch (action) {
-      case 'scroll-up':
-        this.sendKey('\x1b[A');
-        break;
-      case 'scroll-down':
-        this.sendKey('\x1b[B');
-        break;
-      case 'arrow-left':
-        this.sendKey('\x1b[D');
-        break;
-      case 'arrow-right':
-        this.sendKey('\x1b[C');
-        break;
-      case 'esc':
-        this.sendKey('\x1b');
-        break;
-      case 'opt-enter':
-        this.sendKey('\x1b\r');
-        break;
-      case 'tab':
-        this.sendKey('\t');
-        break;
-      case 'shift-tab':
-        this.sendKey('\x1b[Z');
-        break;
-      case 'ctrl-o':
-        this.sendKey('\x0f');
-        break;
       case 'effort-max':
         this.sendCommand('/effort max');
         break;
@@ -554,7 +554,10 @@ const KeyboardAccessoryBar = {
         // Blur active element to dismiss keyboard
         document.activeElement?.blur();
         break;
+      default:
+        return;
     }
+    MobileTerminalControls.feedback(action);
   },
 
   /** Enter confirm state: button turns amber for 2s waiting for second tap */
@@ -590,22 +593,7 @@ const KeyboardAccessoryBar = {
    *  Sends text and Enter separately so Ink processes them as distinct events. */
   sendCommand(command) {
     if (!app.activeSessionId) return;
-    // Send command text first (without Enter)
-    app.sendInput(command);
-    // Send Enter separately after a brief delay so Ink has time to process the text.
-    setTimeout(() => app.sendInput('\r'), 120);
-  },
-
-  /** Send a special key (arrow, escape, etc.) directly to the PTY.
-   *  Bypasses tmux send-keys -l (literal mode) since escape sequences
-   *  must be written raw to be interpreted as key presses by Ink. */
-  sendKey(escapeSequence) {
-    if (!app.activeSessionId) return;
-    fetch(`/api/sessions/${app.activeSessionId}/input`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ input: escapeSequence })
-    }).catch(() => {});
+    app.sendTerminalCommand(command);
   },
 
   /** Browse the active session's workspace and insert a selected path without Enter. */
@@ -662,8 +650,7 @@ const KeyboardAccessoryBar = {
       const text = textarea.value;
       close();
       if (text) {
-        app.sendInput(text);
-        setTimeout(() => app.sendInput('\r'), 80);
+        app.sendPastedText(text, { submit: true });
       }
     };
 
@@ -706,19 +693,573 @@ const KeyboardAccessoryBar = {
     textarea.focus();
   },
 
-  /** Show the accessory bar */
-  show() {
-    if (this.element) {
-      this.element.classList.add('visible');
+};
+
+// ═══════════════════════════════════════════════════════════════
+// Keyboard-hidden terminal navigation
+// ═══════════════════════════════════════════════════════════════
+
+/**
+ * MobileNavigationPad - Persistent terminal-menu controls for phone layouts.
+ *
+ * Arrow actions are emitted on pointer release so a second simultaneous pointer
+ * can turn an Up+Down pair into Enter without leaking the first arrow.
+ */
+const MobileNavigationPad = {
+  element: null,
+  enabled: false,
+  _pointers: new Map(),
+  _consumedPointers: new Set(),
+  _chordActive: false,
+  _volumeKeys: new Map(),
+  _volumeChordActive: false,
+  _swipeDistance: 36,
+  _swipeTime: 600,
+
+  _buttons: `
+    
+    
+    
+    
+    
+    `,
+
+  init(enabled = false) {
+    if (!MobileDetection.isTouchDevice() || this.element) return;
+
+    this.element = document.createElement('div');
+    this.element.className = 'mobile-terminal-nav';
+    this.element.setAttribute('role', 'group');
+    this.element.setAttribute('aria-label', 'Terminal menu navigation');
+    this.element.setAttribute('aria-hidden', 'true');
+    this.element.innerHTML = this._buttons;
+
+    this._pointerDownHandler = (e) => this.onPointerDown(e);
+    this._pointerUpHandler = (e) => this.onPointerUp(e);
+    this._pointerCancelHandler = (e) => this.onPointerCancel(e);
+    this._clickHandler = (e) => this.onClick(e);
+    this._volumeKeyDownHandler = (e) => this.onVolumeKeyDown(e);
+    this._volumeKeyUpHandler = (e) => this.onVolumeKeyUp(e);
+
+    this.element.addEventListener('pointerdown', this._pointerDownHandler);
+    this.element.addEventListener('pointerup', this._pointerUpHandler);
+    this.element.addEventListener('pointercancel', this._pointerCancelHandler);
+    this.element.addEventListener('lostpointercapture', this._pointerCancelHandler);
+    this.element.addEventListener('click', this._clickHandler);
+    document.addEventListener('keydown', this._volumeKeyDownHandler, true);
+    document.addEventListener('keyup', this._volumeKeyUpHandler, true);
+    document.body.appendChild(this.element);
+
+    this.setEnabled(enabled);
+  },
+
+  setEnabled(enabled) {
+    this.enabled = enabled === true;
+    this.syncVisibility();
+  },
+
+  syncVisibility() {
+    if (!this.element) return;
+
+    const keyboardVisible =
+      (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) ||
+      document.body.classList.contains('keyboard-visible');
+    const isPhoneLayout =
+      typeof MobileDetection !== 'undefined' &&
+      MobileDetection.isTouchDevice() &&
+      MobileDetection.getDeviceType() !== 'desktop';
+    const hasSession =
+      typeof app !== 'undefined' && Boolean(app.activeSessionId) && !app.activeWebviewId;
+    const hasOpenDialog = MobileTerminalControls.hasOpenDialog();
+    const visible =
+      this.enabled && isPhoneLayout && hasSession && !keyboardVisible && !hasOpenDialog;
+
+    this.element.classList.toggle('visible', visible);
+    this.element.setAttribute('aria-hidden', visible ? 'false' : 'true');
+    for (const button of this.element.querySelectorAll('button')) {
+      button.disabled = !visible;
+    }
+    this.syncJumpVisibility(visible);
+    document.body.classList.toggle('mobile-nav-visible', visible);
+
+    if (!visible) {
+      this.resetPointers();
+      this.resetVolumeKeys();
+    }
+  },
+
+  syncJumpVisibility(navigationVisible = this.element?.classList.contains('visible')) {
+    const button = this.element?.querySelector('[data-nav-key="jump-bottom"]');
+    if (!(button instanceof HTMLButtonElement)) return;
+
+    const hasSession =
+      typeof app !== 'undefined' && Boolean(app.activeSessionId) && !app.activeWebviewId;
+    const readingHistory =
+      typeof app !== 'undefined' && typeof app.isTerminalReadingHistory === 'function'
+        ? app.isTerminalReadingHistory()
+        : typeof app !== 'undefined' && typeof app.isTerminalAtBottom === 'function'
+          ? !app.isTerminalAtBottom()
+          : false;
+    const visible = Boolean(navigationVisible && hasSession && readingHistory);
+    button.hidden = !visible;
+    button.disabled = !visible;
+    button.setAttribute('aria-hidden', visible ? 'false' : 'true');
+    this.element.classList.toggle('jump-visible', visible);
+  },
+
+  blurTerminalInput() {
+    const active = document.activeElement;
+    const terminalTextarea =
+      typeof app !== 'undefined' && app.terminal ? app.terminal.textarea : null;
+    const isTerminalInput =
+      active === terminalTextarea ||
+      active?.classList?.contains('xterm-helper-textarea') ||
+      active?.id === 'cjkInput';
+    if (isTerminalInput) active.blur();
+  },
+
+  onPointerDown(e) {
+    if (!this.element?.classList.contains('visible')) return;
+
+    const button = e.target.closest?.('[data-nav-key]');
+    const key = button?.dataset.navKey || 'swipe';
+    // Android can leave xterm's hidden textarea focused after the user dismisses
+    // the keyboard. Release it inside the trusted touch gesture before sending
+    // terminal input, otherwise the same gesture may reopen the keyboard.
+    this.blurTerminalInput();
+    e.preventDefault();
+    e.stopPropagation();
+
+    this._pointers.set(e.pointerId, {
+      key,
+      button: button || null,
+      startY: e.clientY,
+      startedAt: Date.now(),
+    });
+    button?.classList.add('pressed');
+
+    try {
+      (button || this.element).setPointerCapture?.(e.pointerId);
+    } catch {
+      // Synthetic test events are not registered as active OS pointers.
+    }
+
+    if ((key === 'up' || key === 'down') && !this._chordActive) {
+      const opposite = key === 'up' ? 'down' : 'up';
+      const oppositeEntry = [...this._pointers.entries()].find(
+        ([pointerId, state]) => pointerId !== e.pointerId && state.key === opposite
+      );
+      if (oppositeEntry) {
+        this._consumedPointers.add(e.pointerId);
+        this._consumedPointers.add(oppositeEntry[0]);
+        this._chordActive = true;
+        this.element.classList.add('chord-active');
+        this.sendKey('enter');
+      }
     }
   },
 
-  /** Hide the accessory bar */
-  hide() {
-    if (this.element) {
-      this.element.classList.remove('visible');
+  onPointerUp(e) {
+    const state = this._pointers.get(e.pointerId);
+    if (!state) return;
+
+    e.preventDefault();
+    e.stopPropagation();
+    const consumed = this._consumedPointers.has(e.pointerId);
+
+    if (!consumed) {
+      if (state.key === 'swipe') {
+        const elapsed = Date.now() - state.startedAt;
+        const deltaY = e.clientY - state.startY;
+        if (elapsed <= this._swipeTime && Math.abs(deltaY) >= this._swipeDistance) {
+          this.sendKey(deltaY < 0 ? 'up' : 'down');
+        }
+      } else {
+        this.sendKey(state.key);
+      }
     }
-  }
+
+    this.releasePointer(e.pointerId, state);
+  },
+
+  onPointerCancel(e) {
+    const state = this._pointers.get(e.pointerId);
+    if (!state) return;
+    this.releasePointer(e.pointerId, state);
+  },
+
+  releasePointer(pointerId, state) {
+    this._pointers.delete(pointerId);
+    this._consumedPointers.delete(pointerId);
+
+    if (
+      state.button &&
+      ![...this._pointers.values()].some((pointer) => pointer.button === state.button)
+    ) {
+      state.button.classList.remove('pressed');
+    }
+
+    const pointerDirectionHeld = [...this._pointers.values()].some(
+      (pointer) => pointer.key === 'up' || pointer.key === 'down'
+    );
+    if (!pointerDirectionHeld) {
+      this._chordActive = false;
+      this.syncChordVisual();
+    }
+  },
+
+  onClick(e) {
+    const button = e.target.closest?.('[data-nav-key]');
+    if (!button) return;
+
+    this.blurTerminalInput();
+    e.preventDefault();
+    e.stopPropagation();
+    // Pointer input is handled above. detail=0 preserves keyboard/switch-control activation.
+    if (e.detail === 0 && this.element?.classList.contains('visible')) {
+      this.sendKey(button.dataset.navKey);
+    }
+  },
+
+  volumeDirection(e) {
+    const values = [e.key, e.code];
+    if (values.includes('AudioVolumeUp') || values.includes('VolumeUp')) return 'up';
+    if (values.includes('AudioVolumeDown') || values.includes('VolumeDown')) return 'down';
+    return null;
+  },
+
+  onVolumeKeyDown(e) {
+    const direction = this.volumeDirection(e);
+    if (!direction || !this.element?.classList.contains('visible')) return;
+
+    this.blurTerminalInput();
+    e.preventDefault();
+    e.stopPropagation();
+    if (e.repeat) return;
+
+    // Recover from a missing keyup before recording a fresh physical press.
+    if (this._volumeKeys.has(direction)) {
+      this.releaseVolumeKey(direction);
+    }
+
+    const state = { consumed: false };
+    this._volumeKeys.set(direction, state);
+    this.syncDirectionPressed(direction);
+
+    const opposite = direction === 'up' ? 'down' : 'up';
+    const oppositeState = this._volumeKeys.get(opposite);
+    if (oppositeState && !this._volumeChordActive) {
+      state.consumed = true;
+      oppositeState.consumed = true;
+      this._volumeChordActive = true;
+      this.syncChordVisual();
+      this.sendKey('enter');
+    }
+  },
+
+  onVolumeKeyUp(e) {
+    const direction = this.volumeDirection(e);
+    const state = direction && this._volumeKeys.get(direction);
+    if (!state) return;
+
+    e.preventDefault();
+    e.stopPropagation();
+    if (!state.consumed && this.element?.classList.contains('visible')) {
+      this.sendKey(direction);
+    }
+    this.releaseVolumeKey(direction);
+  },
+
+  releaseVolumeKey(direction) {
+    this._volumeKeys.delete(direction);
+    this.syncDirectionPressed(direction);
+
+    if (this._volumeKeys.size === 0) {
+      this._volumeChordActive = false;
+      this.syncChordVisual();
+    }
+  },
+
+  syncDirectionPressed(direction) {
+    const button = this.element?.querySelector(`[data-nav-key="${direction}"]`);
+    if (!button) return;
+
+    const pointerHeld = [...this._pointers.values()].some(
+      (pointer) => pointer.key === direction
+    );
+    button.classList.toggle('pressed', pointerHeld || this._volumeKeys.has(direction));
+  },
+
+  syncChordVisual() {
+    this.element?.classList.toggle(
+      'chord-active',
+      this._chordActive || this._volumeChordActive
+    );
+  },
+
+  sendKey(key) {
+    if (key === 'jump-bottom') {
+      if (typeof app === 'undefined' || !app.activeSessionId) return;
+      app.jumpTerminalToLatest?.();
+      MobileTerminalControls.feedback(key);
+      this.syncJumpVisibility();
+      return;
+    }
+    MobileTerminalControls.sendKey(key);
+  },
+
+  resetPointers() {
+    this._pointers.clear();
+    this._consumedPointers.clear();
+    this._chordActive = false;
+    this.element?.classList.remove('chord-active');
+    for (const button of this.element?.querySelectorAll('.pressed') || []) {
+      button.classList.remove('pressed');
+    }
+  },
+
+  resetVolumeKeys() {
+    this._volumeKeys.clear();
+    this._volumeChordActive = false;
+    this.syncChordVisual();
+    for (const direction of ['up', 'down']) {
+      this.syncDirectionPressed(direction);
+    }
+  },
+
+  cleanup() {
+    if (!this.element) return;
+    this.resetPointers();
+    this.resetVolumeKeys();
+    this.element.removeEventListener('pointerdown', this._pointerDownHandler);
+    this.element.removeEventListener('pointerup', this._pointerUpHandler);
+    this.element.removeEventListener('pointercancel', this._pointerCancelHandler);
+    this.element.removeEventListener('lostpointercapture', this._pointerCancelHandler);
+    this.element.removeEventListener('click', this._clickHandler);
+    document.removeEventListener('keydown', this._volumeKeyDownHandler, true);
+    document.removeEventListener('keyup', this._volumeKeyUpHandler, true);
+    this.element.remove();
+    this.element = null;
+    document.body.classList.remove('mobile-nav-visible');
+  },
+};
+
+// ═══════════════════════════════════════════════════════════════
+// Unified mobile terminal controls
+// ═══════════════════════════════════════════════════════════════
+
+/**
+ * MobileTerminalControls - Product-level owner for the two responsive surfaces.
+ */
+const MobileTerminalControls = {
+  enabled: false,
+  hapticsEnabled: true,
+  soundEnabled: false,
+  _audioContext: null,
+  _modalObserver: null,
+
+  /**
+   * Resolve the canonical per-device setting while preserving both shipped
+   * legacy formats. The old extendedKeyboardBar=false selected a smaller bar;
+   * it never disabled mobile controls. Only explicit canonical or legacy
+   * enablement turns the consolidated controls on.
+   */
+  resolveEnabled(settings = {}, defaults = {}, isTouchDevice = null) {
+    if (typeof settings?.mobileTerminalControlsEnabled === 'boolean') {
+      return settings.mobileTerminalControlsEnabled;
+    }
+    if (typeof settings?.mobileNavigationPadEnabled === 'boolean') {
+      return settings.mobileNavigationPadEnabled;
+    }
+    if (typeof defaults?.mobileTerminalControlsEnabled === 'boolean') {
+      return defaults.mobileTerminalControlsEnabled;
+    }
+    if (settings?.extendedKeyboardBar === true || defaults?.extendedKeyboardBar === true) {
+      return true;
+    }
+    return false;
+  },
+
+  init(enabled = false) {
+    KeyboardAccessoryBar.init();
+    MobileNavigationPad.init(false);
+    this._installModalObserver();
+    this.setEnabled(enabled);
+  },
+
+  configureFeedback(settings = {}, defaults = {}) {
+    this.hapticsEnabled =
+      settings.mobileControlHaptics ?? defaults.mobileControlHaptics ?? true;
+    this.soundEnabled =
+      settings.mobileControlSound ?? defaults.mobileControlSound ?? false;
+  },
+
+  setEnabled(enabled) {
+    this.enabled = enabled === true;
+    KeyboardAccessoryBar.setEnabled(this.enabled);
+    MobileNavigationPad.setEnabled(this.enabled);
+  },
+
+  syncVisibility() {
+    KeyboardAccessoryBar.syncVisibility();
+    MobileNavigationPad.syncVisibility();
+  },
+
+  blurTerminalInput() {
+    MobileNavigationPad.blurTerminalInput();
+  },
+
+  sendKey(action) {
+    const sequence = TERMINAL_CONTROL_SEQUENCES[action];
+    if (
+      !sequence ||
+      typeof app === 'undefined' ||
+      !app.activeSessionId ||
+      app.activeWebviewId
+    ) {
+      return;
+    }
+    app.sendTerminalKey(sequence);
+    this.feedback(action);
+  },
+
+  feedback(action) {
+    if (
+      this.hapticsEnabled &&
+      typeof navigator !== 'undefined' &&
+      typeof navigator.vibrate === 'function'
+    ) {
+      try {
+        navigator.vibrate(action === 'enter' ? 18 : 10);
+      } catch {
+        // Vibration is optional and may be blocked by browser policy.
+      }
+    }
+    if (!this.soundEnabled || typeof window === 'undefined') return;
+
+    const AudioContext = window.AudioContext || window.webkitAudioContext;
+    if (!AudioContext) return;
+    try {
+      const context = this._audioContext || new AudioContext();
+      this._audioContext = context;
+      const play = () => {
+        const oscillator = context.createOscillator();
+        const gain = context.createGain();
+        const now = context.currentTime;
+        const frequencies = { up: 620, down: 440, enter: 760, esc: 320, tab: 540 };
+        oscillator.type = 'sine';
+        oscillator.frequency.setValueAtTime(frequencies[action] || 500, now);
+        gain.gain.setValueAtTime(0.025, now);
+        gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.035);
+        oscillator.connect(gain);
+        gain.connect(context.destination);
+        oscillator.start(now);
+        oscillator.stop(now + 0.04);
+      };
+      if (context.state === 'suspended') {
+        context.resume().then(play).catch(() => {});
+      } else {
+        play();
+      }
+    } catch {
+      // Audio feedback is best-effort on browsers without a usable Web Audio context.
+    }
+  },
+
+  /** True when a pointer target will perform its own semantic key claim. */
+  isKeyControlTarget(target) {
+    if (!target?.closest) return false;
+    if (target.closest('.mobile-terminal-nav')) return true;
+    const accessoryButton = target.closest('.keyboard-accessory-bar [data-action]');
+    return Boolean(TERMINAL_ACCESSORY_KEY_ACTIONS[accessoryButton?.dataset.action]);
+  },
+
+  /** Match every modal convention currently used by Codeman's UI modules. */
+  hasOpenDialog() {
+    return [...document.querySelectorAll('.modal')].some((modal) => {
+      if (
+        modal.classList.contains('active') ||
+        modal.classList.contains('show') ||
+        modal.hasAttribute('open')
+      ) {
+        return true;
+      }
+      return Boolean(modal.style.display && modal.style.display !== 'none');
+    });
+  },
+
+  _installModalObserver() {
+    if (
+      this._modalObserver ||
+      typeof MobileDetection === 'undefined' ||
+      !MobileDetection.isTouchDevice()
+    ) {
+      return;
+    }
+    this._modalObserver = new MutationObserver((mutations) => {
+      if (
+        mutations.some(
+          (mutation) =>
+            mutation.target.classList?.contains('modal') ||
+            (mutation.type === 'childList' &&
+              [...mutation.addedNodes, ...mutation.removedNodes].some(
+                (node) =>
+                  node.nodeType === Node.ELEMENT_NODE &&
+                  (node.matches?.('.modal') || node.querySelector?.('.modal'))
+              ))
+        )
+      ) {
+        this.syncVisibility();
+      }
+    });
+    this._modalObserver.observe(document.body, {
+      subtree: true,
+      childList: true,
+      attributes: true,
+      attributeFilter: ['class', 'style', 'open'],
+    });
+  },
+
+  cleanup() {
+    this._modalObserver?.disconnect();
+    this._modalObserver = null;
+    MobileNavigationPad.cleanup();
+    KeyboardAccessoryBar.clearConfirm();
+    KeyboardAccessoryBar.element?.remove();
+    KeyboardAccessoryBar.element = null;
+    KeyboardAccessoryBar.enabled = false;
+    document.body.classList.remove('keyboard-accessory-visible');
+    this._audioContext?.close?.().catch?.(() => {});
+    this._audioContext = null;
+    this.enabled = false;
+  },
 };
 
 // ═══════════════════════════════════════════════════════════════
diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js
index 5b8b99a5..b1d527dd 100644
--- a/src/web/public/mobile-handlers.js
+++ b/src/web/public/mobile-handlers.js
@@ -18,7 +18,7 @@
  * @globals {object} KeyboardHandler
  * @globals {object} SwipeHandler
  *
- * @dependency keyboard-accessory.js (KeyboardAccessoryBar reference in KeyboardHandler.onKeyboardShow, soft — guarded with typeof check)
+ * @dependency keyboard-accessory.js (MobileTerminalControls references in KeyboardHandler, soft — guarded with typeof checks)
  * @loadorder 2 of 15 — loaded after constants.js, before voice-input.js
  */
 
@@ -115,6 +115,7 @@ const MobileDetection = {
       'device-tablet',
       'device-desktop',
       'touch-device',
+      'handheld-device',
       'ios-device',
       'safari-browser'
     );
@@ -127,6 +128,10 @@ const MobileDetection = {
       body.classList.add('touch-device');
     }
 
+    if (this.isHandheldDevice()) {
+      body.classList.add('handheld-device');
+    }
+
     // Add iOS-specific class for safe area handling
     if (this.isIOS()) {
       body.classList.add('ios-device');
@@ -171,6 +176,9 @@ const MobileDetection = {
         // Tab auto-wrap is width-driven, so it must re-evaluate on resize — the only
         // other trigger is a tab content render. No-op on mobile/tablet (method bails).
         if (typeof app !== 'undefined') app.updateTabOverflowMode?.();
+        if (typeof MobileTerminalControls !== 'undefined') {
+          MobileTerminalControls.syncVisibility();
+        }
       }, 100);
     };
     window.addEventListener('resize', this._resizeHandler);
@@ -209,9 +217,35 @@ const MobileDetection = {
  * Also handles terminal scrolling and toolbar repositioning via visualViewport API.
  */
 const KeyboardHandler = {
+  VIEWPORT_SETTLE_MS: 80,
+  FRAME_COVER_MIN_MS: 220,
+  FRAME_COVER_MAX_MS: 1600,
+  FRAME_COVER_LOAD_POLL_MS: 100,
+  FRAME_COVER_CODEX_QUIET_MS: CODEX_POST_SWITCH_QUIET_MS,
+  KEYBOARD_OPEN_INTENT_MS: 1200,
+  KEYBOARD_CLOSE_START_DELTA_PX: 40,
   lastViewportHeight: 0,
   keyboardVisible: false,
   initialViewportHeight: 0,
+  initialViewportWidth: 0,
+  _keyboardOpenMinHeight: 0,
+  _keyboardClosing: false,
+  _keyboardOpeningTimer: null,
+  _viewportSettleTimer: null,
+  _settleScrollToBottom: false,
+  _settleFocusInput: false,
+  _terminalInputRequested: false,
+  _terminalFrameCover: null,
+  _terminalFrameCoverStartedAt: 0,
+  _terminalFrameCoverArmed: false,
+  _terminalFrameCoverReady: false,
+  _terminalFrameCoverReadyAt: 0,
+  _terminalFrameCoverReadyVersion: 0,
+  _terminalFrameCoverSwapVersion: 0,
+  _terminalFrameCoverAuthoritative: false,
+  _terminalFrameCoverMinTimer: null,
+  _terminalFrameCoverMaxTimer: null,
+  _terminalFrameCoverQuietTimer: null,
 
   /** Initialize keyboard handling */
   init() {
@@ -219,11 +253,23 @@ const KeyboardHandler = {
     if (!MobileDetection.isTouchDevice()) return;
 
     this.initialViewportHeight = window.visualViewport?.height || window.innerHeight;
+    this.initialViewportWidth = window.visualViewport?.width || window.innerWidth;
     this.lastViewportHeight = this.initialViewportHeight;
+    this._keyboardOpenMinHeight = 0;
+    this._keyboardClosing = false;
 
     // Simple focus handler - scroll input into view after keyboard appears
     this._focusinHandler = (e) => {
       const target = e.target;
+      this._terminalInputRequested = this.isTerminalInputElement(target);
+      if (this._terminalInputRequested) {
+        if (!this.keyboardVisible) {
+          this._markKeyboardOpening();
+          this._beginTerminalFrameCover();
+        }
+        return;
+      }
+      this._clearKeyboardOpeningIntent();
       if (!this.isInputElement(target)) return;
 
       // Wait for keyboard animation, then scroll input into view
@@ -240,6 +286,7 @@ const KeyboardHandler = {
       };
       this._viewportScrollHandler = () => {
         this.updateLayoutForKeyboard();
+        if (this._terminalInputRequested) window.scrollTo(0, 0);
       };
       window.visualViewport.addEventListener('resize', this._viewportResizeHandler);
       // Also handle scroll (iOS scrolls viewport when keyboard appears)
@@ -251,7 +298,7 @@ const KeyboardHandler = {
     // view when the user types, pushing the entire UI off-screen. The CSS
     // position:fixed on .app prevents most cases, but reset as a safety net.
     this._windowScrollHandler = () => {
-      if (this.keyboardVisible) {
+      if (this.keyboardVisible || document.body.classList.contains('keyboard-opening')) {
         window.scrollTo(0, 0);
       }
     };
@@ -276,17 +323,79 @@ const KeyboardHandler = {
       window.removeEventListener('scroll', this._windowScrollHandler);
       this._windowScrollHandler = null;
     }
+    if (this._viewportSettleTimer) {
+      clearTimeout(this._viewportSettleTimer);
+      this._viewportSettleTimer = null;
+    }
+    this._clearKeyboardOpeningIntent();
+    this._settleScrollToBottom = false;
+    this._settleFocusInput = false;
+    this._terminalInputRequested = false;
+    this._keyboardOpenMinHeight = 0;
+    this._keyboardClosing = false;
+    this._discardTerminalFrameCover();
+  },
+
+  /**
+   * Pin the app before the first visualViewport resize. Mobile browsers may
+   * scroll the layout viewport as soon as focus moves to xterm's textarea,
+   * several frames before the keyboard crosses the visibility threshold.
+   */
+  _markKeyboardOpening() {
+    document.body.classList.add('keyboard-opening');
+    window.scrollTo(0, 0);
+    if (this._keyboardOpeningTimer) clearTimeout(this._keyboardOpeningTimer);
+    this._keyboardOpeningTimer = setTimeout(() => {
+      this._keyboardOpeningTimer = null;
+      if (!this.keyboardVisible) document.body.classList.remove('keyboard-opening');
+    }, this.KEYBOARD_OPEN_INTENT_MS);
+  },
+
+  _clearKeyboardOpeningIntent() {
+    if (this._keyboardOpeningTimer) clearTimeout(this._keyboardOpeningTimer);
+    this._keyboardOpeningTimer = null;
+    document.body.classList.remove('keyboard-opening');
   },
 
   /** Handle viewport resize (keyboard show/hide) */
   handleViewportResize() {
     const currentHeight = window.visualViewport?.height || window.innerHeight;
+    const currentWidth = window.visualViewport?.width || window.innerWidth;
+
+    // Keyboard animations often arrive as several sub-threshold resize steps.
+    // Keep the full-height baseline anchored instead of ratcheting it downward.
+    // A substantial width change indicates a rotation or posture change, where
+    // the previous orientation's height is no longer a useful baseline.
+    if (Math.abs(this.initialViewportWidth - currentWidth) > 80) {
+      this.initialViewportHeight = Math.max(currentHeight, window.innerHeight);
+      this.initialViewportWidth = currentWidth;
+    }
     const heightDiff = this.initialViewportHeight - currentHeight;
 
+    // A soft keyboard closes through several growing visualViewport frames.
+    // Start covering at the first meaningful growth instead of waiting until
+    // the final hidden threshold, where a resize redraw may already be visible.
+    if (this.keyboardVisible) {
+      const openMinHeight =
+        this._keyboardOpenMinHeight > 0
+          ? Math.min(this._keyboardOpenMinHeight, currentHeight)
+          : Math.min(this.lastViewportHeight || currentHeight, currentHeight);
+      this._keyboardOpenMinHeight = openMinHeight;
+      if (!this._keyboardClosing && currentHeight - openMinHeight >= this.KEYBOARD_CLOSE_START_DELTA_PX) {
+        this._keyboardClosing = true;
+        if (this._terminalInputRequested) {
+          this._beginTerminalFrameCover({ restart: true });
+        }
+      }
+    }
+
     // Keyboard appeared (viewport shrunk by more than 150px)
     if (heightDiff > 150 && !this.keyboardVisible) {
       this.keyboardVisible = true;
+      this._keyboardOpenMinHeight = currentHeight;
+      this._keyboardClosing = false;
       document.body.classList.add('keyboard-visible');
+      this._clearKeyboardOpeningIntent();
       // While the keyboard is open, size the app to the visual viewport so
       // xterm's bottom row and cursor sit above the OS keyboard.
       document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
@@ -296,23 +405,34 @@ const KeyboardHandler = {
     // Use 100px threshold (not 50) to handle iOS address bar drift,
     // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
     else if (heightDiff < 100 && this.keyboardVisible) {
+      // Closing can begin while the opening cover still owns a queued
+      // compositor swap. Restart its lifecycle so that stale release cannot
+      // expose the final fit/SIGWINCH redraw sequence.
+      if (this._terminalInputRequested) {
+        this._beginTerminalFrameCover({ restart: true });
+      }
       this.keyboardVisible = false;
+      this._keyboardOpenMinHeight = 0;
+      this._keyboardClosing = false;
       document.body.classList.remove('keyboard-visible');
+      this._clearKeyboardOpeningIntent();
       this.onKeyboardHide();
       // Re-sync --app-height now that keyboard is gone (MobileDetection skipped
       // updates while keyboardVisible was true)
       MobileDetection.updateAppHeight();
     }
 
-    // Update baseline when keyboard is not visible — adapts to address bar
-    // state changes, orientation changes, and other viewport shifts
+    // Grow the baseline while the keyboard is hidden (for example when the
+    // browser address bar collapses), but never lower it during a keyboard
+    // opening animation.
     if (!this.keyboardVisible) {
-      this.initialViewportHeight = currentHeight;
+      this.initialViewportHeight = Math.max(this.initialViewportHeight, currentHeight);
     } else {
       document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
     }
 
     this.updateLayoutForKeyboard();
+    this._scheduleViewportSettle();
     this.lastViewportHeight = currentHeight;
   },
 
@@ -326,15 +446,25 @@ const KeyboardHandler = {
     }
 
     const cjkInput = document.getElementById('cjkInput');
-    const isSmallMedium = MobileDetection.isSmallScreen() || MobileDetection.isMediumScreen();
+    const isPhoneLayout = MobileDetection.isHandheldDevice();
+    const isMediumScreen = !isPhoneLayout && window.innerWidth < 768;
 
     if (this.keyboardVisible) {
       const keyboardHeight = this.initialViewportHeight - (window.visualViewport.height || window.innerHeight);
       const accessoryBar = document.querySelector('.keyboard-accessory-bar');
 
-      if (isSmallMedium) {
-        // Phones/small tablets: toolbar and accessory bar are position:fixed
-        // via CSS. Use translateY to lift them above the keyboard.
+      if (isPhoneLayout) {
+        // The phone app is already constrained to the visual viewport. CSS
+        // reserves exactly one accessory row when mobile terminal controls are
+        // enabled; otherwise the terminal reaches the OS keyboard.
+        const toolbar = document.querySelector('.toolbar');
+        const main = document.querySelector('.main');
+        if (toolbar) toolbar.style.transform = '';
+        if (accessoryBar) accessoryBar.style.transform = '';
+        if (main) main.style.paddingBottom = '';
+      } else if (isMediumScreen) {
+        // Small tablets retain fixed controls and use the visual viewport
+        // offset to lift them above the keyboard.
         const toolbar = document.querySelector('.toolbar');
         const main = document.querySelector('.main');
 
@@ -349,8 +479,15 @@ const KeyboardHandler = {
           accessoryBar.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
         }
         if (main && keyboardHeight > 0) {
-          const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible') ? 44 : 0;
-          main.style.paddingBottom = `${84 + cjkInputHeight}px`;
+          const toolbarHeight =
+            toolbar && getComputedStyle(toolbar).display !== 'none' ? toolbar.getBoundingClientRect().height : 0;
+          const accessoryHeight = accessoryBar?.classList.contains('visible')
+            ? accessoryBar.getBoundingClientRect().height
+            : 0;
+          const cjkInputHeight = cjkInput?.classList.contains('cjk-input-visible')
+            ? cjkInput.getBoundingClientRect().height
+            : 0;
+          main.style.paddingBottom = `${toolbarHeight + accessoryHeight + cjkInputHeight}px`;
         }
       } else if (keyboardHeight > 0) {
         // iPad: use direct bottom positioning (translateY unreliable —
@@ -362,16 +499,23 @@ const KeyboardHandler = {
 
       // CJK textarea positioning (always position:fixed on touch devices).
       if (cjkInput?.classList.contains('cjk-input-visible') && keyboardHeight > 0) {
-        if (isSmallMedium) {
-          // Phones: use translateY like toolbar/accessory bar.
+        if (isPhoneLayout) {
+          // Phone CSS keeps the CJK field in terminal-wrap's flex flow.
+          cjkInput.style.transform = '';
+          cjkInput.style.bottom = '';
+        } else if (isMediumScreen) {
+          // Small tablets use the same translation as the fixed controls.
           const layoutHeight = window.innerHeight;
           const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
           const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
           cjkInput.style.transform = keyboardOffset > 0 ? `translateY(${-keyboardOffset}px)` : '';
           cjkInput.style.bottom = '';
         } else {
-          // iPad: direct bottom = keyboard + accessory bar height.
-          cjkInput.style.bottom = `${keyboardHeight + 44}px`;
+          // iPad: direct bottom = keyboard + the currently visible accessory row.
+          const accessoryHeight = accessoryBar?.classList.contains('visible')
+            ? accessoryBar.getBoundingClientRect().height
+            : 0;
+          cjkInput.style.bottom = `${keyboardHeight + accessoryHeight}px`;
           cjkInput.style.transform = '';
         }
       }
@@ -403,91 +547,374 @@ const KeyboardHandler = {
     }
   },
 
+  /** Clear stale mobile keyboard state before a non-touch viewport measures xterm. */
+  resetForDesktopViewport() {
+    if (MobileDetection.isTouchDevice()) return;
+    this.keyboardVisible = false;
+    document.body.classList.remove('keyboard-visible', 'keyboard-opening');
+    if (this._keyboardOpeningTimer) clearTimeout(this._keyboardOpeningTimer);
+    this._keyboardOpeningTimer = null;
+    this.resetLayout();
+  },
+
   /** Called when keyboard appears */
   onKeyboardShow() {
-    // Show keyboard accessory bar
-    if (typeof KeyboardAccessoryBar !== 'undefined') {
-      KeyboardAccessoryBar.show();
+    if (this._terminalInputRequested) this._beginTerminalFrameCover();
+    if (typeof app !== 'undefined') app._captureLocalEchoPromptAnchor?.();
+    if (typeof MobileTerminalControls !== 'undefined') {
+      MobileTerminalControls.syncVisibility();
     }
+    this.updateLayoutForKeyboard();
 
     // Reset any page scroll that occurred during keyboard open.
     // iOS Safari may scroll the document to reveal xterm's hidden textarea.
     window.scrollTo(0, 0);
 
-    // Refit terminal locally AND send resize to server so Claude Code (Ink)
-    // knows the actual terminal dimensions. Without this, Ink redraws at the
-    // old (larger) row count when the user types, causing content to scroll
-    // off the visible area with each keystroke.
-    // Note: the throttledResize handler still suppresses ongoing resize events
-    // while keyboard is up — this one-shot resize on open/close is sufficient.
-    setTimeout(() => {
+    if (this._terminalInputRequested && typeof app !== 'undefined' && app.terminal) {
+      // Keyboard intent means the reader has left history and returned to the
+      // live prompt. Do this locally before the OS animation settles so the
+      // input row is visible immediately without asking the TUI to redraw.
+      app._terminalScrollLocked = false;
+      app._wasAtBottomBeforeWrite = true;
+      app.terminal.scrollToBottom();
+      app._focusMobileTerminalInput?.();
+    }
+
+    // visualViewport emits multiple heights throughout the OS animation.
+    // Re-schedule on every event and fit only after the final height settles;
+    // fitting a hard-coded 150ms intermediate frame left xterm at stale rows.
+    this._scheduleViewportSettle({
+      scrollToBottom: this._terminalInputRequested,
+      focusInput: this._terminalInputRequested,
+    });
+
+    // Reposition subagent windows to stack from bottom (above keyboard)
+    if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
+  },
+
+  /** Called when keyboard hides */
+  onKeyboardHide() {
+    if (typeof app !== 'undefined') app._captureLocalEchoPromptAnchor?.();
+    if (typeof app !== 'undefined') {
+      app._localEchoOverlay?.setViewportPinned?.(false);
+    }
+    const terminalOwnedKeyboard = this._terminalInputRequested;
+    this._terminalInputRequested = false;
+    if (typeof MobileTerminalControls !== 'undefined') {
+      MobileTerminalControls.syncVisibility();
+    }
+
+    this.resetLayout();
+
+    this._scheduleViewportSettle({ scrollToBottom: terminalOwnedKeyboard });
+
+    // Reposition subagent windows to stack from top (below header)
+    if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
+  },
+
+  /**
+   * Restore terminal-owned input after selectSession() has already fitted,
+   * resized, and painted the newly active session. Re-running onKeyboardShow()
+   * here schedules a redundant second fit and causes a visible post-switch jump.
+   */
+  restoreTerminalInputAfterSessionSwitch() {
+    if (!this.keyboardVisible || typeof app === 'undefined' || !app.terminal) return;
+    app._captureLocalEchoPromptAnchor?.();
+    app._terminalScrollLocked = false;
+    app._wasAtBottomBeforeWrite = true;
+    app.terminal.scrollToBottom();
+    app._syncMobileHelperTextareaToCursor?.();
+    app._localEchoOverlay?.rerender?.();
+    app._focusMobileTerminalInput?.();
+    window.scrollTo(0, 0);
+  },
+
+  /** Coalesce the keyboard animation into one final xterm reflow and PTY resize. */
+  _scheduleViewportSettle({ scrollToBottom = false, focusInput = false } = {}) {
+    this._settleScrollToBottom = this._settleScrollToBottom || scrollToBottom;
+    this._settleFocusInput = this._settleFocusInput || focusInput;
+    if (this._viewportSettleTimer) clearTimeout(this._viewportSettleTimer);
+    this._viewportSettleTimer = setTimeout(() => {
+      this._viewportSettleTimer = null;
+      const shouldScrollToBottom = this._settleScrollToBottom;
+      const shouldFocusInput = this.keyboardVisible && (this._settleFocusInput || this._terminalInputRequested);
+      const shouldResizeTerminal = !this.keyboardVisible || shouldFocusInput || this._terminalInputRequested;
+      this._settleScrollToBottom = false;
+      this._settleFocusInput = false;
+
       if (typeof app !== 'undefined' && app.terminal) {
-        if (app.fitAddon)
+        if (app.fitAddon) {
           try {
             app.fitAddon.fit();
           } catch {}
-        // Eliminate terminal row quantization gap: xterm can only show whole
-        // rows, so leftover pixels create dead space below the last row.
-        // Shrink .main's paddingBottom by the gap so the terminal fills flush
-        // to the accessory bar.
-        this._shrinkPaddingToFit();
-        app.terminal.scrollToBottom();
+        }
+        if (this.keyboardVisible) this._shrinkPaddingToFit();
+        if (shouldScrollToBottom) {
+          app._terminalScrollLocked = false;
+          app._wasAtBottomBeforeWrite = true;
+          app.terminal.scrollToBottom();
+        }
         app._syncMobileHelperTextareaToCursor?.();
         app._localEchoOverlay?.rerender?.();
-        // Send resize to server so PTY dimensions match xterm
-        this._sendTerminalResize();
+        if (shouldFocusInput) app._focusMobileTerminalInput?.();
+        if (shouldResizeTerminal) {
+          this._armTerminalFrameCover();
+          this._sendTerminalResize();
+        }
       }
-      // Reset again after fit/resize in case layout changes triggered scroll
       window.scrollTo(0, 0);
-    }, 150);
+    }, this.VIEWPORT_SETTLE_MS);
+  },
 
-    // Reposition subagent windows to stack from bottom (above keyboard)
-    if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
+  /**
+   * Freeze the currently painted terminal rows across a phone keyboard resize.
+   * xterm's DOM renderer can briefly clear between fit() and the TUI's SIGWINCH
+   * redraw; the inert clone keeps the last valid frame visible in that window.
+   */
+  _beginTerminalFrameCover({ includeShell = false, restart = false, arm = false } = {}) {
+    if (!MobileDetection.isTouchDevice() || typeof app === 'undefined') return;
+    if (this._terminalFrameCover) {
+      if (restart) this._restartTerminalFrameCover();
+      if (arm) this._armTerminalFrameCover();
+      return;
+    }
+    const session = app.activeSessionId ? app.sessions?.get(app.activeSessionId) : null;
+    if (!app.activeSessionId || (!includeShell && session?.mode === 'shell')) return;
+
+    const terminalElement = app.terminal?.element;
+    const screen = terminalElement?.querySelector?.('.xterm-screen');
+    if (!(terminalElement instanceof HTMLElement) || !(screen instanceof HTMLElement)) return;
+    const rect = screen.getBoundingClientRect();
+    if (rect.width < 1 || rect.height < 1) return;
+
+    const cover = document.createElement('div');
+    cover.className = 'terminal-resize-frame-cover';
+    cover.setAttribute('aria-hidden', 'true');
+    const rows = screen.querySelector('.xterm-rows');
+    if (!(rows instanceof HTMLElement)) return;
+    const frame = document.createElement('div');
+    frame.className = `${screen.className} terminal-resize-frame`;
+    frame.appendChild(rows.cloneNode(true));
+    const localEcho = [...screen.children].find(
+      (child) =>
+        child instanceof HTMLElement &&
+        child.style.pointerEvents === 'none' &&
+        child.style.zIndex === '7' &&
+        child.textContent
+    );
+    if (localEcho) frame.appendChild(localEcho.cloneNode(true));
+    frame.style.width = `${rect.width}px`;
+    frame.style.height = `${rect.height}px`;
+    cover.appendChild(frame);
+    terminalElement.appendChild(cover);
+
+    this._terminalFrameCover = cover;
+    this._terminalFrameCoverStartedAt = Date.now();
+    this._terminalFrameCoverArmed = false;
+    this._terminalFrameCoverReady = false;
+    this._terminalFrameCoverReadyAt = 0;
+    this._terminalFrameCoverReadyVersion += 1;
+    this._terminalFrameCoverSwapVersion = 0;
+    this._terminalFrameCoverAuthoritative = false;
+    this._scheduleTerminalFrameCoverExpiry();
+    if (arm) this._armTerminalFrameCover();
   },
 
-  /** Called when keyboard hides */
-  onKeyboardHide() {
-    // Hide keyboard accessory bar
-    if (typeof KeyboardAccessoryBar !== 'undefined') {
-      KeyboardAccessoryBar.hide();
+  _restartTerminalFrameCover() {
+    const cover = this._terminalFrameCover;
+    if (!cover) return;
+    this._clearTerminalFrameCoverTimers();
+    this._terminalFrameCoverStartedAt = Date.now();
+    this._terminalFrameCoverArmed = false;
+    this._terminalFrameCoverReady = false;
+    this._terminalFrameCoverReadyAt = 0;
+    this._terminalFrameCoverReadyVersion += 1;
+    this._terminalFrameCoverSwapVersion = 0;
+    this._terminalFrameCoverAuthoritative = false;
+    this._scheduleTerminalFrameCoverExpiry();
+  },
+
+  _scheduleTerminalFrameCoverExpiry(delay = this.FRAME_COVER_MAX_MS) {
+    if (this._terminalFrameCoverMaxTimer) clearTimeout(this._terminalFrameCoverMaxTimer);
+    this._terminalFrameCoverMaxTimer = setTimeout(() => {
+      this._terminalFrameCoverMaxTimer = null;
+      if (this._terminalFrameCoverLoadPending()) {
+        this._scheduleTerminalFrameCoverExpiry(this.FRAME_COVER_LOAD_POLL_MS);
+        return;
+      }
+      this._forceTerminalFrameCoverSwap();
+    }, delay);
+  },
+
+  _terminalFrameCoverLoadPending() {
+    if (typeof app === 'undefined' || !app.activeSessionId) return false;
+    const state = app.terminalLoadStates?.get?.(app.activeSessionId);
+    return Boolean(state && state.phase !== 'failed');
+  },
+
+  _armTerminalFrameCover() {
+    if (!this._terminalFrameCover) return;
+    this._terminalFrameCoverArmed = true;
+    this._terminalFrameCoverReady = false;
+    this._terminalFrameCoverReadyAt = 0;
+    this._terminalFrameCoverReadyVersion += 1;
+    this._terminalFrameCoverSwapVersion = 0;
+    this._terminalFrameCoverAuthoritative = false;
+    if (this._terminalFrameCoverQuietTimer) {
+      clearTimeout(this._terminalFrameCoverQuietTimer);
+      this._terminalFrameCoverQuietTimer = null;
     }
+    if (this._terminalFrameCoverMinTimer) clearTimeout(this._terminalFrameCoverMinTimer);
+    const elapsed = Date.now() - this._terminalFrameCoverStartedAt;
+    this._terminalFrameCoverMinTimer = setTimeout(
+      () => {
+        this._terminalFrameCoverMinTimer = null;
+        this._tryFinishTerminalFrameCover();
+      },
+      Math.max(0, this.FRAME_COVER_MIN_MS - elapsed)
+    );
+  },
 
-    this.resetLayout();
+  onTerminalFramePending() {
+    if (!this._terminalFrameCover || !this._terminalFrameCoverArmed) return;
+    this._terminalFrameCoverReady = false;
+    this._terminalFrameCoverReadyAt = 0;
+    this._terminalFrameCoverReadyVersion += 1;
+    this._terminalFrameCoverSwapVersion = 0;
+    if (this._terminalFrameCoverQuietTimer) {
+      clearTimeout(this._terminalFrameCoverQuietTimer);
+      this._terminalFrameCoverQuietTimer = null;
+    }
+  },
 
-    // Refit terminal, scroll to bottom, and send resize to restore original dimensions
-    setTimeout(() => {
-      if (typeof app !== 'undefined' && app.fitAddon) {
-        try {
-          app.fitAddon.fit();
-        } catch {}
-        if (app.terminal) app.terminal.scrollToBottom();
-        // Send resize to server to restore full terminal size
-        this._sendTerminalResize();
+  onTerminalFrameReady() {
+    if (!this._terminalFrameCoverArmed) return;
+    this._terminalFrameCoverReady = true;
+    this._terminalFrameCoverReadyAt = Date.now();
+    this._terminalFrameCoverReadyVersion += 1;
+    this._tryFinishTerminalFrameCover();
+  },
+
+  onTerminalFrameAuthoritative() {
+    if (!this._terminalFrameCover || !this._terminalFrameCoverArmed) return;
+    this._terminalFrameCoverAuthoritative = true;
+    if (this._terminalFrameCoverQuietTimer) {
+      clearTimeout(this._terminalFrameCoverQuietTimer);
+      this._terminalFrameCoverQuietTimer = null;
+    }
+    this.onTerminalFrameReady();
+  },
+
+  _tryFinishTerminalFrameCover() {
+    if (!this._terminalFrameCover || !this._terminalFrameCoverArmed || !this._terminalFrameCoverReady) {
+      return;
+    }
+    if (Date.now() - this._terminalFrameCoverStartedAt < this.FRAME_COVER_MIN_MS) return;
+    if (!this._terminalHasVisibleFrame()) return;
+    const activeSession =
+      typeof app !== 'undefined' && app.activeSessionId ? app.sessions?.get?.(app.activeSessionId) : null;
+    const quietMs =
+      this._terminalFrameCoverAuthoritative || activeSession?.mode !== 'codex' ? 0 : this.FRAME_COVER_CODEX_QUIET_MS;
+    const quietRemaining = this._terminalFrameCoverReadyAt + quietMs - Date.now();
+    if (quietRemaining > 0) {
+      if (!this._terminalFrameCoverQuietTimer) {
+        this._terminalFrameCoverQuietTimer = setTimeout(() => {
+          this._terminalFrameCoverQuietTimer = null;
+          this._tryFinishTerminalFrameCover();
+        }, quietRemaining);
       }
-    }, 100);
+      return;
+    }
+    this._scheduleTerminalFrameCoverSwap();
+  },
 
-    // Reposition subagent windows to stack from top (below header)
-    if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows();
+  _scheduleTerminalFrameCoverSwap() {
+    const readyVersion = this._terminalFrameCoverReadyVersion;
+    if (this._terminalFrameCoverSwapVersion === readyVersion) return;
+    this._terminalFrameCoverSwapVersion = readyVersion;
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        if (
+          !this._terminalFrameCover ||
+          !this._terminalFrameCoverArmed ||
+          !this._terminalFrameCoverReady ||
+          this._terminalFrameCoverReadyVersion !== readyVersion
+        ) {
+          return;
+        }
+        this._finishTerminalFrameCover();
+      });
+    });
+  },
+
+  _forceTerminalFrameCoverSwap() {
+    const cover = this._terminalFrameCover;
+    if (!cover) return;
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        if (this._terminalFrameCover === cover) this._finishTerminalFrameCover();
+      });
+    });
+  },
+
+  _terminalHasVisibleFrame() {
+    if (typeof app === 'undefined' || !app.terminal) return false;
+    if (app._localEchoOverlay?.state?.visible) return true;
+    const terminal = app.terminal;
+    const buffer = terminal.buffer?.active;
+    if (!buffer?.getLine) return false;
+    const viewportY = buffer.viewportY || 0;
+    const rows = Math.max(1, terminal.rows || 1);
+    for (let row = 0; row < rows; row++) {
+      if (
+        buffer
+          .getLine(viewportY + row)
+          ?.translateToString?.(true)
+          ?.trim()
+      )
+        return true;
+    }
+    return false;
+  },
+
+  _finishTerminalFrameCover() {
+    const cover = this._terminalFrameCover;
+    if (!cover) return;
+    this._terminalFrameCoverArmed = false;
+    this._discardTerminalFrameCover();
+  },
+
+  _clearTerminalFrameCoverTimers() {
+    if (this._terminalFrameCoverMinTimer) clearTimeout(this._terminalFrameCoverMinTimer);
+    if (this._terminalFrameCoverMaxTimer) clearTimeout(this._terminalFrameCoverMaxTimer);
+    if (this._terminalFrameCoverQuietTimer) clearTimeout(this._terminalFrameCoverQuietTimer);
+    this._terminalFrameCoverMinTimer = null;
+    this._terminalFrameCoverMaxTimer = null;
+    this._terminalFrameCoverQuietTimer = null;
+  },
+
+  _discardTerminalFrameCover() {
+    this._clearTerminalFrameCoverTimers();
+    this._terminalFrameCover?.remove();
+    this._terminalFrameCover = null;
+    this._terminalFrameCoverStartedAt = 0;
+    this._terminalFrameCoverArmed = false;
+    this._terminalFrameCoverReady = false;
+    this._terminalFrameCoverReadyAt = 0;
+    this._terminalFrameCoverReadyVersion += 1;
+    this._terminalFrameCoverSwapVersion = 0;
+    this._terminalFrameCoverAuthoritative = false;
   },
 
   /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */
   _sendTerminalResize() {
     if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return;
     try {
-      const dims = app.fitAddon.proposeDimensions();
-      if (dims) {
-        const cols = Math.max(dims.cols, 40);
-        const rows = Math.max(dims.rows, 10);
-        app._lastResizeDims = { cols, rows };
-        // Declare the viewport type so resize arbitration can ignore this
-        // while a desktop connection is sizing the same session.
-        const viewportType = MobileDetection.getDeviceType ? MobileDetection.getDeviceType() : 'mobile';
-        fetch(`/api/sessions/${app.activeSessionId}/resize`, {
-          method: 'POST',
-          headers: { 'Content-Type': 'application/json' },
-          body: JSON.stringify({ cols, rows, viewportType }),
-        }).catch(() => {});
-      }
+      return app._requestTerminalFrameReconcile?.({
+        reason: 'keyboard-resize',
+        resizeOptions: { takeControl: true, refit: false },
+        settleMs: TUI_REDRAW_SETTLE_MS,
+      });
     } catch {}
   },
 
@@ -506,9 +933,12 @@ const KeyboardHandler = {
       if (!cellH) return;
       const gap = container.clientHeight - app.terminal.rows * cellH;
       if (gap > 0 && gap < cellH) {
-        const currentPadding = parseInt(main.style.paddingBottom) || 0;
-        main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px';
-        if (app.fitAddon)
+        const currentPadding = parseFloat(getComputedStyle(main).paddingBottom) || 0;
+        const nextPadding = Math.max(0, currentPadding - gap);
+        if (Math.abs(nextPadding - currentPadding) < 0.5) return;
+        main.style.paddingBottom = `${nextPadding}px`;
+        const appliedPadding = parseFloat(getComputedStyle(main).paddingBottom) || 0;
+        if (Math.abs(appliedPadding - currentPadding) >= 0.5 && app.fitAddon)
           try {
             app.fitAddon.fit();
           } catch {}
@@ -516,6 +946,14 @@ const KeyboardHandler = {
     } catch {}
   },
 
+  /** Check whether focus belongs to the terminal's mobile text-entry surface. */
+  isTerminalInputElement(el) {
+    if (!el) return false;
+    if (el.id === 'cjkInput') return true;
+    if (typeof app !== 'undefined' && el === app.terminal?.textarea) return true;
+    return Boolean(el.closest?.('.xterm, .terminal-container'));
+  },
+
   /** Check if element is an input that triggers keyboard (excludes terminal) */
   isInputElement(el) {
     if (!el) return false;
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css
index 95c5064c..d20714e2 100644
--- a/src/web/public/mobile.css
+++ b/src/web/public/mobile.css
@@ -439,14 +439,17 @@ html.mobile-init .file-browser-panel {
   }
 
   .main {
-    padding-bottom: calc(40px + var(--safe-area-bottom));
+    padding-bottom: calc(var(--mobile-toolbar-height) + var(--safe-area-bottom));
   }
 
   /* iOS Safari: toolbar is pushed up by (100vh - --app-height) to clear the
      browser's bottom bar. Match that offset in main's padding so the terminal
      doesn't extend behind the toolbar. */
   .ios-device.safari-browser .main {
-    padding-bottom: calc(40px + var(--safe-area-bottom) + (100vh - var(--app-height, 100vh)));
+    padding-bottom: calc(
+      var(--mobile-toolbar-height) + var(--safe-area-bottom) +
+        (100vh - var(--app-height, 100vh))
+    );
   }
 
   .header-right {
@@ -468,18 +471,18 @@ html.mobile-init .file-browser-panel {
     height: 12px;
   }
 
-  /* Hide header settings gear, lifecycle log, away digest, session manager, and
-     file viewer on mobile - settings moved to toolbar; the others are secondary /
-     desktop-oriented controls that don't belong on the cramped phone header (the
-     session manager stays reachable via the Ctrl+K palette's "Browse all sessions"
-     item; the file viewer button is opt-in but its panel is desktop-sized).
-     (The attachments button is opt-in / default-hidden everywhere via its own
-     --hidden marker, so it needs no mobile-specific rule here.) */
+  /* Hide secondary and destructive header controls
+     on mobile - settings moved to toolbar; the others are secondary controls
+     that don't belong on the cramped phone header (the session manager stays
+     reachable via the Ctrl+K palette's "Browse all sessions" item). File Viewer
+     remains available from the mobile options surface. Instance shutdown stays
+     off the phone header to avoid an accidental process-level action. */
   .btn-icon-header.btn-settings,
   .btn-icon-header.btn-lifecycle-log,
   .btn-icon-header.btn-away-digest,
   .btn-icon-header.btn-session-manager,
-  .btn-icon-header.btn-file-viewer {
+  .btn-icon-header.btn-file-viewer,
+  .btn-icon-header.btn-instance-shutdown {
     display: none !important;
   }
 
@@ -568,19 +571,6 @@ html.mobile-init .file-browser-panel {
     position: relative;
   }
 
-  /* When keyboard is visible, pin the app container to the viewport.
-     iOS Safari scrolls the page to bring the focused input (xterm's hidden
-     textarea) into view, even with overflow:hidden. position:fixed prevents
-     the browser from scrolling the document under the app. */
-  .keyboard-visible .app {
-    position: fixed;
-    top: 0;
-    left: 0;
-    right: 0;
-    bottom: auto;
-    height: var(--app-height, 100dvh);
-  }
-
   /* Ultra-compact session tabs — .tabs-two-rows override needed to match
      specificity of .session-tabs.tabs-two-rows in styles.css (0,2,0) */
   .session-tabs,
@@ -695,7 +685,7 @@ html.mobile-init .file-browser-panel {
     bottom: var(--safe-area-bottom);
   }
   .keyboard-visible.ios-device.safari-browser .keyboard-accessory-bar {
-    bottom: calc(var(--safe-area-bottom) + 40px);
+    bottom: 0;
   }
 
   /* Show case selector in center */
@@ -915,6 +905,13 @@ html.mobile-init .file-browser-panel {
     color: #fff !important;
   }
 
+  /* The richer keyboard-hidden navigation band already contains Enter. Keep
+     the toolbar action as a fallback when that per-device feature is disabled,
+     but never spend scarce phone width on two simultaneous Enter controls. */
+  body.mobile-nav-visible .btn-toolbar.btn-enter {
+    display: none !important;
+  }
+
   /* Hide case selector on mobile - simplified toolbar */
   .case-select-group {
     display: none !important;
@@ -923,7 +920,7 @@ html.mobile-init .file-browser-panel {
   /* Simplified toolbar layout — Run, Enter, and Case */
   .toolbar-left .toolbar-group:first-child {
     width: 100%;
-    gap: 8px;
+    gap: 4px;
   }
 
   /* Mobile case button - visible on mobile */
@@ -1022,13 +1019,13 @@ html.mobile-init .file-browser-panel {
   .keyboard-accessory-bar {
     display: none;
     position: fixed;
-    bottom: calc(var(--safe-area-bottom) + 40px); /* Above toolbar */
+    bottom: calc(var(--safe-area-bottom) + var(--mobile-toolbar-height)); /* Above toolbar */
     left: 0;
     right: 0;
-    height: 44px;
+    height: var(--mobile-terminal-accessory-height);
     background: #1a1a1a;
     border-top: 1px solid rgba(255, 255, 255, 0.1);
-    padding: 6px 8px;
+    padding: 0 8px;
     padding-left: calc(8px + var(--safe-area-left));
     padding-right: calc(8px + var(--safe-area-right));
     gap: 8px;
@@ -1052,11 +1049,13 @@ html.mobile-init .file-browser-panel {
 
   .accessory-btn {
     display: inline-flex;
+    min-width: 40px;
+    height: 100%;
     align-items: center;
     justify-content: center;
     flex-shrink: 0;
     gap: 4px;
-    padding: 6px 12px;
+    padding: 0 8px;
     background: #2a2a2a;
     border: 1px solid rgba(255, 255, 255, 0.15);
     border-radius: 6px;
@@ -1084,12 +1083,17 @@ html.mobile-init .file-browser-panel {
   }
 
   .accessory-btn-arrow {
-    padding: 6px 10px;
+    padding: 0 6px;
     background: #2563eb;
     border-color: rgba(59, 130, 246, 0.5);
     color: #fff;
   }
 
+  .keyboard-accessory-bar [data-action='opt-enter'] {
+    min-width: 58px;
+    padding: 0 6px;
+  }
+
   .accessory-btn-arrow:active {
     background: #1d4ed8;
   }
@@ -1098,7 +1102,7 @@ html.mobile-init .file-browser-panel {
     margin-left: auto;
     flex: 1 1 0;
     max-width: 100px;
-    padding: 10px 8px;
+    padding: 0 8px;
     background: #2563eb;
     border-color: rgba(59, 130, 246, 0.5);
     color: #fff;
@@ -1841,9 +1845,104 @@ html.mobile-init .file-browser-panel {
 
   /* File browser panel - compact on mobile, visibility controlled by JS */
   .file-browser-panel {
-    max-width: 100%;
-    max-height: 50vh;
+    top: auto;
+    right: calc(8px + var(--safe-area-right));
     bottom: calc(44px + 2rem + var(--safe-area-bottom));
+    left: calc(8px + var(--safe-area-left));
+    width: auto;
+    height: min(64dvh, 560px);
+    min-width: 0;
+    min-height: 260px;
+    max-width: none;
+    max-height: 64dvh;
+    border-radius: 8px;
+    resize: none;
+  }
+
+  .file-browser-header {
+    min-height: 40px;
+    cursor: default;
+  }
+
+  .file-browser-tab {
+    min-height: 38px;
+    font-size: 0.7rem;
+  }
+
+  .file-browser-scope {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 0.15rem;
+  }
+
+  .file-browser-scope select {
+    height: 34px;
+    font-size: 0.72rem;
+  }
+
+  .file-browser-root {
+    max-width: 100%;
+    padding: 0 0.1rem;
+  }
+
+  .repo-change-row {
+    min-height: 50px;
+  }
+
+  .repo-change-path,
+  .repo-commit-file > span:last-child {
+    font-size: 0.68rem;
+  }
+
+  .file-preview-overlay {
+    align-items: stretch;
+    padding-top: var(--safe-area-top);
+    padding-right: var(--safe-area-right);
+    padding-bottom: var(--safe-area-bottom);
+    padding-left: var(--safe-area-left);
+  }
+
+  .file-preview-window {
+    width: 100%;
+    height: 100%;
+    max-width: none;
+    max-height: none;
+    border-radius: 0;
+  }
+
+  .file-preview-header {
+    min-height: 46px;
+    gap: 0.35rem;
+    padding: 0.35rem 0.45rem;
+  }
+
+  .file-preview-title {
+    min-width: 0;
+    font-size: 0.72rem;
+  }
+
+  .file-preview-actions {
+    flex-shrink: 0;
+  }
+
+  .file-preview-mode {
+    height: 30px;
+  }
+
+  .file-preview-mode-btn {
+    padding: 0 0.4rem;
+    font-size: 0.62rem;
+  }
+
+  .repository-diff {
+    font-size: 0.68rem;
+  }
+
+  .repository-diff-line {
+    grid-template-columns: 34px 34px minmax(max-content, 1fr);
+  }
+
+  .repository-diff-line code {
+    padding-right: 0.45rem;
   }
 
   /* Notification drawer - full width on mobile, includes safe area padding */
@@ -2042,6 +2141,111 @@ html.mobile-init .file-browser-panel {
     font-size: 0.7rem;
   }
 
+  .settings-item-has-description {
+    cursor: pointer;
+  }
+
+  .settings-description-trigger {
+    min-width: 0;
+    touch-action: manipulation;
+  }
+
+  .settings-description-trigger .settings-item-label,
+  .settings-item-label.settings-description-trigger {
+    text-decoration-line: underline;
+    text-decoration-style: dotted;
+    text-decoration-color: var(--text-muted);
+    text-underline-offset: 3px;
+  }
+
+  .settings-description-trigger:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: 3px;
+    border-radius: 3px;
+  }
+
+  .settings-description-layer {
+    position: absolute;
+    inset: 0;
+    z-index: 4;
+    display: flex;
+    align-items: flex-end;
+    justify-content: center;
+    padding: 0.75rem;
+    padding-bottom: calc(0.75rem + env(safe-area-inset-bottom, 0px));
+  }
+
+  .settings-description-layer[hidden] {
+    display: none;
+  }
+
+  .settings-description-backdrop {
+    position: absolute;
+    inset: 0;
+    background: rgba(6, 9, 13, 0.72);
+  }
+
+  .settings-description-panel {
+    position: relative;
+    width: 100%;
+    max-width: 420px;
+    max-height: min(60dvh, 420px);
+    overflow-y: auto;
+    background: var(--bg-card);
+    border: 1px solid var(--border);
+    border-radius: 8px;
+    box-shadow: 0 18px 48px rgba(0, 0, 0, 0.42);
+    color: var(--text);
+  }
+
+  .settings-description-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 0.75rem;
+    min-height: 48px;
+    padding: 0.35rem 0.35rem 0.35rem 0.85rem;
+    border-bottom: 1px solid var(--border);
+  }
+
+  .settings-description-header h4 {
+    min-width: 0;
+    margin: 0;
+    overflow-wrap: anywhere;
+    font-size: 0.85rem;
+    font-weight: 600;
+    letter-spacing: 0;
+  }
+
+  .settings-description-close {
+    flex: 0 0 44px;
+    width: 44px;
+    height: 44px;
+    padding: 0;
+    border: 0;
+    border-radius: 4px;
+    background: transparent;
+    color: var(--text-dim);
+    font: inherit;
+    font-size: 1.35rem;
+    line-height: 1;
+  }
+
+  .settings-description-close:focus-visible {
+    outline: 2px solid var(--accent);
+    outline-offset: -2px;
+  }
+
+  .settings-description-text {
+    margin: 0;
+    padding: 0.85rem;
+    overflow-wrap: anywhere;
+    color: var(--text-dim);
+    font-size: 0.82rem;
+    line-height: 1.5;
+    letter-spacing: 0;
+  }
+
   .settings-section-header {
     font-size: 0.6rem;
     padding: 0.35rem 0 0.2rem 0;
@@ -2298,11 +2502,167 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
   background: var(--modal-backdrop);
 }
 
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-enter {
+  background: var(--control-bg) !important;
+  border-color: var(--control-border) !important;
+  color: var(--text) !important;
+}
+
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-enter:hover,
+html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-toolbar.btn-enter:active {
+  background: var(--control-bg-hover) !important;
+  border-color: var(--control-border-hover) !important;
+  color: var(--text) !important;
+}
+
 
 /* Keyboard accessory bar + paste overlay base styles moved to styles.css
    (always loaded — covers iPad landscape where mobile.css doesn't load).
    Phone-specific overrides remain in @media (max-width: 430px) above. */
 
+/* ============================================================================
+   Keyboard-hidden terminal menu navigation
+   A dedicated band keeps vertical terminal gestures available for scrollback.
+   The empty space around the buttons is also a vertical swipe surface.
+   ============================================================================ */
+@media (max-width: 768px) {
+  .mobile-terminal-nav {
+    display: none;
+    position: fixed;
+    right: 0;
+    bottom: calc(var(--safe-area-bottom) + var(--mobile-toolbar-height));
+    left: 0;
+    box-sizing: border-box;
+    height: var(--mobile-terminal-nav-height);
+    padding: 0 calc(8px + var(--safe-area-right)) 0
+      calc(8px + var(--safe-area-left));
+    align-items: center;
+    justify-content: center;
+    gap: 6px;
+    background: var(--term-bg, #161b23);
+    border-top: 0;
+    z-index: 51;
+    touch-action: none;
+    user-select: none;
+    -webkit-user-select: none;
+    -webkit-touch-callout: none;
+  }
+
+  .touch-device .mobile-terminal-nav.visible {
+    display: flex;
+  }
+
+  .keyboard-visible .mobile-terminal-nav {
+    display: none !important;
+  }
+
+  .mobile-terminal-nav-btn {
+    display: inline-flex;
+    width: var(--mobile-terminal-nav-height);
+    min-width: var(--mobile-terminal-nav-height);
+    height: var(--mobile-terminal-nav-height);
+    min-height: var(--mobile-terminal-nav-height);
+    padding: 0;
+    align-items: center;
+    justify-content: center;
+    color: #fff;
+    background: #2563eb;
+    border: 1px solid rgba(147, 197, 253, 0.55);
+    border-radius: 6px;
+    touch-action: none;
+  }
+
+  .mobile-terminal-nav-jump {
+    position: absolute;
+    bottom: calc(100% + 6px);
+    left: 50%;
+    width: 48px;
+    min-width: 48px;
+    height: 44px;
+    min-height: 44px;
+    color: #e5e7eb;
+    background: #2f333b;
+    border-color: rgba(255, 255, 255, 0.25);
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
+    transform: translateX(-50%);
+  }
+
+  .mobile-terminal-nav-jump[hidden] {
+    display: none;
+  }
+
+  .mobile-terminal-nav [data-nav-key="esc"] {
+    margin-right: auto;
+  }
+
+  .mobile-terminal-nav [data-nav-key="tab"] {
+    margin-left: auto;
+  }
+
+  .mobile-terminal-nav-btn svg {
+    width: 22px;
+    height: 22px;
+    fill: none;
+    stroke: currentColor;
+    stroke-width: 2.25;
+    stroke-linecap: round;
+    stroke-linejoin: round;
+    pointer-events: none;
+  }
+
+  .mobile-terminal-nav-enter {
+    color: #e5e7eb;
+    background: #2f333b;
+    border-color: rgba(255, 255, 255, 0.2);
+  }
+
+  .mobile-terminal-nav-key {
+    color: #e5e7eb;
+    background: #2f333b;
+    border-color: rgba(255, 255, 255, 0.2);
+    font: 600 0.75rem/1 var(--ui-font);
+  }
+
+  .mobile-terminal-nav-btn.pressed {
+    background: #1d4ed8;
+    transform: translateY(1px);
+  }
+
+  .mobile-terminal-nav-jump.pressed {
+    transform: translate(-50%, 1px);
+  }
+
+  .mobile-terminal-nav.chord-active .mobile-terminal-nav-enter {
+    color: #fff;
+    background: #15803d;
+    border-color: #4ade80;
+  }
+
+  .mobile-terminal-nav-btn:focus-visible {
+    outline: 2px solid #f8fafc;
+    outline-offset: -3px;
+  }
+
+  .mobile-terminal-nav-btn:disabled {
+    opacity: 0.45;
+  }
+
+  body.mobile-nav-visible:not(.keyboard-visible) .main {
+    padding-bottom: calc(
+      var(--mobile-toolbar-height) + var(--mobile-terminal-nav-height) +
+        var(--safe-area-bottom)
+    );
+    background: var(--term-bg, #161b23);
+  }
+
+  body.mobile-nav-visible:not(.keyboard-visible).ios-device.safari-browser .main {
+    padding-bottom: calc(
+      var(--mobile-toolbar-height) + var(--mobile-terminal-nav-height) +
+        var(--safe-area-bottom) + (100vh - var(--app-height, 100vh))
+    );
+  }
+}
+
 /* ============================================================================
    iOS Safari Specific Fixes
    ============================================================================ */
diff --git a/src/web/public/notification-manager.js b/src/web/public/notification-manager.js
index 8c28e915..2d7f4d9f 100644
--- a/src/web/public/notification-manager.js
+++ b/src/web/public/notification-manager.js
@@ -9,7 +9,7 @@
  *   5. Audio alerts (Web Audio API beep, user-opt-in)
  *
  * Features:
- * - Per-event-type preferences (enabled, browser, audio, push) with v1→v4 migration
+ * - Per-event-type preferences (enabled, browser, audio, push) with v1→v5 migration
  * - Device-specific defaults (notifications disabled on mobile by default)
  * - 5s notification grouping window to batch rapid-fire events
  * - 100-notification cap with oldest eviction
@@ -21,7 +21,7 @@
  * @param {CodemanApp} app - Reference to the main app instance
  *
  * @dependency constants.js (STUCK_THRESHOLD_DEFAULT_MS, timing constants)
- * @dependency mobile-handlers.js (MobileDetection.getDeviceType for device-specific defaults)
+ * @dependency mobile-handlers.js (MobileDetection stable handheld identity/device type)
  * @loadorder 4 of 15 — loaded after voice-input.js, before keyboard-accessory.js
  */
 
@@ -65,12 +65,19 @@ class NotificationManager {
     });
   }
 
-  loadPreferences() {
+  _usesMobilePreferences() {
+    return (
+      MobileDetection.isHandheldDevice?.() ??
+      MobileDetection.getDeviceType() === 'mobile'
+    );
+  }
+
+  getDefaultPreferences() {
     const defaultEventTypes = {
       permission_prompt: { enabled: true, browser: true, audio: true, push: false },
       elicitation_dialog: { enabled: true, browser: true, audio: true, push: false },
       idle_prompt: { enabled: true, browser: true, audio: false, push: false },
-      stop: { enabled: true, browser: false, audio: false, push: false },
+      stop: { enabled: false, browser: false, audio: false, push: false },
       session_error: { enabled: true, browser: true, audio: false, push: false },
       respawn_cycle: { enabled: true, browser: false, audio: false, push: false },
       token_milestone: { enabled: true, browser: false, audio: false, push: false },
@@ -80,8 +87,8 @@ class NotificationManager {
     };
 
     // Device-specific defaults: mobile has notifications disabled by default
-    const isMobile = MobileDetection.getDeviceType() === 'mobile';
-    const defaults = {
+    const isMobile = this._usesMobilePreferences();
+    return {
       enabled: !isMobile, // Disabled on mobile by default
       browserNotifications: !isMobile,
       audioAlerts: false,
@@ -92,51 +99,97 @@ class NotificationManager {
       muteInfo: false,
       // Per-event-type preferences
       eventTypes: defaultEventTypes,
-      _version: 4,
+      _version: 5,
+    };
+  }
+
+  /**
+   * Apply the complete v1→v5 migration to either local or server-hydrated
+   * preferences. Keeping one normalization path prevents fresh browsers from
+   * reviving retired drawer-only hook defaults.
+   */
+  normalizePreferences(rawPreferences) {
+    const defaults = this.getDefaultPreferences();
+    if (
+      !rawPreferences ||
+      typeof rawPreferences !== 'object' ||
+      Array.isArray(rawPreferences)
+    ) {
+      return defaults;
+    }
+
+    const prefs = {
+      ...rawPreferences,
+      eventTypes:
+        rawPreferences.eventTypes &&
+        typeof rawPreferences.eventTypes === 'object' &&
+        !Array.isArray(rawPreferences.eventTypes)
+          ? Object.fromEntries(
+              Object.entries(rawPreferences.eventTypes).map(([key, value]) => [
+                key,
+                value && typeof value === 'object' ? { ...value } : value,
+              ])
+            )
+          : undefined,
+    };
+    const version = Number.isInteger(prefs._version) ? prefs._version : 0;
+
+    // Migrate: v1 had browserNotifications defaulting to false
+    if (version < 2) {
+      prefs.browserNotifications = true;
+    }
+    // Migrate: v2 -> v3 adds eventTypes
+    if (version < 3) {
+      prefs.eventTypes = { ...defaults.eventTypes };
+    }
+    // Migrate: v3 -> v4 adds push field to all eventTypes
+    if (version < 4 && prefs.eventTypes) {
+      for (const key of Object.keys(prefs.eventTypes)) {
+        if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
+          prefs.eventTypes[key].push = false;
+        }
+      }
+    }
+    // Migrate: v4 -> v5 removes the drawer-only Response Complete default.
+    // Preserve users who opted into any external delivery channel.
+    if (version < 5) {
+      const stopPref = prefs.eventTypes?.stop;
+      if (
+        stopPref?.enabled === true &&
+        !stopPref.browser &&
+        !stopPref.audio &&
+        !stopPref.push
+      ) {
+        stopPref.enabled = false;
+      }
+    }
+
+    return {
+      ...defaults,
+      ...prefs,
+      eventTypes: { ...defaults.eventTypes, ...prefs.eventTypes },
+      _version: 5,
     };
+  }
+
+  loadPreferences() {
     try {
       const storageKey = this.getStorageKey();
       const saved = localStorage.getItem(storageKey);
       if (saved) {
-        const prefs = JSON.parse(saved);
-        // Migrate: v1 had browserNotifications defaulting to false
-        if (!prefs._version || prefs._version < 2) {
-          prefs.browserNotifications = true;
-          prefs._version = 2;
-        }
-        // Migrate: v2 -> v3 adds eventTypes
-        if (prefs._version < 3) {
-          prefs.eventTypes = defaultEventTypes;
-          prefs._version = 3;
-          localStorage.setItem(storageKey, JSON.stringify(prefs));
-        }
-        // Migrate: v3 -> v4 adds push field to all eventTypes
-        if (prefs._version < 4) {
-          if (prefs.eventTypes) {
-            for (const key of Object.keys(prefs.eventTypes)) {
-              if (prefs.eventTypes[key] && prefs.eventTypes[key].push === undefined) {
-                prefs.eventTypes[key].push = false;
-              }
-            }
-          }
-          prefs._version = 4;
-          localStorage.setItem(storageKey, JSON.stringify(prefs));
-        }
-        // Merge with defaults to ensure all eventTypes exist
-        return {
-          ...defaults,
-          ...prefs,
-          eventTypes: { ...defaultEventTypes, ...prefs.eventTypes },
-        };
+        const normalized = this.normalizePreferences(JSON.parse(saved));
+        localStorage.setItem(storageKey, JSON.stringify(normalized));
+        return normalized;
       }
     } catch (_e) { /* ignore */ }
-    return defaults;
+    return this.getDefaultPreferences();
   }
 
   // Get storage key for notification prefs (device-specific)
   getStorageKey() {
-    const isMobile = MobileDetection.getDeviceType() === 'mobile';
-    return isMobile ? 'codeman-notification-prefs-mobile' : 'codeman-notification-prefs';
+    return this._usesMobilePreferences()
+      ? 'codeman-notification-prefs-mobile'
+      : 'codeman-notification-prefs';
   }
 
   savePreferences() {
@@ -163,8 +216,10 @@ class NotificationManager {
       'exit-gate': 'ralph_complete',
       'subagent-spawn': 'subagent_spawn',
       'subagent-complete': 'subagent_complete',
-      'hook-teammate-idle': 'idle_prompt',
-      'hook-task-completed': 'stop',
+      // Team lifecycle hooks are agent activity, not session-idle/stop alerts.
+      // Reuse the existing opt-in agent categories instead of making them noisy.
+      'hook-teammate-idle': 'subagent_spawn',
+      'hook-task-completed': 'subagent_complete',
     };
     const eventTypeKey = categoryToEventType[category] || category;
 
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js
index eb69a823..9ef66555 100644
--- a/src/web/public/panels-ui.js
+++ b/src/web/public/panels-ui.js
@@ -2944,35 +2944,336 @@ Object.assign(CodemanApp.prototype, {
   // File Browser Panel
   // ═══════════════════════════════════════════════════════════════
 
-  async loadFileBrowser(sessionId) {
+  resetFileBrowserSessionContext(sessionId, options = {}) {
+    if (!sessionId || (!options.force && this.fileBrowserSessionId === sessionId)) return false;
+
+    this.fileBrowserAbortController?.abort();
+    this.fileBrowserAbortController = null;
+    this.fileBrowserLoadGeneration += 1;
+    if (this.fileBrowserAutoRefreshTimer) {
+      clearInterval(this.fileBrowserAutoRefreshTimer);
+      this.fileBrowserAutoRefreshTimer = null;
+    }
+
+    this.fileBrowserSessionId = sessionId;
+    this.fileBrowserScopeId = 'current';
+    this.fileBrowserData = null;
+    this.fileBrowserRepositoryData = null;
+    this.fileBrowserExpandedDirs.clear();
+    this.fileBrowserFilter = '';
+    this.fileBrowserAllExpanded = false;
+    this.fileBrowserCommitCache.clear();
+    this.fileBrowserExpandedCommit = null;
+    const searchInput = this.$('fileBrowserSearch');
+    if (searchInput) searchInput.value = '';
+    return true;
+  },
+
+  syncFileBrowserSession(sessionId, options = {}) {
+    if (!this.resetFileBrowserSessionContext(sessionId, options)) return null;
+
+    const panel = this.$('fileBrowserPanel');
+    if (panel?.classList.contains('visible')) {
+      return this.loadFileBrowser(sessionId, { scopeId: 'current' });
+    }
+    return null;
+  },
+
+  async loadFileBrowser(sessionId, options = {}) {
     if (!sessionId) return;
 
     const treeEl = this.$('fileBrowserTree');
-    const statusEl = this.$('fileBrowserStatus');
     if (!treeEl) return;
 
-    // Show loading state
-    treeEl.innerHTML = '
Loading files...
'; + this.resetFileBrowserSessionContext(sessionId); - try { - const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`); - if (!res.ok) throw new Error('Failed to load files'); + const repositoryOnly = options.repositoryOnly === true; + const requestedScope = options.scopeId || this.fileBrowserScopeId || 'current'; + const generation = ++this.fileBrowserLoadGeneration; + this.fileBrowserAbortController?.abort(); + const controller = new AbortController(); + this.fileBrowserAbortController = controller; - const result = await res.json(); - if (!result.success) throw new Error(result.error || 'Failed to load files'); + if (!options.silent) { + treeEl.innerHTML = `
Loading ${repositoryOnly ? 'repository' : 'files'}...
`; + } - this.fileBrowserData = result.data; - this.renderFileBrowserTree(); + try { + const scopeQuery = encodeURIComponent(requestedScope); + const repositoryRequest = fetch( + `/api/sessions/${sessionId}/repository?scope=${scopeQuery}`, + { signal: controller.signal } + ); + const filesRequest = repositoryOnly + ? null + : fetch( + `/api/sessions/${sessionId}/files?depth=5&showHidden=false&scope=${scopeQuery}`, + { signal: controller.signal } + ); + const [repositoryResponse, filesResponse] = await Promise.all([ + repositoryRequest, + filesRequest, + ]); + + if (!repositoryResponse.ok) throw new Error('Failed to load repository'); + const repositoryResult = await repositoryResponse.json(); + if (!repositoryResult.success) { + throw new Error(repositoryResult.error || 'Failed to load repository'); + } + + let filesResult = null; + if (filesResponse) { + if (!filesResponse.ok) throw new Error('Failed to load files'); + filesResult = await filesResponse.json(); + if (!filesResult.success) throw new Error(filesResult.error || 'Failed to load files'); + } - // Update status - if (statusEl) { - const { totalFiles, totalDirectories, truncated } = result.data; - statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`; + if ( + generation !== this.fileBrowserLoadGeneration || + this.activeSessionId !== sessionId || + this.fileBrowserSessionId !== sessionId + ) { + return; } + + this.fileBrowserRepositoryData = repositoryResult.data; + this.fileBrowserScopeId = repositoryResult.data.selectedScopeId || 'current'; + if (filesResult) this.fileBrowserData = filesResult.data; + this.renderFileBrowserControls(); + this.renderFileBrowserCurrentView(); + this.updateFileBrowserAutoRefresh(); } catch (err) { + if (err?.name === 'AbortError') return; console.error('Failed to load file browser:', err); - treeEl.innerHTML = `
Failed to load files: ${escapeHtml(err.message)}
`; + if ( + generation === this.fileBrowserLoadGeneration && + this.fileBrowserSessionId === sessionId + ) { + treeEl.innerHTML = `
Failed to load repository: ${escapeHtml(err.message)}
`; + } + } finally { + if (this.fileBrowserAbortController === controller) { + this.fileBrowserAbortController = null; + } + } + }, + + renderFileBrowserControls() { + const repository = this.fileBrowserRepositoryData; + const scopeRow = this.$('fileBrowserScopeRow'); + const scopeSelect = this.$('fileBrowserScope'); + const rootEl = this.$('fileBrowserRoot'); + const searchRow = this.$('fileBrowserSearchRow'); + const expandBtn = this.$('fileBrowserExpandBtn'); + const workingDirectoryBtn = this.$('fileBrowserWorkingDirectoryBtn'); + const changesCount = this.$('fileBrowserChangesCount'); + const available = repository?.available === true; + const session = this.sessions.get(this.fileBrowserSessionId); + + if (scopeRow) scopeRow.hidden = !available; + if (workingDirectoryBtn) { + workingDirectoryBtn.hidden = !session || Boolean(session.remote || session.docker); + } + if (scopeSelect && available) { + scopeSelect.replaceChildren(); + for (const worktree of repository.worktrees) { + const option = document.createElement('option'); + option.value = worktree.id; + const role = worktree.main ? 'root' : 'worktree'; + const branch = worktree.branch || worktree.head.slice(0, 8); + option.textContent = `${worktree.name} · ${branch} (${role})`; + option.title = worktree.path; + option.selected = worktree.id === this.fileBrowserScopeId; + scopeSelect.appendChild(option); + } + } + if (rootEl) { + const selected = repository?.worktrees?.find( + worktree => worktree.id === this.fileBrowserScopeId + ); + rootEl.textContent = selected?.branch || ''; + rootEl.title = selected?.path || repository?.repositoryRoot || ''; + } + + document.querySelectorAll('.file-browser-tab').forEach(tab => { + const active = tab.dataset.view === this.fileBrowserView; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', String(active)); + }); + if (searchRow) searchRow.hidden = this.fileBrowserView !== 'files'; + if (expandBtn) expandBtn.hidden = this.fileBrowserView !== 'files'; + if (changesCount) { + const count = repository?.changes?.length || 0; + changesCount.textContent = String(count); + changesCount.hidden = count === 0; + } + this.updateFileBrowserStatus(); + }, + + switchFileBrowserView(view) { + if (!['files', 'changes', 'history'].includes(view)) return; + this.fileBrowserView = view; + this.renderFileBrowserControls(); + this.renderFileBrowserCurrentView(); + this.updateFileBrowserAutoRefresh(); + }, + + changeFileBrowserScope(scopeId) { + if (!scopeId || scopeId === this.fileBrowserScopeId || !this.fileBrowserSessionId) return; + this.fileBrowserScopeId = scopeId; + this.fileBrowserExpandedDirs.clear(); + this.fileBrowserCommitCache.clear(); + this.fileBrowserExpandedCommit = null; + this.loadFileBrowser(this.fileBrowserSessionId, { scopeId }); + }, + + openFileBrowserWorkingDirectoryEditor() { + const sessionId = this.fileBrowserSessionId || this.activeSessionId; + const session = sessionId ? this.sessions.get(sessionId) : null; + if (!session) { + this.showToast('Open a session to set its work path', 'info'); + return; + } + if (session.remote || session.docker) { + this.showToast('Work path editing is available for local sessions', 'info'); + return; + } + + const modal = this.$('workingDirectoryModal'); + const input = this.$('workingDirectoryInput'); + const status = this.$('workingDirectoryStatus'); + if (!modal || !input) return; + + modal.dataset.sessionId = sessionId; + modal.classList.add('active'); + modal.setAttribute('aria-hidden', 'false'); + input.value = session.workingDir || ''; + if (status) { + status.hidden = true; + status.textContent = ''; + } + this._workingDirectoryFocusTrap?.deactivate(); + this._workingDirectoryFocusTrap = new FocusTrap(modal); + this._workingDirectoryFocusTrap.activate(); + requestAnimationFrame(() => { + input.focus(); + input.select(); + }); + }, + + closeFileBrowserWorkingDirectoryEditor() { + const modal = this.$('workingDirectoryModal'); + if (modal) { + modal.classList.remove('active'); + modal.setAttribute('aria-hidden', 'true'); + delete modal.dataset.sessionId; + } + this._workingDirectoryFocusTrap?.deactivate(); + this._workingDirectoryFocusTrap = null; + }, + + async saveFileBrowserWorkingDirectory(event) { + event?.preventDefault?.(); + const modal = this.$('workingDirectoryModal'); + const input = this.$('workingDirectoryInput'); + const status = this.$('workingDirectoryStatus'); + const saveBtn = this.$('workingDirectorySaveBtn'); + const sessionId = modal?.dataset.sessionId; + const workingDir = input?.value.trim(); + if (!modal || !sessionId || !workingDir) return; + + if (saveBtn) saveBtn.disabled = true; + if (status) { + status.hidden = true; + status.textContent = ''; + } + + try { + this._pendingWorkingDirectoryChange = { sessionId, workingDir }; + const response = await this._apiPut( + `/api/sessions/${encodeURIComponent(sessionId)}/working-directory`, + { workingDir } + ); + let result = null; + try { + result = await response?.json(); + } catch { + /* handled by the response check below */ + } + if (!response?.ok || result?.success === false) { + throw new Error(result?.error || 'Failed to update work path'); + } + + const updatedWorkingDir = result?.data?.workingDir || result?.workingDir || workingDir; + const session = this.sessions.get(sessionId); + if (session) session.workingDir = updatedWorkingDir; + this.closeFileBrowserWorkingDirectoryEditor(); + await this.syncFileBrowserSession(sessionId, { force: true }); + this.showToast('Session work path updated', 'success'); + } catch (err) { + if (status) { + status.textContent = err?.message || 'Failed to update work path'; + status.hidden = false; + } + } finally { + this._pendingWorkingDirectoryChange = null; + if (saveBtn) saveBtn.disabled = false; + } + }, + + renderFileBrowserCurrentView() { + if (this.fileBrowserView === 'changes') { + this.renderFileBrowserChanges(); + } else if (this.fileBrowserView === 'history') { + this.renderFileBrowserHistory(); + } else { + this.renderFileBrowserTree(); } + this.updateFileBrowserStatus(); + }, + + updateFileBrowserStatus() { + const statusEl = this.$('fileBrowserStatus'); + if (!statusEl) return; + if (this.fileBrowserView === 'changes') { + const count = this.fileBrowserRepositoryData?.changes?.length || 0; + statusEl.textContent = `${count} changed file${count === 1 ? '' : 's'} · refreshes every 5s`; + return; + } + if (this.fileBrowserView === 'history') { + const count = this.fileBrowserRepositoryData?.commits?.length || 0; + statusEl.textContent = `${count} recent commit${count === 1 ? '' : 's'}`; + return; + } + if (!this.fileBrowserData) { + statusEl.textContent = ''; + return; + } + const { totalFiles, totalDirectories, truncated } = this.fileBrowserData; + statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`; + }, + + updateFileBrowserAutoRefresh() { + if (this.fileBrowserAutoRefreshTimer) { + clearInterval(this.fileBrowserAutoRefreshTimer); + this.fileBrowserAutoRefreshTimer = null; + } + const panel = this.$('fileBrowserPanel'); + if ( + this.fileBrowserView !== 'changes' || + !panel?.classList.contains('visible') || + !this.fileBrowserSessionId + ) { + return; + } + this.fileBrowserAutoRefreshTimer = setInterval(() => { + if (document.hidden || !panel.classList.contains('visible')) return; + this.loadFileBrowser(this.fileBrowserSessionId, { + scopeId: this.fileBrowserScopeId, + repositoryOnly: true, + silent: true, + }); + }, 5000); }, renderFileBrowserTree() { @@ -2987,6 +3288,10 @@ Object.assign(CodemanApp.prototype, { const html = []; const filter = this.fileBrowserFilter.toLowerCase(); + const sessionId = this.fileBrowserSessionId || this.activeSessionId; + const scopeSuffix = this.fileBrowserScopeId + ? `&scope=${encodeURIComponent(this.fileBrowserScopeId)}` + : ''; const renderNode = (node, depth) => { const isDir = node.type === 'directory'; @@ -3017,7 +3322,7 @@ Object.assign(CodemanApp.prototype, { const nameClass = isDir ? 'file-tree-name directory' : 'file-tree-name'; const downloadBtn = !isDir - ? `` + ? `` : ''; html.push(` @@ -3053,12 +3358,168 @@ Object.assign(CodemanApp.prototype, { if (type === 'directory') { this.toggleFileBrowserFolder(path); } else { - this.openFilePreview(path); + this.openFilePreview(path, sessionId, null, this.fileBrowserScopeId); } }); }); }, + renderFileBrowserChanges() { + const treeEl = this.$('fileBrowserTree'); + if (!treeEl) return; + const repository = this.fileBrowserRepositoryData; + if (!repository?.available) { + treeEl.innerHTML = '
This session is not inside a Git repository
'; + return; + } + const changes = repository.changes || []; + if (changes.length === 0) { + treeEl.innerHTML = '
Working tree is clean
'; + return; + } + + treeEl.innerHTML = changes + .map(change => { + const stats = change.binary + ? 'binary' + : change.additions !== null || change.deletions !== null + ? `+${change.additions || 0} -${change.deletions || 0}` + : ''; + const stage = change.staged && change.unstaged + ? 'staged + working' + : change.staged + ? 'staged' + : 'working'; + const displayPath = change.oldPath + ? `${change.oldPath} -> ${change.path}` + : change.path; + return ` + + `; + }) + .join(''); + + treeEl.querySelectorAll('.repo-change-row').forEach(row => { + row.addEventListener('click', () => this.openRepositoryDiff(row.dataset.path)); + }); + }, + + formatRepositoryDate(value) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + }, + + renderFileBrowserHistory() { + const treeEl = this.$('fileBrowserTree'); + if (!treeEl) return; + const repository = this.fileBrowserRepositoryData; + if (!repository?.available) { + treeEl.innerHTML = '
This session is not inside a Git repository
'; + return; + } + const commits = repository.commits || []; + if (commits.length === 0) { + treeEl.innerHTML = '
No commits yet
'; + return; + } + + treeEl.innerHTML = commits + .map(commit => { + const expanded = commit.hash === this.fileBrowserExpandedCommit; + const cacheKey = `${this.fileBrowserScopeId}:${commit.hash}`; + const details = this.fileBrowserCommitCache.get(cacheKey); + let files = ''; + if (expanded) { + if (!details) { + files = '
Loading changed files...
'; + } else if (details.changes.length === 0) { + files = '
No changed files
'; + } else { + files = ` +
+ ${details.changes.map(change => ` + + `).join('')} +
+ `; + } + } + return ` +
+ + ${files} +
+ `; + }) + .join(''); + + treeEl.querySelectorAll('.repo-commit-summary').forEach(button => { + button.addEventListener('click', () => this.toggleFileBrowserCommit(button.dataset.commit)); + }); + treeEl.querySelectorAll('.repo-commit-file').forEach(button => { + button.addEventListener('click', () => { + this.openRepositoryDiff(button.dataset.path, button.dataset.commit); + }); + }); + }, + + async toggleFileBrowserCommit(commit) { + if (!commit || !this.fileBrowserSessionId) return; + if (this.fileBrowserExpandedCommit === commit) { + this.fileBrowserExpandedCommit = null; + this.renderFileBrowserHistory(); + return; + } + this.fileBrowserExpandedCommit = commit; + this.renderFileBrowserHistory(); + + const cacheKey = `${this.fileBrowserScopeId}:${commit}`; + if (this.fileBrowserCommitCache.has(cacheKey)) return; + const sessionId = this.fileBrowserSessionId; + const scopeId = this.fileBrowserScopeId; + try { + const res = await fetch( + `/api/sessions/${sessionId}/repository/commit?scope=${encodeURIComponent(scopeId)}&commit=${encodeURIComponent(commit)}` + ); + const result = await res.json(); + if (!res.ok || !result.success) { + throw new Error(result.error || 'Failed to load commit'); + } + if ( + this.fileBrowserSessionId !== sessionId || + this.fileBrowserScopeId !== scopeId + ) { + return; + } + this.fileBrowserCommitCache.set(cacheKey, result.data); + if (this.fileBrowserExpandedCommit === commit) this.renderFileBrowserHistory(); + } catch (err) { + console.error('Failed to load commit:', err); + if (this.fileBrowserExpandedCommit === commit) { + const treeEl = this.$('fileBrowserTree'); + const loading = treeEl?.querySelector('.repo-commit-loading'); + if (loading) loading.textContent = `Failed to load commit: ${err.message}`; + } + } + }, + hasMatchingChild(node, filter) { if (!node.children) return false; for (const child of node.children) { @@ -3116,14 +3577,10 @@ Object.assign(CodemanApp.prototype, { }, refreshFileBrowser() { - if (this.activeSessionId) { - this.fileBrowserExpandedDirs.clear(); - this.fileBrowserFilter = ''; - this.fileBrowserAllExpanded = false; - const searchInput = this.$('fileBrowserSearch'); - if (searchInput) searchInput.value = ''; - this.loadFileBrowser(this.activeSessionId); - } + const sessionId = this.activeSessionId || this.fileBrowserSessionId; + if (!sessionId) return; + this.fileBrowserCommitCache.clear(); + this.loadFileBrowser(sessionId, { scopeId: this.fileBrowserScopeId }); }, // Header "File Viewer" button (opt-in via App Settings → Header Displays → @@ -3180,6 +3637,12 @@ Object.assign(CodemanApp.prototype, { } this.fileBrowserDragListeners = null; } + this.fileBrowserAbortController?.abort(); + this.fileBrowserAbortController = null; + if (this.fileBrowserAutoRefreshTimer) { + clearInterval(this.fileBrowserAutoRefreshTimer); + this.fileBrowserAutoRefreshTimer = null; + } // Save setting const settings = this.loadAppSettingsFromStorage(); settings.showFileBrowser = false; @@ -3190,7 +3653,215 @@ Object.assign(CodemanApp.prototype, { if (headerBtn) headerBtn.setAttribute('aria-expanded', 'false'); }, - async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null) { + async openRepositoryDiff(filePath, commit = null) { + const sessionId = this.fileBrowserSessionId || this.activeSessionId; + const scopeId = this.fileBrowserScopeId; + if (!sessionId || !scopeId || !filePath) return; + + const overlay = this.$('filePreviewOverlay'); + const titleEl = this.$('filePreviewTitle'); + const bodyEl = this.$('filePreviewBody'); + const footerEl = this.$('filePreviewFooter'); + const modeEl = this.$('filePreviewMode'); + if (!overlay || !bodyEl) return; + + const generation = (this.fileDiffLoadGeneration || 0) + 1; + this.fileDiffLoadGeneration = generation; + this.fileDiffData = null; + this.fileDiffMode = 'compact'; + overlay.classList.add('visible'); + if (titleEl) titleEl.textContent = filePath; + bodyEl.innerHTML = '
Loading diff...
'; + if (footerEl) footerEl.textContent = ''; + if (modeEl) modeEl.hidden = false; + this.setRepositoryDiffMode('compact'); + + const params = new URLSearchParams({ scope: scopeId, path: filePath }); + if (commit) params.set('commit', commit); + try { + const res = await fetch( + `/api/sessions/${sessionId}/repository/diff?${params.toString()}` + ); + const result = await res.json(); + if (!res.ok || !result.success) { + throw new Error(result.error || 'Failed to load diff'); + } + if ( + generation !== this.fileDiffLoadGeneration || + this.fileBrowserSessionId !== sessionId || + this.fileBrowserScopeId !== scopeId + ) { + return; + } + this.fileDiffData = result.data; + this.renderRepositoryDiff(); + } catch (err) { + if (generation !== this.fileDiffLoadGeneration) return; + bodyEl.innerHTML = `
Error: ${escapeHtml(err.message)}
`; + } + }, + + setRepositoryDiffMode(mode) { + if (!['compact', 'full'].includes(mode)) return; + this.fileDiffMode = mode; + document.querySelectorAll('.file-preview-mode-btn').forEach(button => { + const active = button.dataset.mode === mode; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + }); + if (this.fileDiffData) this.renderRepositoryDiff(); + }, + + parseUnifiedDiffRows(patch) { + const rows = []; + const lines = String(patch || '').split('\n'); + let oldLine = 0; + let newLine = 0; + let inHunk = false; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (index === lines.length - 1 && line === '') continue; + const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/); + if (hunk) { + oldLine = Number.parseInt(hunk[1], 10); + newLine = Number.parseInt(hunk[2], 10); + inHunk = true; + rows.push({ kind: 'hunk', text: line, oldLine: null, newLine: null }); + continue; + } + if (!inHunk || /^(diff --git|index |--- |\+\+\+ |new file|deleted file|similarity|rename )/.test(line)) { + rows.push({ kind: 'meta', text: line, oldLine: null, newLine: null }); + continue; + } + if (line.startsWith('+')) { + rows.push({ + kind: 'add', + text: line.slice(1), + oldLine: null, + newLine, + anchorNewLine: newLine, + }); + newLine++; + } else if (line.startsWith('-')) { + rows.push({ + kind: 'del', + text: line.slice(1), + oldLine, + newLine: null, + anchorNewLine: newLine, + }); + oldLine++; + } else if (line.startsWith(' ')) { + rows.push({ + kind: 'context', + text: line.slice(1), + oldLine, + newLine, + anchorNewLine: newLine, + }); + oldLine++; + newLine++; + } else { + rows.push({ kind: 'meta', text: line, oldLine: null, newLine: null }); + } + } + return rows; + }, + + repositoryDiffRowHtml(row) { + if (row.kind === 'meta' || row.kind === 'hunk') { + return `
${escapeHtml(row.text)}
`; + } + const prefix = row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '; + return ` +
+ ${row.oldLine ?? ''} + ${row.newLine ?? ''} + ${prefix}${escapeHtml(row.text)} +
+ `; + }, + + renderRepositoryDiff() { + const data = this.fileDiffData; + const bodyEl = this.$('filePreviewBody'); + const footerEl = this.$('filePreviewFooter'); + if (!data || !bodyEl) return; + + if (data.binary) { + bodyEl.innerHTML = '
Binary diff cannot be rendered
'; + this.filePreviewContent = data.patch || ''; + } else if (this.fileDiffMode === 'full') { + bodyEl.innerHTML = this.renderFullRepositoryDiff(data); + this.filePreviewContent = data.afterContent ?? data.beforeContent ?? ''; + } else { + const rows = this.parseUnifiedDiffRows(data.patch); + bodyEl.innerHTML = rows.length + ? `
${rows.map(row => this.repositoryDiffRowHtml(row)).join('')}
` + : '
No textual diff for this file
'; + this.filePreviewContent = data.patch || ''; + } + + if (footerEl) { + footerEl.textContent = `${data.label} · +${data.additions} -${data.deletions}${data.truncated ? ' · truncated' : ''}`; + } + }, + + renderFullRepositoryDiff(data) { + const deletedFile = data.beforeExists && !data.afterExists; + const content = deletedFile ? data.beforeContent : data.afterContent; + if (content === null) { + return '
Full file is unavailable or exceeds the preview limit
'; + } + + const contentLines = String(content).split('\n'); + if (content.endsWith('\n')) contentLines.pop(); + if (deletedFile) { + const rows = contentLines.map((text, index) => ({ + kind: 'del', + text, + oldLine: index + 1, + newLine: null, + })); + return `
${rows.map(row => this.repositoryDiffRowHtml(row)).join('')}
`; + } + + const patchRows = this.parseUnifiedDiffRows(data.patch); + const addedLines = new Set(); + const oldLineByNewLine = new Map(); + const deletedBefore = new Map(); + for (const row of patchRows) { + if (row.kind === 'add') addedLines.add(row.newLine); + if (row.kind === 'context') oldLineByNewLine.set(row.newLine, row.oldLine); + if (row.kind === 'del') { + const anchor = row.anchorNewLine || 1; + const deletions = deletedBefore.get(anchor) || []; + deletions.push(row); + deletedBefore.set(anchor, deletions); + } + } + + const rows = []; + for (let newLine = 1; newLine <= contentLines.length; newLine++) { + rows.push(...(deletedBefore.get(newLine) || [])); + rows.push({ + kind: addedLines.has(newLine) ? 'add' : 'context', + text: contentLines[newLine - 1], + oldLine: oldLineByNewLine.get(newLine) ?? null, + newLine, + }); + } + rows.push(...(deletedBefore.get(contentLines.length + 1) || [])); + return `
${rows.map(row => this.repositoryDiffRowHtml(row)).join('')}
`; + }, + + async openFilePreview( + filePath, + sessionId = this.activeSessionId, + attachmentId = null, + scopeId = null + ) { if (!sessionId || !filePath) return; const overlay = this.$('filePreviewOverlay'); @@ -3201,10 +3872,15 @@ Object.assign(CodemanApp.prototype, { if (!overlay || !bodyEl) return; // Show overlay with loading state + this.fileDiffLoadGeneration = (this.fileDiffLoadGeneration || 0) + 1; + this.fileDiffData = null; + const modeEl = this.$('filePreviewMode'); + if (modeEl) modeEl.hidden = true; overlay.classList.add('visible'); titleEl.textContent = filePath; bodyEl.innerHTML = '
Loading...
'; footerEl.textContent = ''; + const scopeSuffix = scopeId ? `&scope=${encodeURIComponent(scopeId)}` : ''; const ext = (filePath.split('.').pop() || '').toLowerCase(); @@ -3240,13 +3916,13 @@ Object.assign(CodemanApp.prototype, { // to file-content below, which would dump the binary bytes as mojibake. if (ext === 'docx' || ext === 'pptx') { footerEl.textContent = ext.toUpperCase(); - const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}`; + const previewSrc = `/api/sessions/${sessionId}/file-preview?path=${encodeURIComponent(filePath)}${scopeSuffix}`; bodyEl.innerHTML = ``; return; } if (ext === 'pdf') { footerEl.textContent = 'PDF'; - const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`; + const rawSrc = `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}${scopeSuffix}`; bodyEl.innerHTML = ``; return; } @@ -3258,7 +3934,9 @@ Object.assign(CodemanApp.prototype, { if (ext === 'svg') { footerEl.textContent = 'SVG'; try { - const res = await fetch(`/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}`); + const res = await fetch( + `/api/sessions/${sessionId}/file-raw?path=${encodeURIComponent(filePath)}${scopeSuffix}` + ); if (!res.ok) throw new Error('Failed to load image'); const blobUrl = URL.createObjectURL(new Blob([await res.text()], { type: 'image/svg+xml' })); bodyEl.innerHTML = `${escapeHtml(filePath)}`; @@ -3271,7 +3949,9 @@ Object.assign(CodemanApp.prototype, { } try { - const res = await fetch(`/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500`); + const res = await fetch( + `/api/sessions/${sessionId}/file-content?path=${encodeURIComponent(filePath)}&lines=500${scopeSuffix}` + ); if (!res.ok) throw new Error('Failed to load file'); const result = await res.json(); @@ -3306,10 +3986,14 @@ Object.assign(CodemanApp.prototype, { }, closeFilePreview() { + this.fileDiffLoadGeneration = (this.fileDiffLoadGeneration || 0) + 1; const overlay = this.$('filePreviewOverlay'); if (overlay) { overlay.classList.remove('visible'); } + const modeEl = this.$('filePreviewMode'); + if (modeEl) modeEl.hidden = true; + this.fileDiffData = null; this.filePreviewContent = ''; }, diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index d3cbec61..e0f729e9 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -161,17 +161,37 @@ Object.assign(CodemanApp.prototype, { const selected = option.name === selectedName; const id = `quickStartCaseOption-${index}`; return ` - +
+ + + + + +
`; }) .join(''); @@ -238,6 +258,20 @@ Object.assign(CodemanApp.prototype, { }); list.addEventListener('mousedown', event => event.preventDefault()); list.addEventListener('click', event => { + const action = event.target.closest?.('[data-case-action]'); + if (action) { + event.preventDefault(); + event.stopPropagation(); + const caseName = action.closest('.case-combobox-row')?.dataset?.case; + if (!caseName) return; + if (action.dataset.caseAction === 'edit') { + this.editCaseFromPicker(caseName); + } else if (action.dataset.caseAction === 'delete') { + this.closeCasePicker(); + this.deleteCase(caseName); + } + return; + } const option = event.target.closest?.('.case-combobox-option'); if (option?.dataset?.case) { this.selectQuickStartCase(option.dataset.case); @@ -507,26 +541,10 @@ Object.assign(CodemanApp.prototype, { } }, - /** Send Enter to the active session (phone toolbar button). - * - * MUST go through xterm's onData path, NOT straight to sendInput()/the API. - * With local echo on (the mobile default) the characters you typed are still - * buffered in the LocalEchoOverlay and have NEVER reached the PTY. The onData - * Enter branch (terminal-ui.js) is what flushes that pending text and only - * then sends \r. Send a bare \r instead and you submit an empty line while the - * typed text stays stranded on screen — which reads as "the button does - * nothing". triggerDataEvent replays it exactly as if the key were pressed, - * so overlay flush, flushed-offset cleanup and ordering are all reused. */ + /** Send Enter through the controller-owned draft and delivery path. */ sendEnterKey() { if (!this.activeSessionId) return; - const coreService = this.terminal?._core?.coreService; - if (coreService && typeof coreService.triggerDataEvent === 'function') { - coreService.triggerDataEvent('\r', true); - return; - } - // Fallback only if xterm's private core API moves: correct when local echo - // is off, and still better than doing nothing. - this.sendInput('\r'); + this._terminalInputController?.sendControl('\r'); }, _initRunMode() { @@ -576,13 +594,43 @@ Object.assign(CodemanApp.prototype, { return startNumber; }, + /** + * Launch progress may use the terminal only on the session-less home screen. + * When another session is active, mutating the shared xterm would serialize + * launch chrome into that session's snapshot during the subsequent switch. + */ + _beginSessionLaunchStatus(message, ansiColor = '1;32') { + const ownsTerminal = !this.activeSessionId; + if (ownsTerminal) { + this.terminal.clear(); + this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`); + this.terminal.writeln(''); + } else { + this.showToast?.(message, 'info'); + } + return ownsTerminal; + }, + + _appendSessionLaunchStatus(ownsTerminal, message, ansiColor = '90') { + if (!ownsTerminal || this.activeSessionId) return; + this.terminal.writeln(`\x1b[${ansiColor}m ${message}\x1b[0m`); + }, + + _reportSessionLaunchError(ownsTerminal, message) { + if (ownsTerminal && !this.activeSessionId) { + this.terminal.writeln(`\x1b[1;31m Error: ${message}\x1b[0m`); + } else { + this.showToast?.(message, 'error'); + } + }, + async runClaude() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; const tabCount = Math.min(20, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1)); - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus( + `Starting ${tabCount} Claude session(s) in ${caseName}...` + ); // Focus terminal NOW, in the synchronous user-gesture context (button click). // iOS Safari ignores programmatic focus() after any await, so this must happen // before the first async call. The keyboard opens here and stays open through @@ -665,7 +713,7 @@ Object.assign(CodemanApp.prototype, { await this._ensureCreatedSessionVisible(data.data.sessionId, data.data.session); remoteIds.push(data.data.sessionId); } - this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} remote session(s) ready`); if (remoteIds[0]) { await this.selectSession(remoteIds[0]); this.loadQuickStartCases(); @@ -700,7 +748,7 @@ Object.assign(CodemanApp.prototype, { const modelOverride = globalSettings.claudeModel || (useOpus1m ? 'opus[1m]' : ''); // Step 1: Create all sessions in parallel - this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Creating ${tabCount} session(s)...`); const createPromises = sessionNames.map(name => fetch('/api/sessions', { method: 'POST', @@ -741,12 +789,12 @@ Object.assign(CodemanApp.prototype, { )); // Step 3: Start all sessions in parallel (biggest speedup) - this.terminal.writeln(`\x1b[90m Starting ${tabCount} session(s) in parallel...\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `Starting ${tabCount} session(s) in parallel...`); await Promise.all(sessionIds.map(id => fetch(`/api/sessions/${id}/interactive`, { method: 'POST' }) )); - this.terminal.writeln(`\x1b[90m All ${tabCount} sessions ready\x1b[0m`); + this._appendSessionLaunchStatus(ownsLaunchTerminal, `All ${tabCount} sessions ready`); // Auto-switch to the new session using selectSession (does proper refresh) if (firstSessionId) { @@ -756,7 +804,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -799,9 +847,10 @@ Object.assign(CodemanApp.prototype, { const caseName = document.getElementById('quickStartCase').value || 'testcase'; const shellCount = Math.min(20, Math.max(1, parseInt(document.getElementById('shellCount').value) || 1)); - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;33m Starting ${shellCount} Shell session(s) in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus( + `Starting ${shellCount} Shell session(s) in ${caseName}...`, + '1;33' + ); try { // Get the case path @@ -907,7 +956,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -918,9 +967,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting OpenCode session in ${caseName}...`); // Focus in sync gesture context (see runClaude comment) this.terminal.focus(); @@ -930,8 +977,10 @@ Object.assign(CodemanApp.prototype, { const statusRes = await fetch('/api/opencode/status'); const status = (await statusRes.json()).data; if (!status.available) { - this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m'); + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' + ); return; } } @@ -964,7 +1013,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -975,9 +1024,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Codex session in ${caseName}...`); this.terminal.focus(); try { @@ -985,8 +1032,10 @@ Object.assign(CodemanApp.prototype, { const statusRes = await fetch('/api/codex/status'); const status = (await statusRes.json()).data; if (!status.available) { - this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m'); + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'Codex CLI not found. Install with: npm install -g @openai/codex' + ); return; } } @@ -1003,6 +1052,7 @@ Object.assign(CodemanApp.prototype, { ...(isRemote ? {} : { codexConfig: { dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false, + animations: globalSettings.codexAnimationsEnabled ?? false, renderMode: 'hybrid', }, ...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}), @@ -1021,7 +1071,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -1032,9 +1082,7 @@ Object.assign(CodemanApp.prototype, { const _runLoc = (this.cases || []).find(c => c.name === caseName)?.location; const isRemote = _runLoc === 'remote' || _runLoc === 'docker'; - this.terminal.clear(); - this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`); - this.terminal.writeln(''); + const ownsLaunchTerminal = this._beginSessionLaunchStatus(`Starting Gemini session in ${caseName}...`); this.terminal.focus(); try { @@ -1042,8 +1090,10 @@ Object.assign(CodemanApp.prototype, { const statusRes = await fetch('/api/gemini/status'); const status = (await statusRes.json()).data; if (!status.available) { - this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m'); - this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m'); + this._reportSessionLaunchError( + ownsLaunchTerminal, + 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli' + ); return; } } @@ -1072,7 +1122,7 @@ Object.assign(CodemanApp.prototype, { this.terminal.focus(); } catch (err) { - this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); + this._reportSessionLaunchError(ownsLaunchTerminal, err.message); } }, @@ -1637,6 +1687,23 @@ Object.assign(CodemanApp.prototype, { if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m; }, + editCaseFromPicker(caseName, mobile = false) { + this.selectQuickStartCase(caseName); + if (mobile) this.closeMobileCasePicker(); + + const popover = document.getElementById( + mobile ? 'caseSettingsPopoverMobile' : 'caseSettingsPopover' + ); + // Force the existing toggle onto its open path even if another case's + // settings popover was already visible. + popover?.classList.add('hidden'); + if (mobile) { + this.toggleCaseSettingsMobile(); + } else { + this.toggleCaseSettings(); + } + }, + // ═══════════════════════════════════════════════════════════════ // Create Case Modal // ═══════════════════════════════════════════════════════════════ @@ -2441,25 +2508,41 @@ Object.assign(CodemanApp.prototype, { for (const c of allCases) { const isSelected = c.name === currentCase; html += ` - + + + - + `; } diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 9e0c0d0c..5f393dab 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -347,7 +347,21 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsTerminalWheelLocal').checked = settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false; document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false; - document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false; + const mobileControlsItem = document.getElementById('appSettingsMobileTerminalControlsItem'); + const mobileControlItems = [ + mobileControlsItem, + document.getElementById('appSettingsMobileControlHapticsItem'), + document.getElementById('appSettingsMobileControlSoundItem'), + ]; + for (const item of mobileControlItems) { + if (item) item.style.display = MobileDetection.isTouchDevice() ? '' : 'none'; + } + document.getElementById('appSettingsMobileTerminalControls').checked = + MobileTerminalControls.resolveEnabled(settings, defaults); + document.getElementById('appSettingsMobileControlHaptics').checked = + settings.mobileControlHaptics ?? defaults.mobileControlHaptics ?? true; + document.getElementById('appSettingsMobileControlSound').checked = + settings.mobileControlSound ?? defaults.mobileControlSound ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; // Claude CLI settings const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); @@ -362,6 +376,8 @@ Object.assign(CodemanApp.prototype, { // Codex CLI settings document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked = settings.codexDangerouslyBypassApprovals ?? false; + document.getElementById('appSettingsCodexAnimations').checked = + settings.codexAnimationsEnabled ?? false; // Claude Permissions settings document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false; document.getElementById('appSettingsClaudeModel').value = settings.claudeModel ?? ''; @@ -408,7 +424,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('eventIdleAudio').checked = idlePref.audio ?? false; // Response complete (stop) const stopPref = eventTypes.stop || {}; - document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? true; + document.getElementById('eventStopEnabled').checked = stopPref.enabled ?? false; document.getElementById('eventStopBrowser').checked = stopPref.browser ?? false; document.getElementById('eventStopPush').checked = stopPref.push ?? false; document.getElementById('eventStopAudio').checked = stopPref.audio ?? false; @@ -464,6 +480,7 @@ Object.assign(CodemanApp.prototype, { modal.querySelectorAll('.modal-tabs .modal-tab-btn').forEach(btn => { btn.onclick = () => this.switchSettingsTab(btn.dataset.tab); }); + this._prepareMobileSettingsDescriptions(); modal.classList.add('active'); // Activate focus trap @@ -486,7 +503,178 @@ Object.assign(CodemanApp.prototype, { if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.(); }, + _ensureSettingsDescriptionLayer() { + let layer = document.getElementById('settingsDescriptionLayer'); + if (layer) return layer; + + const modal = document.getElementById('appSettingsModal'); + if (!modal) return null; + + layer = document.createElement('div'); + layer.id = 'settingsDescriptionLayer'; + layer.className = 'settings-description-layer'; + layer.hidden = true; + layer.setAttribute('aria-hidden', 'true'); + + const backdrop = document.createElement('div'); + backdrop.className = 'settings-description-backdrop'; + backdrop.setAttribute('aria-hidden', 'true'); + + const panel = document.createElement('div'); + panel.className = 'settings-description-panel'; + panel.setAttribute('role', 'dialog'); + panel.setAttribute('aria-modal', 'true'); + panel.setAttribute('aria-labelledby', 'settingsDescriptionTitle'); + panel.setAttribute('aria-describedby', 'settingsDescriptionText'); + + const header = document.createElement('div'); + header.className = 'settings-description-header'; + + const title = document.createElement('h4'); + title.id = 'settingsDescriptionTitle'; + + const close = document.createElement('button'); + close.id = 'settingsDescriptionClose'; + close.className = 'settings-description-close'; + close.type = 'button'; + close.setAttribute('aria-label', 'Close setting description'); + close.textContent = '\u00d7'; + + const text = document.createElement('p'); + text.id = 'settingsDescriptionText'; + text.className = 'settings-description-text'; + + header.append(title, close); + panel.append(header, text); + layer.append(backdrop, panel); + modal.appendChild(layer); + + backdrop.addEventListener('click', () => this.closeSettingsDescription()); + close.addEventListener('click', () => this.closeSettingsDescription()); + layer.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + event.stopPropagation(); + this.closeSettingsDescription(); + }); + + return layer; + }, + + _prepareMobileSettingsDescriptions() { + const modal = document.getElementById('appSettingsModal'); + if (!modal) return; + + const isPhone = + typeof MobileDetection !== 'undefined' && + MobileDetection.getDeviceType?.() === 'mobile'; + const items = modal.querySelectorAll('.settings-item[title]'); + + for (const item of items) { + const trigger = item.querySelector('.settings-item-text') || item.querySelector('.settings-item-label'); + item.classList.toggle('settings-item-has-description', isPhone); + if (!trigger) continue; + + trigger.classList.toggle('settings-description-trigger', isPhone); + if (isPhone) { + trigger.setAttribute('role', 'button'); + trigger.setAttribute('tabindex', '0'); + trigger.setAttribute('aria-haspopup', 'dialog'); + trigger.setAttribute('aria-controls', 'settingsDescriptionLayer'); + } else { + trigger.removeAttribute('role'); + trigger.removeAttribute('tabindex'); + trigger.removeAttribute('aria-haspopup'); + trigger.removeAttribute('aria-controls'); + } + } + + if (!isPhone) { + this.closeSettingsDescription(); + return; + } + + this._ensureSettingsDescriptionLayer(); + if (this._settingsDescriptionHandlersReady) return; + this._settingsDescriptionHandlersReady = true; + + modal.addEventListener('click', (event) => { + const item = event.target.closest?.('.settings-item.settings-item-has-description'); + if (!item) return; + if (event.target.closest?.('input, select, textarea, button, a, label, .settings-item-actions')) return; + const trigger = item.querySelector('.settings-description-trigger'); + this.openSettingsDescription(item, trigger); + }); + + modal.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const trigger = event.target.closest?.('.settings-description-trigger'); + if (!trigger) return; + const item = trigger.closest('.settings-item.settings-item-has-description'); + if (!item) return; + event.preventDefault(); + this.openSettingsDescription(item, trigger); + }); + }, + + openSettingsDescription(item, trigger) { + const description = item?.getAttribute?.('title')?.trim(); + if (!description) return; + + const layer = this._ensureSettingsDescriptionLayer(); + const modal = document.getElementById('appSettingsModal'); + const modalContent = modal?.querySelector('.modal-content'); + if (!layer || !modal || !modalContent) return; + + const label = item.querySelector('.settings-item-label')?.textContent?.trim() || 'Setting'; + document.getElementById('settingsDescriptionTitle').textContent = label; + document.getElementById('settingsDescriptionText').textContent = description; + + try { + trigger?.focus?.({ preventScroll: true }); + } catch { + trigger?.focus?.(); + } + + modalContent.inert = true; + modalContent.setAttribute('aria-hidden', 'true'); + layer.hidden = false; + layer.setAttribute('aria-hidden', 'false'); + + const parentTrap = this.activeFocusTrap; + this._settingsDescriptionParentTrap = parentTrap || null; + if (parentTrap) { + parentTrap.element.removeEventListener('keydown', parentTrap.boundHandleKeydown); + } + this._settingsDescriptionFocusTrap = new FocusTrap(layer); + this._settingsDescriptionFocusTrap.activate(); + }, + + closeSettingsDescription() { + const layer = document.getElementById('settingsDescriptionLayer'); + if (!layer || layer.hidden) return; + + const modalContent = document.getElementById('appSettingsModal')?.querySelector('.modal-content'); + if (modalContent) { + modalContent.inert = false; + modalContent.removeAttribute('aria-hidden'); + } + layer.hidden = true; + layer.setAttribute('aria-hidden', 'true'); + + const descriptionTrap = this._settingsDescriptionFocusTrap; + this._settingsDescriptionFocusTrap = null; + descriptionTrap?.deactivate(); + + const parentTrap = this._settingsDescriptionParentTrap; + this._settingsDescriptionParentTrap = null; + if (parentTrap && parentTrap === this.activeFocusTrap) { + parentTrap.element.addEventListener('keydown', parentTrap.boundHandleKeydown); + } + }, + closeAppSettings() { + this.closeSettingsDescription(); document.getElementById('appSettingsModal').classList.remove('active'); // Deactivate focus trap and restore focus @@ -1458,8 +1646,10 @@ Object.assign(CodemanApp.prototype, { localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked, cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, + mobileTerminalControlsEnabled: document.getElementById('appSettingsMobileTerminalControls').checked, + mobileControlHaptics: document.getElementById('appSettingsMobileControlHaptics').checked, + mobileControlSound: document.getElementById('appSettingsMobileControlSound').checked, webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked, - extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, skin: document.getElementById('appSettingsSkin').value, // Claude CLI settings @@ -1467,6 +1657,7 @@ Object.assign(CodemanApp.prototype, { allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), // Codex CLI settings codexDangerouslyBypassApprovals: document.getElementById('appSettingsCodexDangerouslyBypassApprovals').checked, + codexAnimationsEnabled: document.getElementById('appSettingsCodexAnimations').checked, // Claude Permissions settings agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked, claudeModel: document.getElementById('appSettingsClaudeModel').value, @@ -1587,7 +1778,7 @@ Object.assign(CodemanApp.prototype, { audio: document.getElementById('eventSubagentAudio').checked, }, }, - _version: 4, + _version: 5, }; if (this.notificationManager) { this.notificationManager.preferences = notifPrefsToSave; @@ -1610,12 +1801,13 @@ Object.assign(CodemanApp.prototype, { // Apply CJK input visibility immediately this._updateCjkInputState(); - // Apply keyboard bar mode - KeyboardAccessoryBar.setMode(settings.extendedKeyboardBar ? 'extended' : 'simple'); + // Apply both responsive surfaces of the unified mobile terminal controls. + MobileTerminalControls.configureFeedback(settings, this.getDefaultSettings()); + MobileTerminalControls.setEnabled(settings.mobileTerminalControlsEnabled); // Save to server (includes notification prefs for cross-browser persistence). // Strip device-specific DISPLAY keys so they never sync across devices — - // localEcho/cjk/extendedKeyboard/skin are per-platform, and showPlanUsageLimits + // localEcho/cjk/mobile terminal controls/skin are per-platform, and showPlanUsageLimits // is per-device too (desktop can show the usage chip while mobile stays hidden). // webglRendererEnabled is per-device as well (renderer choice is GPU-specific, // and syncing would leak mobile's hidden-checkbox false onto desktop); it's @@ -1627,7 +1819,9 @@ Object.assign(CodemanApp.prototype, { const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, - extendedKeyboardBar: _ekb, + mobileTerminalControlsEnabled: _mtc, + mobileControlHaptics: _mch, + mobileControlSound: _mcs, skin: _skin, language: _language, showPlanUsageLimits: _pul, @@ -1809,6 +2003,9 @@ Object.assign(CodemanApp.prototype, { remoteAutoReconnect: true, // Input gestureControlEnabled: false, + mobileTerminalControlsEnabled: false, + mobileControlHaptics: false, + mobileControlSound: false, // Feature toggles - keep tracking on even on mobile subagentTrackingEnabled: true, subagentActiveTabOnly: true, // Only show subagents for active tab @@ -1939,9 +2136,8 @@ Object.assign(CodemanApp.prototype, { } // File Viewer header button — opt-in, default OFF. Marker class (base is - // display:inline-flex !important); clicking it toggles the file browser panel. - // Default ON (desktop): the folder button is part of the standard header now; - // phones still hide it via mobile.css (btn-file-viewer in the phone-hidden set). + // display:inline-flex !important); phone-width touch layouts keep the header + // action hidden because the repository panel is launched from mobile options. const showFileViewerButton = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true; const fileViewerBtn = document.querySelector('.btn-file-viewer'); if (fileViewerBtn) { @@ -2241,7 +2437,9 @@ Object.assign(CodemanApp.prototype, { 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showLifecycleLog', 'showResponseViewer', 'showRedrawButton', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', - 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar', + 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', + 'mobileTerminalControlsEnabled', 'mobileNavigationPadEnabled', 'extendedKeyboardBar', + 'mobileControlHaptics', 'mobileControlSound', 'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled', 'language', 'terminalWheelLocalScrollback', @@ -2275,7 +2473,8 @@ Object.assign(CodemanApp.prototype, { if (notificationPreferences && this.notificationManager) { const localNotifPrefs = localStorage.getItem(this.notificationManager.getStorageKey()); if (!localNotifPrefs) { - this.notificationManager.preferences = notificationPreferences; + this.notificationManager.preferences = + this.notificationManager.normalizePreferences(notificationPreferences); this.notificationManager.savePreferences(); } } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 67e2a847..526396b0 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -81,6 +81,9 @@ /* Touch target minimum (iOS Human Interface Guidelines) */ --touch-target-min: 44px; + --mobile-toolbar-height: 40px; + --mobile-terminal-accessory-height: 44px; + --mobile-terminal-nav-height: 48px; /* UI font + accent/gradient/hover tokens referenced by the v1.0 override block. These :root values equal the daylight-blue palette so the no-attribute fallback @@ -429,6 +432,17 @@ textarea:focus-visible { font-size: 16px !important; /* prevent iOS auto-zoom on focus */ } +/* Local echo owns IME candidate rendering at the detected CLI prompt. Keep + * xterm's native composition element measurable because it positions the + * hidden textarea used by Android IMEs; display:none collapses that anchor to + * zero height and Gboard stops emitting text mutations after the first char. */ +.touch-device .xterm.codeman-local-echo .composition-view.active { + opacity: 0 !important; + color: transparent !important; + background: transparent !important; + pointer-events: none; +} + /* Session tab focus */ .session-tab:focus-visible { outline: 2px solid var(--accent); @@ -947,7 +961,7 @@ body { color: var(--text); display: flex; align-items: center; - gap: 8px; + gap: 4px; } .tunnel-panel-status { @@ -1216,6 +1230,13 @@ body { transform: rotate(45deg); } +.btn-icon-header.btn-instance-shutdown:hover, +.btn-icon-header.btn-instance-shutdown:active { + color: var(--red); + background: rgba(239, 68, 68, 0.12); + transform: none; +} + /* Admin Panel header button: only shown to admins in multi-user mode (marker class removed by admin-ui.js after identity boot). Deliberately BIG and prominent: it is the entry point to user/permission management. */ @@ -2504,6 +2525,45 @@ body.solo-mode .btn-lifecycle-log { background: transparent !important; } +/* Keep the newest session frame visually stable while xterm parses older + scrollback underneath it. The cover matches only .xterm-screen so the real + scrollbar remains visible and its thumb can shrink as history arrives. */ +.terminal-history-replay-cover { + position: absolute; + z-index: 5; + overflow: hidden; + pointer-events: none; + background: var(--term-bg, #161b23); + opacity: 1; +} + +.terminal-history-replay-frame { + position: absolute !important; + inset: 0 auto auto 0 !important; + pointer-events: none !important; +} + +/* Preserve the last painted terminal rows while a mobile keyboard resize + refits xterm and waits for the foreground TUI's SIGWINCH redraw. */ +.terminal-resize-frame-cover { + position: absolute; + inset: 0; + z-index: 6; + overflow: hidden; + pointer-events: none; + background: var(--term-bg, #161b23); + opacity: 1; +} + +.terminal-resize-frame { + position: absolute !important; + top: 0 !important; + right: auto !important; + bottom: auto !important; + left: 0 !important; + pointer-events: none !important; +} + /* Touch devices: prevent browser from claiming the touch gesture before our JS touchmove handler fires. Without this, the browser starts native scrolling during the first few px of finger travel and ignores our @@ -4053,13 +4113,21 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { display: none; } +.case-combobox-row { + position: relative; + display: flex; + align-items: center; +} + .case-combobox-option { display: grid; grid-template-columns: 18px minmax(0, 1fr); align-items: center; + flex: 1; + min-width: 0; width: 100%; min-height: 34px; - padding: 0.35rem 0.45rem; + padding: 0.35rem 4.2rem 0.35rem 0.45rem; background: transparent; border: 1px solid transparent; border-radius: 6px; @@ -4093,6 +4161,58 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { white-space: nowrap; } +.case-combobox-actions { + position: absolute; + right: 0.3rem; + display: flex; + gap: 0.15rem; + opacity: 0; + pointer-events: none; + transition: opacity var(--transition-smooth); +} + +.case-combobox-row:hover .case-combobox-actions, +.case-combobox-row:focus-within .case-combobox-actions { + opacity: 1; + pointer-events: auto; +} + +.case-combobox-action { + display: inline-flex; + width: 28px; + height: 28px; + padding: 0; + align-items: center; + justify-content: center; + color: var(--text-dim); + background: #242a34; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + cursor: pointer; +} + +.case-combobox-action:hover, +.case-combobox-action:focus-visible { + color: var(--text); + background: #303846; +} + +.case-combobox-action-delete:hover, +.case-combobox-action-delete:focus-visible { + color: #f87171; + border-color: rgba(248, 113, 113, 0.45); +} + +.case-combobox-action svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + .case-combobox-empty { padding: 0.85rem 0.75rem; color: var(--text-muted); @@ -5059,23 +5179,20 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { .mobile-case-item { display: flex; align-items: center; - gap: 12px; - padding: 14px 20px; + gap: 4px; + padding: 6px 12px 6px 8px; background: transparent; - border: none; color: #e5e5e5; - font-size: 0.95rem; - text-align: left; - cursor: pointer; transition: background 0.1s; width: 100%; } -.mobile-case-item:hover { +.mobile-case-item:hover, +.mobile-case-item:focus-within { background: rgba(255, 255, 255, 0.05); } -.mobile-case-item:active { +.mobile-case-item:has(.mobile-case-item-select:active) { background: rgba(255, 255, 255, 0.1); } @@ -5084,6 +5201,23 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { color: #22c55e; } +.mobile-case-item-select { + display: flex; + min-width: 0; + min-height: 44px; + padding: 8px 4px 8px 12px; + align-items: center; + flex: 1; + gap: 12px; + color: inherit; + background: transparent; + border: 0; + font: inherit; + font-size: 0.95rem; + text-align: left; + cursor: pointer; +} + .mobile-case-item-icon { width: 20px; height: 20px; @@ -5120,21 +5254,43 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { opacity: 1; } -.mobile-case-item-delete { - width: 28px; - height: 28px; +.mobile-case-item-actions { + display: flex; + flex-shrink: 0; + gap: 4px; +} + +.mobile-case-item-action { + width: 36px; + height: 36px; + padding: 0; display: flex; align-items: center; justify-content: center; color: var(--text-dim); - opacity: 0.4; flex-shrink: 0; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; transition: all var(--transition-smooth); } +.mobile-case-item-action svg { + width: 16px; + height: 16px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +.mobile-case-item-edit:active { + color: #93c5fd; + background: rgba(59, 130, 246, 0.15); +} + .mobile-case-item-delete:active { - opacity: 1; color: #ef4444; background: rgba(239, 68, 68, 0.15); } @@ -5437,6 +5593,34 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { margin-bottom: 0.5rem; } +.instance-shutdown-copy p { + margin: 0; +} + +.instance-shutdown-note { + margin-top: 0.65rem !important; + color: var(--text-muted); + font-size: 0.8rem; + line-height: 1.45; +} + +.instance-shutdown-status { + margin-top: 0.75rem !important; + color: var(--yellow); + font-size: 0.8rem; +} + +.instance-shutdown-status.error { + color: var(--red); +} + +.instance-shutdown-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding: 0.75rem 1rem 1rem; +} + /* Close Session Options */ .close-options { display: flex; @@ -8404,11 +8588,11 @@ kbd { position: fixed; top: calc(var(--header-height) + 10px); right: 20px; - width: 280px; + width: min(380px, calc(100vw - 40px)); height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px); height: calc(100dvh - var(--header-height) - var(--toolbar-height) - 40px); max-height: 600px; - min-width: 200px; + min-width: 280px; min-height: 300px; background: var(--floating-bg); backdrop-filter: blur(20px); @@ -8450,6 +8634,140 @@ kbd { gap: 0.25rem; } +.file-browser-actions .btn-icon-sm[hidden] { + display: none; +} + +.file-browser-tabs { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + flex-shrink: 0; + border-bottom: 1px solid var(--border); +} + +.file-browser-tab { + min-width: 0; + min-height: 34px; + padding: 0.35rem 0.25rem; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 0.72rem; + cursor: pointer; +} + +.file-browser-tab:hover { + color: var(--text); + background: var(--bg-hover); +} + +.file-browser-tab.active { + color: var(--text); + border-bottom-color: var(--accent); + background: var(--bg-input); +} + +.file-browser-tab-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 17px; + height: 17px; + margin-left: 0.2rem; + padding: 0 0.25rem; + border-radius: 8px; + background: rgba(245, 158, 11, 0.18); + color: #f4bf75; + font-size: 0.62rem; + font-variant-numeric: tabular-nums; +} + +.file-browser-tab-count[hidden], +.file-browser-scope[hidden], +.file-browser-search[hidden] { + display: none; +} + +.file-browser-scope { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.4rem; + padding: 0.4rem; + border-bottom: 1px solid var(--border); + background: rgba(255, 255, 255, 0.015); + flex-shrink: 0; +} + +.file-browser-scope select { + min-width: 0; + width: 100%; + height: 30px; + padding: 0 1.7rem 0 0.45rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-dark); + color: var(--text); + font-size: 0.7rem; + text-overflow: ellipsis; +} + +.file-browser-root { + max-width: 92px; + overflow: hidden; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.62rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.working-directory-form { + display: grid; + gap: 0.55rem; +} + +.working-directory-form label { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 600; +} + +.working-directory-form input { + width: 100%; + min-width: 0; + height: 36px; + padding: 0 0.6rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-dark); + color: var(--text); + font-family: var(--font-mono); + font-size: 0.75rem; +} + +.working-directory-form input:focus { + border-color: var(--accent); + outline: none; +} + +.working-directory-status { + margin: 0; + color: var(--red); + font-size: 0.7rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.working-directory-actions { + display: flex; + justify-content: flex-end; + gap: 0.45rem; + padding-top: 0.35rem; +} + .file-browser-search { padding: 0.4rem; border-bottom: 1px solid var(--border); @@ -8517,6 +8835,178 @@ kbd { font-size: 0.75rem; } +/* Repository changes */ +.repo-change-row, +.repo-commit-summary, +.repo-commit-file { + width: 100%; + border: 0; + background: transparent; + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.repo-change-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 0.45rem; + min-height: 46px; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.045); +} + +.repo-change-row:hover, +.repo-commit-summary:hover, +.repo-commit-file:hover { + background: var(--bg-hover); +} + +.repo-status { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.68rem; + font-weight: 700; +} + +.repo-status-m, +.repo-status-r, +.repo-status-c { + border-color: rgba(96, 165, 250, 0.45); + color: #8fc4ff; +} + +.repo-status-a, +.repo-status-new { + border-color: rgba(74, 222, 128, 0.42); + color: #80db96; +} + +.repo-status-d { + border-color: rgba(248, 113, 113, 0.45); + color: #ff9494; +} + +.repo-status-u { + border-color: rgba(245, 158, 11, 0.48); + color: #f4bf75; +} + +.repo-change-main { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.1rem; +} + +.repo-change-path, +.repo-commit-file > span:last-child { + overflow: hidden; + font-family: var(--font-mono); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.repo-change-stage { + color: var(--text-muted); + font-size: 0.61rem; +} + +.repo-change-stats { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.62rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.repo-add { + color: #80db96; +} + +.repo-del { + color: #ff9494; +} + +/* Repository history */ +.repo-commit { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +.repo-commit-summary { + display: grid; + grid-template-columns: 14px minmax(0, 1fr); + gap: 0.3rem; + min-height: 48px; + padding: 0.38rem 0.5rem; +} + +.repo-commit-chevron { + align-self: center; + color: var(--text-muted); + font-size: 0.55rem; + transition: transform 120ms ease; +} + +.repo-commit.expanded .repo-commit-chevron { + transform: rotate(90deg); +} + +.repo-commit-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.15rem; +} + +.repo-commit-subject { + overflow: hidden; + font-size: 0.72rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.repo-commit-meta { + overflow: hidden; + color: var(--text-muted); + font-size: 0.61rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.repo-commit-meta code { + color: #8fc4ff; +} + +.repo-commit-files { + padding: 0 0 0.3rem 1.1rem; +} + +.repo-commit-file { + display: grid; + grid-template-columns: 22px minmax(0, 1fr); + align-items: center; + gap: 0.4rem; + min-height: 34px; + padding: 0.2rem 0.5rem; +} + +.repo-commit-loading { + padding: 0.45rem 0.6rem 0.55rem 1.6rem; + color: var(--text-muted); + font-size: 0.65rem; +} + /* File Tree Item */ .file-tree-item { display: flex; @@ -8663,9 +9153,43 @@ kbd { .file-preview-actions { display: flex; + align-items: center; gap: 0.25rem; } +.file-preview-mode { + display: inline-flex; + align-items: center; + height: 28px; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.file-preview-mode[hidden] { + display: none; +} + +.file-preview-mode-btn { + height: 100%; + padding: 0 0.48rem; + border: 0; + border-right: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + font-size: 0.65rem; + cursor: pointer; +} + +.file-preview-mode-btn:last-child { + border-right: 0; +} + +.file-preview-mode-btn.active { + background: var(--bg-hover); + color: var(--text); +} + .file-preview-body { flex: 1; overflow: auto; @@ -8688,6 +9212,79 @@ kbd { font-family: inherit; } +.repository-diff { + min-width: 100%; + width: max-content; + padding: 0.35rem 0; + font-family: var(--font-mono); + font-size: 0.72rem; + line-height: 1.45; +} + +.repository-diff-line { + display: grid; + grid-template-columns: 42px 42px minmax(max-content, 1fr); + min-height: 21px; +} + +.repository-diff-line code { + display: block; + min-width: 0; + padding: 0 0.7rem 0 0.35rem; + color: #d8d8df; + font: inherit; + white-space: pre; +} + +.diff-gutter { + padding: 0 0.35rem; + border-right: 1px solid rgba(255, 255, 255, 0.06); + color: #777781; + text-align: right; + user-select: none; + font-variant-numeric: tabular-nums; +} + +.diff-prefix { + display: inline-block; + width: 1.2ch; + user-select: none; +} + +.repository-diff-line.diff-add { + background: rgba(46, 160, 67, 0.16); +} + +.repository-diff-line.diff-add code, +.repository-diff-line.diff-add .diff-gutter { + color: #9be9a8; +} + +.repository-diff-line.diff-del { + background: rgba(248, 81, 73, 0.15); +} + +.repository-diff-line.diff-del code, +.repository-diff-line.diff-del .diff-gutter { + color: #ffb0aa; +} + +.repository-diff-line.diff-hunk { + background: rgba(56, 139, 253, 0.11); +} + +.repository-diff-line.diff-hunk code { + color: #8fc4ff; +} + +.repository-diff-line.diff-meta code { + color: #8b8b95; +} + +.repository-diff-full .diff-context { + background: #111; +} + .file-preview-body img { max-width: 100%; max-height: 100%; @@ -9862,6 +10459,15 @@ kbd { display: none !important; } +/* File browsing remains available from the mobile options surface, but these + cramped/destructive header actions stay off modern wide-phone headers too. */ +@media (max-width: 480px) { + body.touch-device .btn-file-viewer, + body.touch-device .btn-instance-shutdown { + display: none !important; + } +} + /* "Cron" footer-toolbar button — opt-in, hidden by default (enable under App Settings → Display → Header Displays). Toolbar button, not a header icon, so only the hide marker is needed; out-specify any base .btn-toolbar display. */ @@ -10411,6 +11017,27 @@ kbd { line-height: 1.7; } +/* Double-click/double-tap reply copy feedback. `manipulation` keeps panning and + pinch zoom while preventing browser double-tap zoom from stealing the gesture. */ +.response-viewer-body, +.rv-message { + touch-action: manipulation; +} + +.response-viewer-body.rv-copy-feedback, +.rv-message.rv-copy-feedback { + animation: rv-copy-confirm 700ms ease-out; +} + +@keyframes rv-copy-confirm { + from { + box-shadow: inset 0 0 0 2px rgba(109, 219, 127, 0.8); + } + to { + box-shadow: inset 0 0 0 1px rgba(109, 219, 127, 0); + } +} + .rv-text p, .response-viewer-body > p { margin: 0 0 0.85em; @@ -10853,7 +11480,7 @@ kbd { position: fixed; left: var(--safe-area-left); right: var(--safe-area-right); - bottom: calc(var(--safe-area-bottom) + 40px); + bottom: calc(var(--safe-area-bottom) + var(--mobile-toolbar-height)); z-index: 52; display: block; min-height: 34px; @@ -10870,11 +11497,27 @@ kbd { } .touch-device.keyboard-visible #cjkInput.cjk-input-visible { - bottom: calc(var(--safe-area-bottom) + 84px); + bottom: calc( + var(--safe-area-bottom) + var(--mobile-toolbar-height) + + var(--mobile-terminal-accessory-height) + ); } body.touch-device.cjk-input-visible .main { - padding-bottom: calc(84px + var(--safe-area-bottom)); + padding-bottom: calc( + var(--mobile-toolbar-height) + var(--mobile-terminal-accessory-height) + + var(--safe-area-bottom) + ); +} + +/* ═══════════════════════════════════════════════════════════════ + Keyboard-Hidden Terminal Navigation + Keep the dynamically-created control hidden unless mobile.css supplies the + supported <=768px layout and explicitly reveals its .visible state. + ═══════════════════════════════════════════════════════════════ */ + +.mobile-terminal-nav { + display: none; } /* ═══════════════════════════════════════════════════════════════ @@ -10886,10 +11529,10 @@ body.touch-device.cjk-input-visible .main { .keyboard-accessory-bar { display: none; - height: 44px; + height: var(--mobile-terminal-accessory-height); background: var(--floating-bg); border-top: 1px solid var(--control-border); - padding: 6px 8px; + padding: 0 8px; gap: 8px; align-items: center; overflow-x: auto; @@ -10917,13 +11560,56 @@ body.touch-device.cjk-input-visible .main { will-change: transform; } +/* Android scaling, phone landscape, and foldables can expose a desktop-width + viewport. These rules live in the always-loaded stylesheet so keyboard + geometry follows stable handheld identity at every posture. */ +body.handheld-device.keyboard-opening .app, +body.handheld-device.keyboard-visible .app { + position: fixed; + top: 0; + right: 0; + bottom: auto; + left: 0; + height: var(--app-height, 100dvh); +} + +body.handheld-device.keyboard-visible .main { + padding-bottom: 0 !important; +} + +body.handheld-device.keyboard-visible.keyboard-accessory-visible .main { + padding-bottom: var(--mobile-terminal-accessory-height) !important; +} + +body.handheld-device.keyboard-visible .toolbar { + display: none !important; +} + +body.handheld-device.keyboard-visible .keyboard-accessory-bar { + position: absolute; + bottom: 0 !important; + transform: none !important; +} + +body.handheld-device.keyboard-visible .keyboard-accessory-bar.visible { + display: flex !important; +} + +body.touch-device.handheld-device.keyboard-visible #cjkInput.cjk-input-visible { + position: static; + flex-shrink: 0; + transform: none !important; +} + .accessory-btn { display: inline-flex; + min-width: 40px; + height: 100%; align-items: center; justify-content: center; flex-shrink: 0; gap: 4px; - padding: 6px 12px; + padding: 0 8px; background: var(--control-bg); border: 1px solid var(--control-border); border-radius: 6px; @@ -10950,12 +11636,17 @@ body.touch-device.cjk-input-visible .main { } .accessory-btn-arrow { - padding: 6px 10px; + padding: 0 6px; background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } +.keyboard-accessory-bar [data-action='opt-enter'] { + min-width: 58px; + padding: 0 6px; +} + .accessory-btn-arrow:active { background: var(--accent-hover); } @@ -10964,7 +11655,7 @@ body.touch-device.cjk-input-visible .main { margin-left: auto; flex: 1 1 0; max-width: 100px; - padding: 10px 8px; + padding: 0 8px; background: var(--accent); border-color: var(--accent); color: var(--accent-ink); diff --git a/src/web/public/terminal-input-controller.js b/src/web/public/terminal-input-controller.js new file mode 100644 index 00000000..3a90162e --- /dev/null +++ b/src/web/public/terminal-input-controller.js @@ -0,0 +1,1025 @@ +/** + * @fileoverview Single pre-delivery controller for terminal text input. + * + * Browser/xterm/CJK/accessory adapters feed semantic input into this class. + * It owns transient composition state, alternate-path deduplication, local + * echo mutations, helper-textarea lifecycle, and final delivery ordering. + * Durable draft persistence and exactly-once transport remain injected ports. + * + * @globals {TerminalInputController} + * @dependency None + * @loadorder 5.58 of 16 - loaded after terminal-input-state.js, before app.js + */ + +class TerminalInputController { + constructor(options = {}) { + this._textarea = options.textarea || null; + this._terminal = options.terminal || null; + this._overlay = options.overlay || null; + this._getOverlay = typeof options.getOverlay === 'function' ? options.getOverlay : () => this._overlay; + this._getSessionId = typeof options.getSessionId === 'function' ? options.getSessionId : () => ''; + this._getSessionMode = typeof options.getSessionMode === 'function' ? options.getSessionMode : () => ''; + this._isLocalEchoEnabled = + typeof options.isLocalEchoEnabled === 'function' ? options.isLocalEchoEnabled : () => false; + this._isRestoringDraft = typeof options.isRestoringDraft === 'function' ? options.isRestoringDraft : () => false; + this._captureDraft = typeof options.captureDraft === 'function' ? options.captureDraft : () => {}; + this._setDraft = typeof options.setDraft === 'function' ? options.setDraft : () => {}; + this._clearDraft = typeof options.clearDraft === 'function' ? options.clearDraft : () => {}; + this._deliver = typeof options.deliver === 'function' ? options.deliver : () => {}; + this._preparePaste = typeof options.preparePaste === 'function' ? options.preparePaste : (text) => text; + this._sendNamedKey = typeof options.sendNamedKey === 'function' ? options.sendNamedKey : () => {}; + this._trace = typeof options.trace === 'function' ? options.trace : () => {}; + this._log = typeof options.log === 'function' ? options.log : () => {}; + this._setTimer = + typeof options.setTimer === 'function' + ? options.setTimer + : (callback, delay) => globalThis.setTimeout(callback, delay); + this._clearTimer = + typeof options.clearTimer === 'function' ? options.clearTimer : (timer) => globalThis.clearTimeout(timer); + this._now = typeof options.now === 'function' ? options.now : () => globalThis.performance?.now?.() ?? Date.now(); + this._commitTimeoutMs = Number.isFinite(options.commitTimeoutMs) ? options.commitTimeoutMs : 80; + this._onTab = typeof options.onTab === 'function' ? options.onTab : null; + + this._compositionActive = false; + this._compositionPending = false; + this._compositionEpoch = 0; + this._expectedCommit = null; + this._fallbackCommit = null; + this._fallbackSessionId = null; + this._compositionCommitTimer = null; + this._pendingDelivery = ''; + this._pendingDeliverySessionId = null; + this._deliveryFlushTimer = null; + this._lastKeystrokeTime = 0; + this._compositionInputCommittedEpoch = -1; + this._ignoredCompositionEndEpoch = -1; + this._helperMutationSnapshot = null; + this._lastKeydownHandledAt = -Infinity; + this._lastBackspaceKeydownAt = -Infinity; + this._lastMobileEnterKeydownAt = -Infinity; + this._mobileLineBreakPending = false; + this._mobileLineBreakFallbackTimer = null; + this._textareaListeners = []; + this._lastRoutedPaste = ''; + this._lastRoutedPasteAt = 0; + this._lastRoutedPasteSource = ''; + this._multipartPasteUntil = 0; + this._multipartPasteCandidateUntil = 0; + } + + get state() { + return { + compositionActive: this._compositionActive, + compositionPending: this._compositionPending, + compositionEpoch: this._compositionEpoch, + expectedCommit: this._expectedCommit, + fallbackCommit: this._fallbackCommit, + fallbackSessionId: this._fallbackSessionId, + pendingDelivery: this._pendingDelivery, + }; + } + + beginComposition() { + this._clearMobileLineBreakTimer(); + this._mobileLineBreakPending = false; + this._compositionActive = true; + this._compositionEpoch += 1; + this._compositionInputCommittedEpoch = -1; + this._helperMutationSnapshot = null; + this._compositionPending = false; + this._expectedCommit = null; + this.clearCompositionDelivery(); + this._clearCompositionTimer(); + const overlay = this._getOverlay(); + if (this._isLocalEchoEnabled()) { + overlay?.setCompositionText?.(''); + this._captureDraft(); + } + this._trace('compositionstart', { + epoch: this._compositionEpoch, + helperLen: this._textarea?.value?.length || 0, + pendingLen: overlay?.pendingText?.length || 0, + local: !!this._isLocalEchoEnabled(), + }); + } + + updateComposition(text) { + if (!this._isLocalEchoEnabled()) return; + this._getOverlay()?.setCompositionText?.(typeof text === 'string' ? text : ''); + this._captureDraft(); + } + + endComposition(text) { + const belongsToKnownComposition = + this._compositionActive || + this._compositionInputCommittedEpoch === this._compositionEpoch || + this._ignoredCompositionEndEpoch === this._compositionEpoch || + this._mobileLineBreakPending; + if (!belongsToKnownComposition) { + this._trace('compositionend-orphan-drop', { + epoch: this._compositionEpoch, + dataLen: typeof text === 'string' ? text.length : 0, + }); + this._resetHelperTextarea(); + return; + } + this._compositionActive = false; + const overlay = this._getOverlay(); + const fallbackText = (typeof text === 'string' && text) || overlay?.compositionText || ''; + this._expectedCommit = this._isLocalEchoEnabled() && fallbackText ? fallbackText : null; + this._compositionPending = true; + this._clearCompositionTimer(); + this._trace('compositionend', { + epoch: this._compositionEpoch, + dataLen: typeof text === 'string' ? text.length : 0, + helperLen: this._textarea?.value?.length || 0, + pendingLen: overlay?.pendingText?.length || 0, + local: !!this._isLocalEchoEnabled(), + }); + if (this._ignoredCompositionEndEpoch === this._compositionEpoch) { + this._ignoredCompositionEndEpoch = -1; + this._compositionPending = false; + this._expectedCommit = null; + return; + } + if (this._compositionInputCommittedEpoch === this._compositionEpoch) { + this._compositionInputCommittedEpoch = -1; + this._compositionPending = false; + this._expectedCommit = null; + this._clearCompositionTimer(); + return; + } + + const endedEpoch = this._compositionEpoch; + if (!this._isLocalEchoEnabled()) { + this._compositionCommitTimer = this._setTimer(() => { + this._compositionCommitTimer = null; + if (endedEpoch === this._compositionEpoch) { + this._compositionPending = false; + } + }, this._commitTimeoutMs); + return; + } + + if (this._mobileLineBreakPending) { + this._mobileLineBreakPending = false; + this._clearMobileLineBreakTimer(); + this.insertDraftLineBreak(fallbackText); + return; + } + + this._compositionCommitTimer = this._setTimer(() => { + this._compositionCommitTimer = null; + if (!this._compositionPending || endedEpoch !== this._compositionEpoch) { + return; + } + this.commitCompositionFallback(fallbackText); + }, this._commitTimeoutMs); + } + + setCompositionPending(value, expectedText = null) { + this._compositionPending = value === true; + this._expectedCommit = + this._compositionPending && typeof expectedText === 'string' && expectedText ? expectedText : null; + if (!this._compositionPending) this._clearCompositionTimer(); + } + + commitCompositionFallback(text) { + if (!this._compositionPending) return false; + const overlay = this._getOverlay(); + const finalText = (typeof text === 'string' && text) || overlay?.compositionText || ''; + if (!finalText) { + this._compositionPending = false; + this._expectedCommit = null; + overlay?.clearComposition?.(); + this._resetHelperTextarea(); + this._captureDraft(); + return false; + } + this._acceptComposition(finalText); + return true; + } + + handleTerminalData(data, source = null) { + const sessionId = this._getSessionId(); + if (!sessionId || !data) return false; + const localEcho = !!this._isLocalEchoEnabled(); + const firstCode = data.charCodeAt(0); + const isCompositionText = data !== '\x7f' && firstCode >= 32; + const inputKind = this._classifyData(data); + const actualSource = source || 'xterm'; + + this._trace('ondata', { + source: actualSource, + kind: inputKind, + len: data.length, + local: localEcho, + pendingLen: this._getOverlay()?.pendingText?.length || 0, + markerLen: this._fallbackCommit?.length || 0, + markerEq: data === this._fallbackCommit, + compositionPending: this._compositionPending, + expectedLen: this._expectedCommit?.length || 0, + }); + + if (this._fallbackCommit !== null && this._fallbackSessionId !== sessionId) { + this._trace('marker-clear', { reason: 'session' }); + this.clearCompositionDelivery(); + } + + if (localEcho && this._compositionPending && data === '\x7f') { + this._compositionPending = false; + this._compositionActive = false; + this._expectedCommit = null; + this._clearCompositionTimer(); + this._getOverlay()?.clearComposition?.(); + this.clearCompositionDelivery(); + this._resetHelperTextarea(); + this._trace('composition-cancel', { reason: 'delete' }); + } + + if (localEcho && this._compositionActive && isCompositionText) { + this._trace('composition-interim-drop', { + source: actualSource, + len: data.length, + }); + return true; + } + + const fallbackCommit = this._fallbackCommit; + if (fallbackCommit !== null && data === fallbackCommit) { + this._trace('marker-drop', { + reason: 'match', + len: data.length, + }); + this.clearCompositionDelivery(); + return true; + } + + if (fallbackCommit !== null) { + const acceptsComposition = + data.length === 1 && data !== '\x7f' && (/\s/.test(data) || (firstCode >= 32 && !/[\p{L}\p{N}]/u.test(data))); + if (!acceptsComposition) { + this._trace('marker-clear', { + reason: 'substantive', + len: data.length, + kind: inputKind, + }); + this.clearCompositionDelivery(); + } else { + this._trace('marker-keep', { + reason: 'boundary', + kind: inputKind, + }); + } + } + + if (!localEcho && this._compositionPending && isCompositionText) { + this._compositionPending = false; + this._clearCompositionTimer(); + this._rememberCompositionDelivery(data); + this._resetHelperTextarea(); + this._trace('marker-remember', { + source: 'shell', + len: data.length, + }); + } + + if (localEcho) { + return this._handleLocalEchoData(data, isCompositionText, actualSource, sessionId); + } + + this._queueNormalDelivery(data, sessionId); + return true; + } + + sendControl(data) { + if (!data || !this._getSessionId()) return; + if (this._isLocalEchoEnabled()) { + const compositionText = this._getOverlay()?.compositionText || ''; + if (compositionText) { + this.setCompositionPending(true, compositionText); + this.commitCompositionFallback(compositionText); + } + if (this._terminal?.input) { + this._terminal.input(data); + } else { + this.handleTerminalData(data, 'terminal-control'); + } + return; + } + this._flushDelivery(); + this._deliver(this._getSessionId(), data); + } + + insertText(text) { + const sessionId = this._getSessionId(); + if (!sessionId || !text) return; + if (this._isLocalEchoEnabled()) { + this._getOverlay()?.appendText?.(text); + this._captureDraft(); + return; + } + this._flushDelivery(); + this._deliver(sessionId, text); + } + + sendExternalText(text, options = {}) { + const sessionId = this._getSessionId(); + if (!sessionId || !text) return; + this._flushDelivery(); + this.clearCompositionDelivery(); + this._deliver(sessionId, text, { + useMux: options.useMux !== false, + }); + } + + sendCommand(command) { + if (!command || !this._getSessionId()) return; + this.insertText(command); + this.sendControl('\r'); + } + + sendPaste(text, options = {}) { + const sessionId = this._getSessionId(); + if (!sessionId || !text) return; + const mode = this._getSessionMode(); + const input = this._preparePaste(String(text), mode !== 'shell'); + this._flushDelivery(); + this._deliver(sessionId, input, { useMux: false }); + if (options.submit) { + this._deliver(sessionId, '\r', { useMux: false }); + } + } + + sendModifiedEnter(keyName) { + const sessionId = this._getSessionId(); + if (!sessionId || !keyName) return; + const overlay = this._getOverlay(); + if (!this._isLocalEchoEnabled()) { + this._flushDelivery(); + this._sendNamedKey(sessionId, keyName, 0); + return; + } + + const compositionText = overlay?.compositionText || ''; + if (compositionText) { + this.setCompositionPending(true, compositionText); + this.commitCompositionFallback(compositionText); + } + const text = overlay?.pendingText || ''; + overlay?.clear?.(); + overlay?.suppressBufferDetection?.(); + this._setDraft(sessionId, { + pendingText: '', + flushedText: text + '\n', + cjkText: '', + updatedAt: Date.now(), + }); + if (text) this._deliver(sessionId, text); + this._sendNamedKey(sessionId, keyName, text ? 80 : 0); + this._resetHelperTextarea(); + } + + insertDraftLineBreak(fallbackText = '') { + const sessionId = this._getSessionId(); + if (!sessionId) return; + const overlay = this._getOverlay(); + if (!this._isLocalEchoEnabled()) { + this._resetHelperTextarea(); + this.sendControl('\r'); + return; + } + const compositionText = fallbackText || overlay?.compositionText || ''; + if (compositionText) { + this.setCompositionPending(true, compositionText); + this.commitCompositionFallback(compositionText); + } else { + overlay?.clearComposition?.(); + } + overlay?.appendText?.('\n'); + this._captureDraft(); + this._resetHelperTextarea(); + } + + clearInput() { + const sessionId = this._getSessionId(); + if (!sessionId) return; + this.clearDeliveryBuffer(); + this._resetCompositionState(); + + const overlay = this._getOverlay(); + if (this._isLocalEchoEnabled() && overlay) { + const flushed = overlay.getFlushed?.() || { + count: 0, + text: '', + }; + overlay.clear?.(); + overlay.suppressBufferDetection?.(); + this._clearDraft(sessionId); + if (flushed.count > 0) { + this._deliver(sessionId, '\x7f'.repeat(flushed.count), { useMux: true }); + } + } else { + this._deliver(sessionId, '\x15', { useMux: true }); + } + this._resetHelperTextarea(); + } + + clearDeliveryBuffer() { + this._pendingDelivery = ''; + this._pendingDeliverySessionId = null; + if (this._deliveryFlushTimer !== null) { + this._clearTimer(this._deliveryFlushTimer); + this._deliveryFlushTimer = null; + } + } + + clearCompositionDelivery() { + this._fallbackCommit = null; + this._fallbackSessionId = null; + } + + reset(options = {}) { + if (options.flushDelivery !== false) { + this._cancelDeliveryFlush(); + this._flushDelivery(); + } else { + this.clearDeliveryBuffer(); + } + this._resetCompositionState(); + this._lastRoutedPaste = ''; + this._lastRoutedPasteAt = 0; + this._lastRoutedPasteSource = ''; + this._multipartPasteUntil = 0; + this._multipartPasteCandidateUntil = 0; + this._resetHelperTextarea(); + } + + attachTextarea(container, options = {}) { + if (!this._textarea || !container || this._textareaListeners.length) { + return false; + } + const mobile = options.mobile === true; + const on = (target, type, handler, listenerOptions) => { + target.addEventListener(type, handler, listenerOptions); + this._textareaListeners.push({ + target, + type, + handler, + listenerOptions, + }); + }; + + if (mobile) { + this._attachMobileCompositionListeners(container, on); + } + this._attachPasteListeners(on, { + segmentedFallback: mobile, + }); + return true; + } + + detachTextarea() { + for (const { target, type, handler, listenerOptions } of this._textareaListeners) { + target.removeEventListener(type, handler, listenerOptions); + } + this._textareaListeners = []; + this._clearMobileLineBreakTimer(); + } + + destroy() { + this.detachTextarea(); + this.reset(); + } + + _attachMobileCompositionListeners(container, on) { + const textarea = this._textarea; + + on(textarea, 'compositionstart', () => { + this.beginComposition(); + }); + on(textarea, 'compositionupdate', (event) => { + this.updateComposition(event.data || ''); + }); + on(textarea, 'compositionend', (event) => { + this.endComposition(event.data || ''); + }); + on(textarea, 'keydown', (event) => { + if (event.key === 'Enter') { + this._lastMobileEnterKeydownAt = this._now(); + } + if (!event.isComposing && event.keyCode === 229) { + this._helperMutationSnapshot = this._captureHelperMutationSnapshot(); + } + if (!event.isComposing && event.keyCode !== 229) { + this._lastKeydownHandledAt = this._now(); + if (event.key === 'Backspace') { + this._lastBackspaceKeydownAt = this._now(); + } + } + }); + on( + textarea, + 'beforeinput', + (event) => { + if (!event.isComposing && (event.inputType === 'insertText' || event.inputType === 'insertReplacementText')) { + this._helperMutationSnapshot = this._captureHelperMutationSnapshot(); + } + + const isLineBreak = event.inputType === 'insertLineBreak' || event.inputType === 'insertParagraph'; + const followsMobileEnter = isLineBreak && this._now() - this._lastMobileEnterKeydownAt < 500; + if (followsMobileEnter && this._getSessionId()) { + event.preventDefault(); + event.stopImmediatePropagation(); + this._lastMobileEnterKeydownAt = -Infinity; + if (!this._isLocalEchoEnabled()) { + this.insertDraftLineBreak(); + return; + } + if (this._compositionActive || event.isComposing) { + this._mobileLineBreakPending = true; + const lineBreakEpoch = this._compositionEpoch; + const fallbackText = this._getOverlay()?.compositionText || ''; + this._clearMobileLineBreakTimer(); + this._mobileLineBreakFallbackTimer = this._setTimer(() => { + this._mobileLineBreakFallbackTimer = null; + if (!this._mobileLineBreakPending || lineBreakEpoch !== this._compositionEpoch) { + return; + } + this._mobileLineBreakPending = false; + this._ignoredCompositionEndEpoch = lineBreakEpoch; + this._compositionActive = false; + this.insertDraftLineBreak(fallbackText); + }, this._commitTimeoutMs); + } else { + this.insertDraftLineBreak(); + } + return; + } + + if ( + this._compositionActive || + event.isComposing || + !this._getSessionId() || + (event.inputType !== 'deleteContentBackward' && event.inputType !== 'deleteWordBackward') + ) { + return; + } + event.preventDefault(); + event.stopImmediatePropagation(); + this._resetHelperTextarea(); + if (this._now() - this._lastBackspaceKeydownAt < 50) { + return; + } + this.handleTerminalData('\x7f', 'beforeinput-delete'); + }, + true + ); + on( + container, + 'input', + (event) => { + if ( + event.target !== textarea || + (event.inputType !== 'insertText' && event.inputType !== 'insertReplacementText') + ) { + return; + } + + event.stopPropagation(); + this._trace('input-capture', { + type: event.inputType || 'none', + eventComp: !!event.isComposing, + stateComp: this._compositionActive, + eventLen: typeof event.data === 'string' ? event.data.length : -1, + helperLen: textarea.value.length, + snapshotLen: this._helperMutationSnapshot?.value?.length ?? -1, + pendingLen: this._getOverlay()?.pendingText?.length || 0, + markerLen: this._fallbackCommit?.length || 0, + }); + if (event.isComposing) return; + + const snapshot = this._helperMutationSnapshot; + this._helperMutationSnapshot = null; + const mutation = this._deriveTextareaMutation(snapshot, textarea.value); + const eventData = typeof event.data === 'string' ? event.data : ''; + const data = snapshot + ? mutation.insertedText || eventData + : eventData || mutation.insertedText || textarea.value; + this._trace('input-route', { + eventLen: eventData.length, + insertedLen: mutation.insertedText.length, + removedLen: mutation.removedText.length, + routeLen: data.length, + markerLen: this._fallbackCommit?.length || 0, + markerEq: data === this._fallbackCommit, + pendingLen: this._getOverlay()?.pendingText?.length || 0, + }); + if (!data && !mutation.removedText) return; + + const finalizingComposition = this._compositionActive; + if (finalizingComposition) { + this._compositionActive = false; + this._compositionInputCommittedEpoch = this._compositionEpoch; + this._compositionPending = true; + this._expectedCommit = this._getOverlay()?.compositionText || null; + this._clearCompositionTimer(); + } + + if (!finalizingComposition && this._now() - this._lastKeydownHandledAt < 50) { + this._resetHelperTextarea(); + return; + } + + const pendingText = this._getOverlay()?.pendingText || ''; + const shouldApplyReplacement = + mutation.removedText && (!this._isLocalEchoEnabled() || pendingText.endsWith(mutation.removedText)); + + this._resetHelperTextarea(); + if (shouldApplyReplacement) { + for (const _char of mutation.removedText) { + this.handleTerminalData('\x7f', 'capture-replacement'); + } + } + if (data) { + this.handleTerminalData(data, 'capture-input'); + } + }, + true + ); + } + + _attachPasteListeners(on, options) { + const textarea = this._textarea; + const multipartGapMs = 40; + const readPasteText = (event) => { + const candidates = [ + event.clipboardData?.getData?.('text/plain'), + event.dataTransfer?.getData?.('text/plain'), + typeof event.data === 'string' ? event.data : '', + textarea.value, + ]; + return candidates.reduce( + (longest, value) => (typeof value === 'string' && value.length > longest.length ? value : longest), + '' + ); + }; + const routeTextPaste = (event, source, explicitText) => { + const sessionId = this._getSessionId(); + if (!sessionId || this._getSessionMode() === 'shell') { + return false; + } + const text = explicitText ?? readPasteText(event); + if (!text) return false; + event.preventDefault(); + event.stopImmediatePropagation(); + this._resetHelperTextarea(); + const now = this._now(); + const duplicateInputAfterCapture = + text === this._lastRoutedPaste && + now - this._lastRoutedPasteAt < 100 && + source.startsWith('input:') && + (this._lastRoutedPasteSource === 'clipboard' || this._lastRoutedPasteSource.startsWith('beforeinput:')); + if (duplicateInputAfterCapture) return true; + + this._lastRoutedPaste = text; + this._lastRoutedPasteAt = now; + this._lastRoutedPasteSource = source; + const lineBreaks = text.match(/\r\n|\r|\n/g)?.length || 0; + this._log(`XTERM_PASTE source=${source} len=${text.length} breaks=${lineBreaks}`); + if (this._isLocalEchoEnabled()) { + this.insertText(text); + } else { + this.sendPaste(text); + } + return true; + }; + const routeInputPasteMutation = (event, phase) => { + const inputType = event.inputType || ''; + const mutationText = typeof event.data === 'string' ? event.data : ''; + const now = this._now(); + const continuesMultipartPaste = now <= this._multipartPasteUntil; + + if (inputType === 'insertFromPaste') { + if (routeTextPaste(event, `${phase}:paste`)) { + this._multipartPasteCandidateUntil = 0; + this._multipartPasteUntil = now + multipartGapMs; + } + return; + } + + if (options.segmentedFallback && inputType === 'insertText' && mutationText) { + if (!continuesMultipartPaste) { + this._multipartPasteCandidateUntil = mutationText.length > 1 ? now + multipartGapMs : 0; + return; + } + if (routeTextPaste(event, `${phase}:text`, mutationText)) { + this._multipartPasteUntil = now + multipartGapMs; + } + return; + } + if ( + options.segmentedFallback && + (continuesMultipartPaste || now <= this._multipartPasteCandidateUntil) && + (inputType === 'insertLineBreak' || inputType === 'insertParagraph') + ) { + if (routeTextPaste(event, `${phase}:break`, '\n')) { + this._multipartPasteCandidateUntil = 0; + this._multipartPasteUntil = now + multipartGapMs; + } + return; + } + this._multipartPasteCandidateUntil = 0; + }; + + on( + textarea, + 'paste', + (event) => { + routeTextPaste(event, 'clipboard'); + }, + true + ); + on( + textarea, + 'beforeinput', + (event) => { + routeInputPasteMutation(event, 'beforeinput'); + }, + true + ); + on( + textarea, + 'input', + (event) => { + routeInputPasteMutation(event, 'input'); + }, + true + ); + } + + _captureHelperMutationSnapshot() { + const value = this._textarea?.value || ''; + const start = this._textarea?.selectionStart ?? value.length; + return { + value, + start, + end: this._textarea?.selectionEnd ?? this._textarea?.selectionStart ?? value.length, + }; + } + + _deriveTextareaMutation(snapshot, currentValue) { + const current = String(currentValue ?? ''); + if (!snapshot) { + return { + insertedText: current, + removedText: '', + }; + } + const previous = String(snapshot.value ?? ''); + const start = Math.max(0, Math.min(previous.length, snapshot.start ?? previous.length)); + const end = Math.max(start, Math.min(previous.length, snapshot.end ?? start)); + const prefix = previous.slice(0, start); + const suffix = previous.slice(end); + if (current.startsWith(prefix) && current.endsWith(suffix) && current.length >= prefix.length + suffix.length) { + return { + insertedText: current.slice(prefix.length, current.length - suffix.length), + removedText: previous.slice(start, end), + }; + } + + let commonPrefix = 0; + while ( + commonPrefix < previous.length && + commonPrefix < current.length && + previous[commonPrefix] === current[commonPrefix] + ) { + commonPrefix += 1; + } + let commonSuffix = 0; + while ( + commonSuffix < previous.length - commonPrefix && + commonSuffix < current.length - commonPrefix && + previous[previous.length - 1 - commonSuffix] === current[current.length - 1 - commonSuffix] + ) { + commonSuffix += 1; + } + return { + insertedText: current.slice(commonPrefix, current.length - commonSuffix), + removedText: previous.slice(commonPrefix, previous.length - commonSuffix), + }; + } + + _handleLocalEchoData(data, isCompositionText, source, sessionId) { + const overlay = this._getOverlay(); + + if (this._compositionPending && isCompositionText) { + const expectedCommit = this._expectedCommit; + const hasStaleHelperPrefix = + expectedCommit && data.length > expectedCommit.length && data.endsWith(expectedCommit); + const finalText = hasStaleHelperPrefix ? expectedCommit : data; + if (hasStaleHelperPrefix) { + this._trace('composition-strip-stale-prefix', { + payloadLen: data.length, + finalLen: finalText.length, + }); + } + this._trace('composition-accept', { + source, + len: finalText.length, + }); + this._acceptComposition(finalText); + return true; + } + + if (data === '\x7f') { + const removedFrom = overlay?.removeChar?.(); + if (removedFrom !== 'pending') { + this._deliver(sessionId, data); + } + this._captureDraft(); + return true; + } + + if (/^[\r\n]+$/.test(data)) { + const text = overlay?.pendingText || ''; + if (text) { + const lineBreaks = text.match(/\r\n|\r|\n/g)?.length || 0; + this._log(`LOCAL_ECHO_SUBMIT len=${text.length} breaks=${lineBreaks}`); + } + overlay?.clear?.(); + overlay?.suppressBufferDetection?.(); + this._clearDraft(sessionId); + this.clearDeliveryBuffer(); + if (text) { + const mode = this._getSessionMode(); + const input = /[\r\n]/.test(text) && mode !== 'shell' ? this._preparePaste(text, true) : text; + this._deliver(sessionId, input); + } + this._deliver(sessionId, '\r'); + this._resetHelperTextarea(); + return true; + } + + if (data.length > 1 && data.charCodeAt(0) >= 32) { + overlay?.appendText?.(data); + this._captureDraft(); + return true; + } + + if (data.charCodeAt(0) < 32) { + if (data.length > 1 && data.charCodeAt(0) === 27) { + this._deliver(sessionId, data); + return true; + } + if (this._isRestoringDraft()) { + this._deliver(sessionId, data); + return true; + } + if (data === '\t' && this._onTab) { + const handled = + this._onTab({ + controller: this, + overlay, + sessionId, + text: overlay?.pendingText || '', + }) !== false; + if (handled) this._resetHelperTextarea(); + return handled; + } + const text = overlay?.pendingText || ''; + overlay?.clear?.(); + overlay?.suppressBufferDetection?.(); + this._clearDraft(sessionId); + if (text) this._deliver(sessionId, text); + this._deliver(sessionId, data); + this._resetHelperTextarea(); + return true; + } + + if (data.length === 1 && data.charCodeAt(0) >= 32) { + overlay?.addChar?.(data); + this._captureDraft(); + return true; + } + + return false; + } + + _queueNormalDelivery(data, sessionId) { + if (this._pendingDeliverySessionId && this._pendingDeliverySessionId !== sessionId) { + this._flushDelivery(); + } + this._pendingDeliverySessionId = sessionId; + this._pendingDelivery += data; + + if (data.charCodeAt(0) < 32 || data.length > 1) { + this._cancelDeliveryFlush(); + this._flushDelivery(); + return; + } + + const now = this._now(); + if (now - this._lastKeystrokeTime > 50) { + this._cancelDeliveryFlush(); + this._lastKeystrokeTime = now; + this._flushDelivery(); + return; + } + + this._lastKeystrokeTime = now; + if (this._deliveryFlushTimer === null) { + this._deliveryFlushTimer = this._setTimer(() => { + this._deliveryFlushTimer = null; + this._flushDelivery(); + }, 0); + } + } + + _flushDelivery() { + if (!this._pendingDelivery) return; + const sessionId = this._pendingDeliverySessionId || this._getSessionId(); + const data = this._pendingDelivery; + this._pendingDelivery = ''; + this._pendingDeliverySessionId = null; + if (sessionId) this._deliver(sessionId, data); + } + + _cancelDeliveryFlush() { + if (this._deliveryFlushTimer === null) return; + this._clearTimer(this._deliveryFlushTimer); + this._deliveryFlushTimer = null; + } + + _acceptComposition(finalText) { + this._compositionPending = false; + this._expectedCommit = null; + this._clearCompositionTimer(); + this._getOverlay()?.commitComposition?.(finalText); + this._resetHelperTextarea(); + this._captureDraft(); + this._rememberCompositionDelivery(finalText); + } + + _rememberCompositionDelivery(finalText) { + this._fallbackCommit = finalText; + this._fallbackSessionId = this._getSessionId(); + } + + _resetHelperTextarea() { + if (!this._textarea) return; + if (this._textarea.value !== '') { + this._textarea.value = ''; + } + try { + this._textarea.setSelectionRange?.(0, 0); + } catch { + /* hidden textarea may be detached during terminal teardown */ + } + } + + _clearCompositionTimer() { + if (this._compositionCommitTimer === null) return; + this._clearTimer(this._compositionCommitTimer); + this._compositionCommitTimer = null; + } + + _clearMobileLineBreakTimer() { + if (this._mobileLineBreakFallbackTimer === null) { + return; + } + this._clearTimer(this._mobileLineBreakFallbackTimer); + this._mobileLineBreakFallbackTimer = null; + } + + _resetCompositionState() { + this._compositionActive = false; + this._compositionPending = false; + this._expectedCommit = null; + this.clearCompositionDelivery(); + this._clearCompositionTimer(); + this._clearMobileLineBreakTimer(); + this._mobileLineBreakPending = false; + this._compositionInputCommittedEpoch = -1; + this._ignoredCompositionEndEpoch = -1; + this._helperMutationSnapshot = null; + this._lastKeydownHandledAt = -Infinity; + this._lastBackspaceKeydownAt = -Infinity; + this._lastMobileEnterKeydownAt = -Infinity; + this._compositionEpoch += 1; + this._getOverlay()?.clearComposition?.(); + } + + _classifyData(data) { + const firstCode = data.charCodeAt(0); + if (data.length === 1) { + if (firstCode === 127) return 'delete'; + if (firstCode < 32) return 'control'; + if (/\s/.test(data)) return 'boundary'; + return 'printable'; + } + return firstCode === 27 ? 'escape' : 'text'; + } +} + +globalThis.TerminalInputController = TerminalInputController; diff --git a/src/web/public/terminal-input-state.js b/src/web/public/terminal-input-state.js new file mode 100644 index 00000000..64b14a24 --- /dev/null +++ b/src/web/public/terminal-input-state.js @@ -0,0 +1,213 @@ +/** + * @fileoverview Durable editable terminal input state, scoped by session. + * + * This store owns the complete local draft record and its persistence lifecycle. + * It deliberately does not render text or deliver bytes to a PTY. Callers provide + * snapshots from their input adapters and explicitly consume `handoff().flushText` + * through Codeman's separate exactly-once delivery layer. + * + * @globals {TerminalInputStateStore} + * @dependency None + * @loadorder 5.75 of 16 - loaded after input-cjk.js, before app.js + */ + +class TerminalInputStateStore { + constructor(options = {}) { + this._storageKey = options.storageKey || 'codeman:sessionDrafts'; + this._snapshotPrefix = options.snapshotPrefix || 'codeman-xs-'; + this._debounceMs = Number.isFinite(options.debounceMs) ? options.debounceMs : 150; + this._now = typeof options.now === 'function' ? options.now : Date.now; + const setTimer = typeof options.setTimer === 'function' ? options.setTimer : globalThis.setTimeout; + const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : globalThis.clearTimeout; + this._setTimer = (callback, delay) => setTimer(callback, delay); + this._clearTimer = (timer) => clearTimer(timer); + this._storage = Object.prototype.hasOwnProperty.call(options, 'storage') ? options.storage : this._resolveStorage(); + this._drafts = new Map(); + this._persistTimer = null; + this.load(); + } + + capture(sessionId, snapshot, options = {}) { + const source = snapshot && typeof snapshot === 'object' ? snapshot : {}; + return this.set( + sessionId, + { + // OS composition sessions cannot survive a page suspension. Preserve + // the visible candidate as ordinary editable text on capture. + pendingText: this._text(source.pendingText) + this._text(source.compositionText), + flushedText: this._text(source.flushedText), + cjkText: this._text(source.cjkText), + }, + options + ); + } + + handoff(sessionId, snapshot, options = {}) { + const source = snapshot && typeof snapshot === 'object' ? snapshot : {}; + const flushText = this._text(source.pendingText); + const draft = this.set( + sessionId, + { + // A live composition remains editable; only committed pending text is + // flushed into the outgoing session's PTY input buffer. + pendingText: this._text(source.compositionText), + flushedText: this._text(source.flushedText) + flushText, + cjkText: this._text(source.cjkText), + }, + options + ); + return { flushText, draft }; + } + + set(sessionId, candidate, options = {}) { + if (!sessionId) return null; + const draft = this._normalize(candidate); + if (draft) { + this._drafts.set(sessionId, draft); + } else { + this._drafts.delete(sessionId); + } + if (options.persist !== false) this.schedulePersist(); + return this._clone(draft); + } + + get(sessionId) { + return this._clone(this._drafts.get(sessionId) || null); + } + + has(sessionId) { + return this._drafts.has(sessionId); + } + + hasFlushed(sessionId) { + return !!this._drafts.get(sessionId)?.flushedText; + } + + clear(sessionId, options = {}) { + if (!sessionId) return; + this._drafts.delete(sessionId); + if (options.persist !== false) this.schedulePersist(); + } + + clearAll(options = {}) { + this._drafts.clear(); + if (options.persist !== false) this.schedulePersist(); + } + + load() { + this._drafts.clear(); + const storage = this._storage; + if (!storage) return; + try { + const raw = storage.getItem(this._storageKey); + if (!raw) return; + const saved = JSON.parse(raw); + if (!saved || !saved.drafts || typeof saved.drafts !== 'object') return; + for (const [sessionId, candidate] of Object.entries(saved.drafts)) { + const draft = this._normalize(candidate); + if (draft) this._drafts.set(sessionId, draft); + } + } catch { + // Corrupt or disabled storage must not block the terminal from starting. + this._drafts.clear(); + } + } + + schedulePersist() { + if (this._persistTimer !== null) return; + this._persistTimer = this._setTimer(() => { + this._persistTimer = null; + this.persistNow(); + }, this._debounceMs); + } + + persistNow() { + if (this._persistTimer !== null) { + this._clearTimer(this._persistTimer); + this._persistTimer = null; + } + const storage = this._storage; + if (!storage) return false; + + const drafts = {}; + for (const [sessionId, draft] of this._drafts) { + drafts[sessionId] = this._clone(draft); + } + const payload = JSON.stringify({ version: 1, drafts }); + if (this._tryPersist(storage, payload)) return true; + + // Terminal snapshots are reproducible from the server; typed text is not. + // Reclaim snapshot quota one entry at a time before abandoning the draft. + for (const key of this._storageKeys(storage)) { + if (!key.startsWith(this._snapshotPrefix)) continue; + try { + storage.removeItem(key); + } catch { + return false; + } + if (this._tryPersist(storage, payload)) return true; + } + return false; + } + + _normalize(raw) { + if (!raw || typeof raw !== 'object') return null; + const pendingText = this._text(raw.pendingText); + const flushedText = this._text(raw.flushedText); + const cjkText = this._text(raw.cjkText); + if (!pendingText && !flushedText && !cjkText) return null; + return { + pendingText, + flushedText, + cjkText, + updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : this._now(), + }; + } + + _clone(draft) { + return draft + ? { + pendingText: draft.pendingText, + flushedText: draft.flushedText, + cjkText: draft.cjkText, + updatedAt: draft.updatedAt, + } + : null; + } + + _text(value) { + return typeof value === 'string' ? value : ''; + } + + _tryPersist(storage, payload) { + try { + storage.setItem(this._storageKey, payload); + return true; + } catch { + return false; + } + } + + _storageKeys(storage) { + try { + const keys = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (key !== null) keys.push(key); + } + return keys; + } catch { + return []; + } + } + + _resolveStorage() { + try { + return globalThis.localStorage || null; + } catch { + return null; + } + } +} + +globalThis.TerminalInputStateStore = TerminalInputStateStore; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3a9de819..f0f044e9 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -7,25 +7,30 @@ * @dependency app.js (CodemanApp class, this.terminal, this.fitAddon, this.sessions) * @dependency constants.js (DEC_SYNC_STRIP_RE, TIMING constants) * @dependency mobile-handlers.js (MobileDetection) + * @dependency terminal-input-controller.js (TerminalInputController) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.js (LocalEchoOverlay) * @loadorder 7 of 15 — loaded after app.js, before respawn-ui.js */ (function (global) { - const TERMINAL_QUERY_RESPONSE_PATTERN = /^\x1b\[[\?>=]?[\d;]*[cnR]$/; + const TERMINAL_CSI_QUERY_RESPONSE_PATTERN = + /^\x1b\[(?:[\?>=]?[\d;]*[cnR]|\??\d+;[0-4]\$y|[468];[\d;]+t)$/; const TERMINAL_OSC_RESPONSE_PATTERN = /^\x1b\][\d;]*[^\x07\x1b]*(?:\x07|\x1b\\)$/; - // Grace window after a manual scroll-up gesture during which sticky-scroll is - // suppressed, so high-frequency Codex status redraws don't snap the viewport - // back to the bottom while the user is inspecting earlier output. - const USER_SCROLL_STICKY_SUPPRESS_MS = 1500; + const TERMINAL_DCS_RESPONSE_PATTERN = /^\x1bP[\s\S]*\x1b\\$/; // Mobile browsers synthesize trusted mouse events after touchend. During this // short window, only the app's synthetic tap-to-position mouse event should // reach xterm. const TOUCH_COMPAT_MOUSE_SUPPRESS_MS = 450; + const TUI_PROMPT_BOTTOM_BAND_ROWS = 8; + const TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM = 4; function isTerminalQueryResponse(data) { - return TERMINAL_QUERY_RESPONSE_PATTERN.test(data) || TERMINAL_OSC_RESPONSE_PATTERN.test(data); + return ( + TERMINAL_CSI_QUERY_RESPONSE_PATTERN.test(data) || + TERMINAL_OSC_RESPONSE_PATTERN.test(data) || + TERMINAL_DCS_RESPONSE_PATTERN.test(data) + ); } function shouldSuppressTerminalQueryResponse(data) { @@ -60,8 +65,9 @@ global.CodemanTerminalInput = { isTerminalQueryResponse, shouldSuppressTerminalQueryResponse, - USER_SCROLL_STICKY_SUPPRESS_MS, TOUCH_COMPAT_MOUSE_SUPPRESS_MS, + TUI_PROMPT_BOTTOM_BAND_ROWS, + TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -126,6 +132,47 @@ Object.assign(CodemanApp.prototype, { const container = document.getElementById('terminalContainer'); this.terminal.open(container); this._installMobileTapMouseGuard(); + this._terminalInputController?.destroy?.(); + this._terminalInputController = new TerminalInputController({ + textarea: this.terminal.textarea, + terminal: this.terminal, + getOverlay: () => this._localEchoOverlay, + getSessionId: () => this.activeSessionId, + getSessionMode: () => + this.activeSessionId + ? this.sessions?.get(this.activeSessionId)?.mode || '' + : '', + isLocalEchoEnabled: () => this._localEchoEnabled, + isRestoringDraft: () => this._restoringFlushedState, + captureDraft: () => this._captureActiveSessionDraft(), + setDraft: (sessionId, draft) => + this._setSessionDraft(sessionId, draft), + clearDraft: (sessionId) => + this._clearSessionDraft(sessionId), + deliver: (sessionId, data, options) => + this._sendInputAsync(sessionId, data, options), + preparePaste: (text, bracketed) => + this._prepareTerminalPaste(text, bracketed), + sendNamedKey: (sessionId, key, delay) => { + const send = () => + fetch(`/api/sessions/${sessionId}/send-key`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key }), + }).catch(() => {}); + if (delay > 0) { + setTimeout(send, delay); + } else { + send(); + } + }, + onTab: (context) => + this._handleTerminalInputTab(context), + log: (message) => _crashDiag.log(message), + }); + this._terminalInputController.attachTextarea(container, { + mobile: MobileDetection.isTouchDevice(), + }); // Suppress xterm key handling during CJK IME composition. // Without this, xterm processes raw keyDown events (e.g., "Process" key) @@ -172,107 +219,22 @@ Object.assign(CodemanApp.prototype, { // distinguish them. We use tmux send-keys -H to send a line feed byte (0x0a) // which the inner application recognizes as "insert newline" vs carriage return. if (ev.key === 'Enter' && (ev.shiftKey || ev.ctrlKey) && ev.type === 'keydown') { - if (this.activeSessionId) { - if (this._localEchoEnabled) { - const text = this._localEchoOverlay?.pendingText || ''; - this._localEchoOverlay?.clear(); - this._localEchoOverlay?.suppressBufferDetection(); - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (text) { - this._pendingInput += text; - flushInput(); - } - setTimeout(() => { - fetch(`/api/sessions/${this.activeSessionId}/send-key`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), - }); - }, text ? 80 : 0); - } else { - fetch(`/api/sessions/${this.activeSessionId}/send-key`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ key: ev.ctrlKey ? 'C-Enter' : 'S-Enter' }), - }); - } - } + this._terminalInputController.sendModifiedEnter( + ev.ctrlKey ? 'C-Enter' : 'S-Enter' + ); return false; } return true; }); - // Android virtual keyboard fix: catch non-composition input events. - // On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard) - // sends keyCode 229 + input event WITHOUT compositionstart/end wrapping. - // The custom key handler above returns false for keyCode 229, telling xterm - // to ignore the keydown. xterm.js expects the character to arrive via - // composition events, but since there's no composition, the character is lost. - // This listener catches those orphaned input events and forwards them to onData. - { - const xtermTextarea = container.querySelector('.xterm-helper-textarea'); - if (xtermTextarea && MobileDetection.isTouchDevice()) { - let composing = false; - let lastKeydownHandled = 0; - xtermTextarea.addEventListener('compositionstart', () => { composing = true; }); - xtermTextarea.addEventListener('compositionend', () => { composing = false; }); - // Track when xterm handles a keydown normally (non-229 keyCode). - // If xterm processed the keydown, it will emit onData itself -- - // the input event handler below must NOT re-send the character. - xtermTextarea.addEventListener('keydown', (e) => { - if (!e.isComposing && e.keyCode !== 229) { - lastKeydownHandled = Date.now(); - } - }); - xtermTextarea.addEventListener('input', (e) => { - // Only handle insertText events outside of composition -- these are - // the ones xterm.js misses on Android virtual keyboards. - if (composing || e.isComposing) return; - if (e.inputType !== 'insertText' || !e.data) return; - // If xterm just handled a keydown (within 50ms), it already sent the - // char via onData. Skip to avoid double-send (e.g., Shift+A => AA). - if (Date.now() - lastKeydownHandled < 50) return; - // xterm.js may have already processed this via its own input handler. - // Check if the textarea was cleared by xterm (value is empty or just - // whitespace) -- if so, xterm handled it and we should not double-send. - // Use a microtask to check after xterm's own handlers have run. - const data = e.data; - const pendingBefore = this._localEchoOverlay?.pendingText || ''; - Promise.resolve().then(() => { - if ( - this._lastTerminalData?.data === data && - performance.now() - this._lastTerminalData.time < 100 - ) { - xtermTextarea.value = ''; - return; - } - const pendingAfter = this._localEchoOverlay?.pendingText || ''; - if ( - this._localEchoEnabled && - pendingAfter.length > pendingBefore.length && - pendingAfter.endsWith(data) - ) { - xtermTextarea.value = ''; - return; - } - // If xterm cleared the textarea, it processed the input -- skip. - const val = xtermTextarea.value; - if (!val || (val.trim() === '' && data !== ' ')) return; - // xterm didn't process it -- forward to terminal as if typed. - // Emit via onData path by writing to terminal's input handler. - this.terminal._core.coreService.triggerDataEvent(data, true); - // Clear the textarea to prevent xterm from processing it later. - xtermTextarea.value = ''; - }); - }); - } - } - + // Android IME, helper-textarea mutation, and composition arbitration + // are installed by TerminalInputController.attachTextarea() above. + // Paste capture and Android segmented-paste fallback use the same + // controller so clipboard mutations cannot race ordinary IME input. // WebGL renderer for GPU-accelerated terminal rendering. // Previously caused "page unresponsive" crashes from synchronous GPU stalls, - // but the 48KB/frame flush cap in flushPendingWrites() now prevents + // but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents // oversized terminal.write() calls that triggered the stalls. // Disable with ?nowebgl URL param if GPU issues return. // Auto-fallback: _initWebGL installs a long-task watchdog that disables @@ -330,6 +292,12 @@ Object.assign(CodemanApp.prototype, { } this._localEchoOverlay = new LocalEchoOverlay(this.terminal); + this.terminal.onScroll((viewportY) => { + if (typeof MobileNavigationPad !== 'undefined') { + MobileNavigationPad.syncJumpVisibility?.(); + } + this._maybeLoadTerminalHistoryPage(viewportY); + }); if (MobileDetection.isTouchDevice()) { this.terminal.onCursorMove(() => this._syncMobileHelperTextareaToCursor()); this.terminal.onRender(() => this._syncMobileHelperTextareaToCursor()); @@ -342,6 +310,12 @@ Object.assign(CodemanApp.prototype, { send: (text) => { this._handleCjkInput(text); }, + paste: (text) => { + this.sendPastedText(text); + }, + draftChanged: () => { + this._captureActiveSessionDraft(); + }, }); } @@ -403,8 +377,7 @@ Object.assign(CodemanApp.prototype, { this._sendSyntheticSgrWheel(ev.clientX, ev.clientY, lines); return; } - this._noteTerminalUserScroll(lines); - this.terminal.scrollLines(lines); + this._scrollTerminalLines(lines); }, { passive: false } ); @@ -421,6 +394,8 @@ Object.assign(CodemanApp.prototype, { let lastTime = 0; let scrollFrame = null; let isTouching = false; + let touchForwardsToApp = false; + let touchLastX = 0; const scrollLoop = (timestamp) => { const dt = lastTime ? (timestamp - lastTime) / 16.67 : 1; @@ -429,12 +404,19 @@ Object.assign(CodemanApp.prototype, { if (!isTouching && Math.abs(velocity) > 0.3) { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); - if (lines !== 0) this.terminal.scrollLines(lines); + if (lines !== 0) { + if (touchForwardsToApp) { + this._sendSyntheticSgrWheel(touchLastX, touchLastY, lines); + } else { + this._scrollTerminalLines(lines); + } + } velocity *= 0.92; scrollFrame = requestAnimationFrame(scrollLoop); } else if (!isTouching) { scrollFrame = null; velocity = 0; + touchForwardsToApp = false; } else { scrollFrame = requestAnimationFrame(scrollLoop); } @@ -445,17 +427,38 @@ Object.assign(CodemanApp.prototype, { let didScroll = false; // track whether touchmove fired (tap vs scroll) let touchStartY = 0; + let tapStartedWithTerminalFocus = false; + let preserveFocusedDraftOnDrag = false; const TAP_THRESHOLD = 8; // px — ignore micro-drift to distinguish tap from scroll container.addEventListener( 'touchstart', (ev) => { if (ev.touches.length === 1) { + touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; touchStartY = touchLastY; velocity = 0; pixelAccum = 0; isTouching = true; didScroll = false; + touchForwardsToApp = this._shouldForwardTouchScrollToApp(); + tapStartedWithTerminalFocus = this._isMobileTerminalInputFocused(); + const touchStartIntent = this._classifyMobileTerminalTap(touchLastX, touchLastY); + const keyboardVisible = + (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) || + document.body.classList.contains('keyboard-visible'); + preserveFocusedDraftOnDrag = + keyboardVisible && + tapStartedWithTerminalFocus && + touchStartIntent !== 'input'; + if (touchStartIntent !== 'input') { + // Cancel the compatibility click before it can focus xterm. + // When a phone keyboard already owns the prompt, delay blur + // until touchend: a real drag keeps the draft active, while a + // plain content tap still activates the TUI and dismisses it. + ev.preventDefault(); + if (!preserveFocusedDraftOnDrag) this._blurMobileTerminalInput(); + } lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -463,7 +466,7 @@ Object.assign(CodemanApp.prototype, { } } }, - { passive: true } + { passive: false } ); container.addEventListener( @@ -473,6 +476,9 @@ Object.assign(CodemanApp.prototype, { const touchY = ev.touches[0].clientY; if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) { didScroll = true; + if (preserveFocusedDraftOnDrag) { + this._localEchoOverlay?.setViewportPinned?.(true); + } } // Below the tap threshold, treat the gesture as a potential tap: // don't preventDefault (iOS needs click synthesis to show the @@ -482,6 +488,7 @@ Object.assign(CodemanApp.prototype, { // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); + touchLastX = ev.touches[0].clientX; const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; velocity = delta * 1.2; @@ -490,8 +497,11 @@ Object.assign(CodemanApp.prototype, { const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { - this._noteTerminalUserScroll(lines); - this.terminal.scrollLines(lines); + if (touchForwardsToApp) { + this._sendSyntheticSgrWheel(touchLastX, touchY, lines); + } else { + this._scrollTerminalLines(lines); + } pixelAccum -= lines * ch; } } @@ -507,44 +517,16 @@ Object.assign(CodemanApp.prototype, { scrollFrame = requestAnimationFrame(scrollLoop); } if (!didScroll && this.terminal) { - // ── Tap-to-position cursor ────────────────────────────────── - // Synthesize a click from the real touch point so the foreground app - // moves its cursor to the tapped cell (iOS doesn't reliably do this - // itself under touch-action:none). CRITICAL: only when mouse tracking - // is ON. xterm disables its local SelectionService while mouse events - // are active, so the synthetic click is forwarded to the PTY as an SGR - // report (cursor moves). But when tracking is OFF, that same click - // drives xterm's LOCAL selection (detail 1/2/3 → char/word/line) — a - // tap on CJK text would select & copy it instead of positioning. So - // gate strictly on the live mouse-tracking mode. const touch = ev.changedTouches && ev.changedTouches[0]; - const mouseMode = this.terminal.modes?.mouseTrackingMode; - const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; if (touch) { this._suppressTrustedTapMouseEvents(); + this._handleMobileTerminalTap(touch, tapStartedWithTerminalFocus); } - if (touch && mouseTrackingOn) { - this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); - } else if (touch && this._sessionUsesServerMouseStrip()) { - // The server strips mouse-tracking DECSETs from claude/codex/gemini - // output (isAltScreenStripMode, session.ts) so the wheel keeps - // scrolling scrollback — which leaves THIS xterm permanently at - // mouseTrackingMode 'none' even though the TUI on the PTY side has - // tracking ON and still understands SGR reports. Encode the report - // ourselves and send it straight to the PTY: no DOM click is - // dispatched, so xterm's local selection can't trigger either. - this._sendSyntheticSgrTap(touch.clientX, touch.clientY); - } - this._syncMobileHelperTextareaToCursor(); - // Route subsequent typing to the right place: keep the CJK input - // field focused when Chinese input is on, otherwise the terminal. - const cjkInput = document.getElementById('cjkInput'); - if (cjkInput?.classList.contains('cjk-input-visible')) { - cjkInput.focus(); - } else { - this.terminal.focus(); - } + } else if (didScroll && preserveFocusedDraftOnDrag) { + this._focusMobileTerminalInput(); } + tapStartedWithTerminalFocus = false; + preserveFocusedDraftOnDrag = false; }, { passive: true } ); @@ -555,6 +537,9 @@ Object.assign(CodemanApp.prototype, { isTouching = false; velocity = 0; pixelAccum = 0; + touchForwardsToApp = false; + tapStartedWithTerminalFocus = false; + preserveFocusedDraftOnDrag = false; }, { passive: true } ); @@ -570,6 +555,19 @@ Object.assign(CodemanApp.prototype, { // Hand-encode the SGR report for plain left-clicks on those sessions. container.addEventListener('click', (ev) => this._handleDesktopTerminalClick(ev)); + // The PTY has one shared size across all connected viewports. Let the page + // receiving a real interaction claim it at that viewport's dimensions. + if (!this._terminalSizingPointerHandler) { + this._terminalSizingPointerHandler = (ev) => this._handleTerminalSizingPointerDown(ev); + document.addEventListener('pointerdown', this._terminalSizingPointerHandler, true); + this._terminalSizingFocusHandler = () => this._scheduleTerminalSizingClaim(); + this._terminalSizingVisibilityHandler = () => { + if (document.visibilityState === 'visible') this._scheduleTerminalSizingClaim(); + }; + window.addEventListener('focus', this._terminalSizingFocusHandler); + document.addEventListener('visibilitychange', this._terminalSizingVisibilityHandler); + } + // Welcome message this.showWelcome(); @@ -578,18 +576,20 @@ Object.assign(CodemanApp.prototype, { // Generation counter for chunkedTerminalWrite — aborts stale writes on tab switch this._chunkedWriteGen = 0; + this._terminalWriteInFlight = null; + this._terminalRenderEpoch = 0; this._bufferLoadSeq = 0; this._bufferLoadOwner = null; - this._lastUserScrollUpAt = null; + this._terminalFrameReconcileSeq = 0; + this._terminalFrameReconcilePending = null; + this._terminalFrameReconcilePromise = null; + this._terminalScrollLocked = false; + this._terminalAppScrollSessions = new Set(); // Handle resize with throttling for performance this._resizeTimeout = null; this._lastResizeDims = null; - // Minimum terminal dimensions to prevent vertical text wrapping - const MIN_COLS = 40; - const MIN_ROWS = 10; - const throttledResize = () => { // Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens // once after the user stops resizing. During active resize, the terminal @@ -608,6 +608,12 @@ Object.assign(CodemanApp.prototype, { } this._resizeTimeout = setTimeout(() => { this._resizeTimeout = null; + // KeyboardHandler owns the touch-keyboard transition and performs one + // final fit after visualViewport settles. Running this generic observer + // path as well causes a second reflow a few hundred milliseconds later. + const keyboardUp = + typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + if (keyboardUp) return; // Fit xterm.js to final container dimensions if (this.fitAddon) { this.fitAddon.fit(); @@ -620,17 +626,17 @@ Object.assign(CodemanApp.prototype, { } this.flushFlickerBuffer(); } - // Skip server resize while mobile keyboard is visible — sending SIGWINCH - // causes Ink to re-render at the new row count, garbling terminal output. - // Local fit() still runs so xterm knows the viewport size for scrolling. - const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; - if (this.activeSessionId && !keyboardUp) { - const dims = this.fitAddon.proposeDimensions(); - // Enforce minimum dimensions to prevent layout issues - const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS; - const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS; + // This generic path only runs with the touch keyboard hidden; the early + // return above leaves the keyboard transition to KeyboardHandler. + if (this.activeSessionId) { + const dims = this.getTerminalDimensions(); // Only send resize if dimensions actually changed - if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) { + if ( + dims && + (!this._lastResizeDims || + dims.cols !== this._lastResizeDims.cols || + dims.rows !== this._lastResizeDims.rows) + ) { // Clear viewport + scrollback ONLY when dimensions actually change. // fitAddon.fit() reflows content: lines at old width may wrap to more rows, // pushing overflow into scrollback. Ink's cursor-up count is based on the @@ -648,31 +654,9 @@ Object.assign(CodemanApp.prototype, { ) { this.terminal.write('\x1b[3J\x1b[H\x1b[2J'); } - this._lastResizeDims = { cols, rows }; - // Typed + WS-first like sendResize: the viewport type feeds resize - // arbitration (a phone rotating must not bypass a desktop claim), - // and a desktop window narrowing past the tablet breakpoint must - // send a typed WS frame so its stale desktop claim is released. - const viewportType = - typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType - ? MobileDetection.getDeviceType() - : 'desktop'; - let sentViaWs = false; - if (this._wsReady && this._wsSessionId === this.activeSessionId) { - try { - this._ws.send(JSON.stringify({ t: 'z', c: cols, r: rows, v: viewportType })); - sentViaWs = true; - } catch { - // Fall through to HTTP POST - } - } - if (!sentViaWs) { - fetch(`/api/sessions/${this.activeSessionId}/resize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ cols, rows, viewportType }), - }).catch(() => {}); - } + // sendResize owns dimension tracking, viewport classification, and + // the WebSocket-first transport with HTTP fallback. + this.sendResize(this.activeSessionId, { refit: false }).catch(() => {}); } } // Update subagent connection lines and local echo at new dimensions @@ -691,266 +675,42 @@ Object.assign(CodemanApp.prototype, { this.terminalResizeObserver = new ResizeObserver(throttledResize); this.terminalResizeObserver.observe(container); - // Handle keyboard input — send to PTY immediately, no local echo. - // PTY/Ink handles all character echoing to avoid desync ("typing visible below" bug). - this._pendingInput = ''; - this._inputFlushTimeout = null; - this._lastKeystrokeTime = 0; - - const flushInput = () => { - this._inputFlushTimeout = null; - if (this._pendingInput && this.activeSessionId) { - const input = this._pendingInput; - const sessionId = this.activeSessionId; - this._pendingInput = ''; - this._sendInputAsync(sessionId, input); - } - }; - - // Local echo mode: buffer keystrokes locally (shown in overlay) and only - // send to PTY on Enter. Avoids out-of-order delivery on high-latency - // mobile connections. The overlay + localStorage persistence ensure input - // survives tab switches and reconnects. - + // xterm is an adapter only. The controller owns semantic input state, + // local echo, batching, control ordering, and IME deduplication. this.terminal.onData((data) => { - // Mouse SGR reports (tap-to-position) are NOT IME input — they must reach - // the PTY even while the CJK input field owns focus. Without this exception - // tapping to move the cursor silently does nothing whenever Chinese input - // is on, because cjkActive stays true the whole time the field is visible. const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data); - // CJK input has focus — block xterm from sending keystrokes to PTY - if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) { - // Self-heal: if the CJK field is visible but focus drifted to xterm's - // hidden textarea (e.g. something called terminal.focus()), everything - // typed lands HERE and is swallowed — keyboard shows the IME composing - // while both the CJK field and the terminal stay empty. Route focus - // back so the very next keystroke lands in the CJK field again. - // Only GENUINE typed input qualifies: onData also fires for xterm's - // self-generated query replies (DA/DSR/CPR/OSC during Ink redraws), - // which arrive no matter what has focus — so require focus to be on - // xterm's own textarea and bail on query replies, or this would steal - // focus from the rename/search/settings inputs while output streams. + if ( + !isMouseReport && + (window.cjkActive || + document.activeElement?.id === 'cjkInput') + ) { const cjkEl = document.getElementById('cjkInput'); if ( cjkEl?.classList.contains('cjk-input-visible') && document.activeElement === this.terminal.textarea && - !window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data) + !window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse( + data + ) ) { - _crashDiag.log('CJK regain-focus (onData swallowed input)'); + _crashDiag.log( + 'CJK regain-focus (onData swallowed input)' + ); cjkEl.focus(); } return; } - if (this.activeSessionId) { - // Filter terminal query replies generated by xterm.js itself. - // Forwarding them through the WebSocket injects DA/DSR/CPR replies - // into the foreground process as typed input (for example "0;276;0c"). - if ( - window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse(data) - ) { - return; - } - this._lastTerminalData = { data, time: performance.now() }; - - // ── Local Echo Mode ── - // When enabled, keystrokes are buffered locally in the overlay for - // instant visual feedback. Nothing is sent to the PTY until Enter - // (or a control char) is pressed — avoids out-of-order char delivery. - if (this._localEchoEnabled) { - if (data === '\x7f') { - const source = this._localEchoOverlay?.removeChar(); - if (source === 'flushed') { - // Sync app-level flushed Maps (per-session state for tab switching) - const { count, text } = this._localEchoOverlay.getFlushed(); - if (this._flushedOffsets?.has(this.activeSessionId)) { - if (count === 0) { - this._flushedOffsets.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - } else { - this._flushedOffsets.set(this.activeSessionId, count); - this._flushedTexts?.set(this.activeSessionId, text); - } - } - this._pendingInput += data; - flushInput(); - } - // 'pending' = removed unsent text (no PTY backspace needed) - // false = nothing to remove (swallow the backspace) - return; - } - if (/^[\r\n]+$/.test(data)) { - // Enter: send full buffered text + \r to PTY in one shot - const text = this._localEchoOverlay?.pendingText || ''; - this._localEchoOverlay?.clear(); - // Suppress detection so PTY-echoed text isn't re-detected as user input - this._localEchoOverlay?.suppressBufferDetection(); - // Clear flushed offset and text — Enter commits all text - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - if (text) { - this._pendingInput += text; - flushInput(); - } - // Send \r after a short delay so text arrives first - setTimeout(() => { - this._pendingInput += '\r'; - flushInput(); - }, 80); - return; - } - if (data.length > 1 && data.charCodeAt(0) >= 32) { - // Paste: append to overlay only (sent on Enter) - this._localEchoOverlay?.appendText(data); - return; - } - if (data.charCodeAt(0) < 32) { - // Skip xterm-generated terminal responses. - // These arrive via triggerDataEvent when the terminal processes - // buffer data (DA responses, OSC color queries, mode reports, etc.). - // They are NOT user input and must not clear flushed text state. - // Covers: CSI (\x1b[), OSC (\x1b]), DCS (\x1bP), APC (\x1b_), - // PM (\x1b^), SOS (\x1bX), and any other multi-byte ESC sequence. - // Single-byte ESC (user pressing Escape) still falls through to - // the control char handler below. - if (data.length > 1 && data.charCodeAt(0) === 27) { - // Multi-byte escape sequence — forward to PTY without clearing - // overlay/flushed state (terminal response, not user input) - this._pendingInput += data; - flushInput(); - return; - } - // During buffer load (tab switch), stray control chars from - // terminal response processing must not wipe the flushed state - // that selectSession() is actively restoring. - if (this._restoringFlushedState) { - this._pendingInput += data; - flushInput(); - return; - } - // Tab key: send pending text + Tab to PTY for tab completion. - // Set a flag so flushPendingWrites() re-detects buffer text when - // the PTY response arrives (event-driven, no fixed timer). - if (data === '\t') { - const text = this._localEchoOverlay?.pendingText || ''; - this._localEchoOverlay?.clear(); - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (text) { - this._pendingInput += text; - } - this._pendingInput += data; - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - // Snapshot prompt line text BEFORE flushing — used to distinguish - // real Tab completions from pre-existing Claude UI text. - let baseText = ''; - try { - const p = this._localEchoOverlay?.findPrompt?.(); - if (p) { - const buf = this.terminal.buffer.active; - const line = buf.getLine(buf.viewportY + p.row); - if (line) - baseText = line - .translateToString(true) - .slice(p.col + 2) - .trimEnd(); - } - } catch {} - this._tabCompletionBaseText = baseText; - flushInput(); - this._tabCompletionSessionId = this.activeSessionId; - this._tabCompletionRetries = 0; - // Fallback: if flushPendingWrites() detection misses the completion - // (e.g., flicker filter delays data, or xterm hasn't processed writes - // by the time the callback fires), retry detection after a delay. - // This ensures the overlay renders even without further terminal data. - if (this._tabCompletionFallback) clearTimeout(this._tabCompletionFallback); - const selfTab = this; - this._tabCompletionFallback = setTimeout(() => { - selfTab._tabCompletionFallback = null; - if (!selfTab._tabCompletionSessionId || selfTab._tabCompletionSessionId !== selfTab.activeSessionId) - return; - const ov = selfTab._localEchoOverlay; - if (!ov || ov.pendingText) return; - selfTab.terminal.write('', () => { - if (!selfTab._tabCompletionSessionId) return; - ov.resetBufferDetection(); - const detected = ov.detectBufferText(); - if (detected && detected !== selfTab._tabCompletionBaseText) { - selfTab._tabCompletionSessionId = null; - selfTab._tabCompletionRetries = 0; - selfTab._tabCompletionBaseText = null; - ov.rerender(); - } - }); - }, 300); - return; - } - // Control chars (Ctrl+C, single ESC): send buffered text + control char immediately - const text = this._localEchoOverlay?.pendingText || ''; - this._localEchoOverlay?.clear(); - // Suppress detection so PTY-echoed text isn't re-detected as user input - this._localEchoOverlay?.suppressBufferDetection(); - // Clear flushed offset and text — control chars (Ctrl+C, Escape) change - // cursor position or abort readline, making flushed text tracking invalid. - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (text) { - this._pendingInput += text; - } - this._pendingInput += data; - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - flushInput(); - return; - } - if (data.length === 1 && data.charCodeAt(0) >= 32) { - // Printable char: add to overlay only (sent on Enter) - this._localEchoOverlay?.addChar(data); - return; - } - } - - // ── Normal Mode (echo disabled) ── - this._pendingInput += data; - - // Control chars (Enter, Ctrl+C, escape sequences) — flush immediately - if (data.charCodeAt(0) < 32 || data.length > 1) { - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - flushInput(); - return; - } - - // Regular chars — flush immediately if typed after a gap (>50ms), - // otherwise batch via microtask to coalesce rapid keystrokes (paste). - const now = performance.now(); - if (now - this._lastKeystrokeTime > 50) { - // Single char after a gap — send immediately, no setTimeout latency - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - this._lastKeystrokeTime = now; - flushInput(); - } else { - // Rapid sequence (paste or fast typing) — coalesce via microtask - this._lastKeystrokeTime = now; - if (!this._inputFlushTimeout) { - this._inputFlushTimeout = setTimeout(flushInput, 0); - } - } + if (!this.activeSessionId) return; + if ( + window.CodemanTerminalInput?.shouldSuppressTerminalQueryResponse( + data + ) + ) { + return; } + this._terminalInputController.handleTerminalData( + data, + 'xterm' + ); }); }, @@ -1188,6 +948,9 @@ Object.assign(CodemanApp.prototype, { // Home screen has no input target — hide the CJK textarea (activeSessionId // is null by the time we get here). Guarded: defined on the app object. this._updateCjkInputState?.(); + if (typeof MobileTerminalControls !== 'undefined') { + MobileTerminalControls.syncVisibility(); + } }, hideWelcome() { @@ -1204,6 +967,9 @@ Object.assign(CodemanApp.prototype, { // Entering a session — restore CJK textarea if the user has it enabled // (activeSessionId is already set by selectSession before this call). this._updateCjkInputState?.(); + if (typeof MobileTerminalControls !== 'undefined') { + MobileTerminalControls.syncVisibility(); + } }, /** @@ -1959,30 +1725,296 @@ Object.assign(CodemanApp.prototype, { return buffer.viewportY >= buffer.baseY - 2; }, - // Record manual scroll gestures so sticky-scroll can give an upward scroll a - // short grace window (see _hasRecentUserScrollUp). A downward scroll that - // lands back at the bottom clears the suppression immediately. + isTerminalReadingHistory() { + const appOwnedScroll = + this.activeSessionId && + this._terminalAppScrollSessions?.has(this.activeSessionId); + return Boolean(appOwnedScroll || !this.isTerminalAtBottom()); + }, + + /** + * Return both Codeman-owned scrollback and Claude's fullscreen transcript to + * live output. Claude 2.1.187+ owns ordinary touch/wheel transcript scrolling, + * so its documented Ctrl+End binding must be triggered in addition to moving + * xterm's local viewport. + */ + jumpTerminalToLatest() { + if (!this.terminal) return; + this._terminalScrollLocked = false; + this._wasAtBottomBeforeWrite = true; + this._terminalAppScrollSessions?.delete(this.activeSessionId); + this._localEchoOverlay?.setViewportPinned?.(false); + this.terminal.scrollToBottom?.(); + + const session = this.activeSessionId ? this.sessions?.get(this.activeSessionId) : null; + if ( + session?.mode === 'claude' && + this._cliVersionAtLeast(session.cliVersion, '2.1.187') + ) { + this.sendTerminalKey('\x1b[1;5F'); + } + }, + + // Keep history anchored after an upward gesture until the reader explicitly + // returns to the bottom. A timer is insufficient here: long-running terminal + // output used to reclaim the viewport after 1.5s while the user was reading. _noteTerminalUserScroll(lines) { if (lines < 0) { - this._lastUserScrollUpAt = performance.now(); + this._terminalScrollLocked = true; } else if (this.isTerminalAtBottom()) { - this._lastUserScrollUpAt = null; + this._terminalScrollLocked = false; + } + }, + + _scrollTerminalLines(lines) { + if (!lines || !this.terminal) return; + this.terminal.scrollLines(lines); + this._noteTerminalUserScroll(lines); + if (typeof MobileNavigationPad !== 'undefined') { + MobileNavigationPad.syncJumpVisibility?.(); + } + }, + + _shouldPreserveTerminalScroll() { + return this._terminalScrollLocked === true; + }, + + _installTerminalHistoryPage(sessionId, result) { + const meta = result?.historyPage; + if (!sessionId || !meta) { + if (sessionId) this._terminalHistoryPaging?.delete(sessionId); + return null; + } + const page = { + start: meta.start, + end: meta.end, + buffer: result.terminalBuffer || '', + }; + const state = { + origin: meta.origin, + start: meta.start, + end: meta.end, + total: meta.total, + pages: [page], + loading: false, + lastViewportY: null, + }; + this._terminalHistoryPaging.set(sessionId, state); + return state; + }, + + _composeTerminalHistoryWindow(pages, latestBuffer, hasGap) { + const chunks = []; + for (const page of pages || []) { + const rows = Math.max(0, (page?.end || 0) - (page?.start || 0)); + if (page?.buffer) { + chunks.push(page.buffer); + } else if (rows > 0) { + // An all-blank tmux page serializes to an empty string. Materialize one + // harmless cell so xterm still allocates the page's first physical row. + chunks.push(' ' + '\r\n'.repeat(Math.max(0, rows - 1))); + } + } + let history = chunks.join('\r\n'); + if (hasGap) { + history += `${history ? '\r\n' : ''}\x1b[90m...\x1b[0m`; + } + if (!history) return latestBuffer || ''; + + // Move every history row into scrollback before the absolute-positioned + // latest-frame repaint. Exactly one screen of line feeds empties the + // viewport without inserting blank rows into scrollback. + const rows = Math.max(1, this.terminal?.rows || 24); + return history + '\r\n'.repeat(rows) + (latestBuffer || ''); + }, + + _maybeLoadTerminalHistoryPage(viewportY) { + const sessionId = this.activeSessionId; + const state = sessionId ? this._terminalHistoryPaging?.get(sessionId) : null; + if (!state || state.invalidated || !Number.isFinite(viewportY)) return; + + const previousY = state.lastViewportY; + state.lastViewportY = viewportY; + if (state.loading || this._isLoadingBuffer) return; + + const buffer = this.terminal?.buffer?.active; + const rows = Math.max(1, this.terminal?.rows || 24); + // Start early enough that a smaller mobile page normally arrives before + // the reader reaches the edge, without prefetching while they stay current. + const threshold = rows * 3; + if (state.start > 0 && viewportY <= threshold) { + void this._loadTerminalHistoryPage('older'); + return; + } + + const movingDown = previousY !== null && viewportY > previousY; + const nearLatestGap = buffer && viewportY >= Math.max(0, buffer.baseY - threshold); + if ( + movingDown && + this._terminalScrollLocked === true && + state.end < state.total && + nearLatestGap && + viewportY < buffer.baseY + ) { + void this._loadTerminalHistoryPage('newer'); } }, - _hasRecentUserScrollUp() { - if (typeof this._lastUserScrollUpAt !== 'number') return false; - return performance.now() - this._lastUserScrollUpAt < window.CodemanTerminalInput.USER_SCROLL_STICKY_SUPPRESS_MS; + async _loadTerminalHistoryPage(direction) { + const sessionId = this.activeSessionId; + const state = sessionId ? this._terminalHistoryPaging?.get(sessionId) : null; + if ( + !sessionId || + !state || + state.invalidated || + state.loading || + this._isLoadingBuffer || + (direction === 'older' ? state.start <= 0 : state.end >= state.total) + ) { + return false; + } + + state.loading = true; + const selectGeneration = this._selectGeneration; + const boundary = direction === 'older' ? `before=${state.start}` : `after=${state.end}`; + let loadOwner = null; + let coverOwner = null; + try { + const pageResponse = await fetch( + `/api/sessions/${sessionId}/terminal?historyPage=1&${boundary}` + + `&lines=${TERMINAL_HISTORY_PAGE_LINES}&format=stream` + ); + const pageResult = await this._readTerminalSnapshotResponse(pageResponse, { + paint: false, + isCancelled: () => + sessionId !== this.activeSessionId || selectGeneration !== this._selectGeneration, + }); + if ( + pageResult.aborted || + sessionId !== this.activeSessionId || + selectGeneration !== this._selectGeneration + ) { + return false; + } + + const meta = pageResult.historyPage; + const isAdjacent = + direction === 'older' ? meta?.end === state.start : meta?.start === state.end; + if (!meta || meta.origin !== state.origin || !isAdjacent || meta.end <= meta.start) { + // Tmux evicted or replaced the retained origin while it was being read. + // Keep the already-rendered window intact; stitching this response would + // silently combine unrelated absolute row coordinates. + state.invalidated = true; + return false; + } + + const addedRows = meta.end - meta.start; + const nextPages = state.pages.slice(); + if (direction === 'older') { + nextPages.unshift({ + start: meta.start, + end: meta.end, + buffer: pageResult.terminalBuffer || '', + }); + } else { + nextPages.push({ + start: meta.start, + end: meta.end, + buffer: pageResult.terminalBuffer || '', + }); + } + + let removedRows = 0; + while (nextPages.length > TERMINAL_HISTORY_WINDOW_PAGES) { + const removed = direction === 'older' ? nextPages.pop() : nextPages.shift(); + removedRows += Math.max(0, (removed?.end || 0) - (removed?.start || 0)); + } + const nextStart = nextPages[0]?.start ?? meta.start; + const nextEnd = nextPages[nextPages.length - 1]?.end ?? meta.end; + + coverOwner = `history-page-${++this._terminalHistoryPageSeq}`; + this._beginTerminalHistoryReplayCover(coverOwner); + loadOwner = this._beginBufferLoad(coverOwner); + const latestResponse = await fetch( + `/api/sessions/${sessionId}/terminal?latest=1` + + `&tail=${TERMINAL_LATEST_FRAME_SIZE}&format=stream` + ); + const latest = await this._readTerminalSnapshotResponse(latestResponse, { + paint: false, + isCancelled: () => + sessionId !== this.activeSessionId || selectGeneration !== this._selectGeneration, + }); + if ( + latest.aborted || + sessionId !== this.activeSessionId || + selectGeneration !== this._selectGeneration + ) { + return false; + } + + const oldViewportY = this.terminal?.buffer?.active?.viewportY || 0; + const replay = this._composeTerminalHistoryWindow( + nextPages, + latest.terminalBuffer, + nextEnd < meta.total + ); + this._resetTerminalForReplay(); + await this.chunkedTerminalWrite(replay, TERMINAL_CHUNK_SIZE, loadOwner); + if ( + sessionId !== this.activeSessionId || + selectGeneration !== this._selectGeneration + ) { + return false; + } + + const targetViewportY = + direction === 'older' + ? oldViewportY + addedRows + : oldViewportY + addedRows - removedRows; + const baseY = this.terminal?.buffer?.active?.baseY || 0; + this.terminal?.scrollToLine?.(Math.max(0, Math.min(baseY, targetViewportY))); + this._terminalScrollLocked = true; + + state.pages = nextPages; + state.start = nextStart; + state.end = nextEnd; + state.total = meta.total; + state.lastViewportY = this.terminal?.buffer?.active?.viewportY ?? null; + this.terminalBufferCache.set(sessionId, replay); + this._finishBufferLoad(loadOwner, { + snapshotCursor: latest.cursor, + flushQueued: true, + }); + loadOwner = null; + this._completeTerminalHistoryReplayCover(coverOwner); + coverOwner = null; + if (typeof MobileNavigationPad !== 'undefined') { + MobileNavigationPad.syncJumpVisibility?.(); + } + return true; + } catch (err) { + console.warn('Failed to load terminal history page:', err); + return false; + } finally { + if (loadOwner !== null) this._finishBufferLoad(loadOwner, { flushQueued: true }); + if (coverOwner !== null) this._discardTerminalHistoryReplayCover(coverOwner); + if (this._terminalHistoryPaging?.get(sessionId) === state) state.loading = false; + } }, - batchTerminalWrite(data) { + batchTerminalWrite(data, cursor) { // If a buffer load (chunkedTerminalWrite) is in progress, queue live events // to prevent interleaving historical buffer data with live SSE data. // This is critical: interleaving causes cursor position chaos with Ink redraws. if (this._isLoadingBuffer) { - if (this._loadBufferQueue) this._loadBufferQueue.push(data); + if (this._loadBufferQueue) { + this._loadBufferQueue.push(this._isTerminalCursor(cursor) ? { data, cursor } : data); + } return; } + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler.onTerminalFramePending?.(); + } // Check if at bottom BEFORE adding data (captures user's scroll position) // Only update if not already scheduled (preserve the first check's result) @@ -2030,17 +2062,58 @@ Object.assign(CodemanApp.prototype, { // Accumulate raw data (may contain DEC 2026 markers) this.pendingWrites.push(data); + this._scheduleTerminalWriteFlush(); + }, - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - this._safeYield(() => { - // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers - // content between 2026h/2026l and renders atomically. No need for - // client-side incomplete-block detection; just flush every frame. - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); + /** + * Stop Codeman from enqueueing another xterm write while a session boundary + * drains bytes already accepted by xterm's shared parser. + */ + _pauseTerminalWrites() { + this._terminalWritesPaused = true; + }, + + _resumeTerminalWrites() { + this._terminalWritesPaused = false; + if (!this._isLoadingBuffer) this._scheduleTerminalWriteFlush(); + }, + + /** + * Resolve only after every xterm write queued before this call has parsed. + * terminal.reset() does not clear xterm's internal WriteBuffer, so resetting + * before this fence would let the previous session mutate the next screen. + */ + _waitForTerminalParserFence() { + const terminal = this.terminal; + if (!terminal?.write) return Promise.resolve(); + return new Promise((resolve) => terminal.write('', resolve)); + }, + + /** + * Schedule one render-budgeted terminal flush. + * + * Clear the scheduled flag before flushing so flushPendingWrites() can queue + * another yield when a large final batch leaves bytes behind. Keeping the + * flag set through the flush stranded that remainder until unrelated output + * arrived, which looked like truncated responses and idle shell commands. + */ + _scheduleTerminalWriteFlush() { + if ( + this._terminalWritesPaused || + this._terminalWriteInFlight || + this.writeFrameScheduled || + this.pendingWrites.length === 0 + ) { + return; } + this.writeFrameScheduled = true; + this._safeYield(() => { + this.writeFrameScheduled = false; + if (this._terminalWritesPaused) return; + // xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers + // content between 2026h/2026l and renders atomically. + this.flushPendingWrites(); + }); }, /** @@ -2056,13 +2129,28 @@ Object.assign(CodemanApp.prototype, { this.flickerFilterActive = false; // Trigger a normal flush - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - this._safeYield(() => { - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); - } + this._scheduleTerminalWriteFlush(); + }, + + /** + * Preserve a stable TUI prompt position across mobile keyboard reflows. + * xterm can briefly expose a replayed cursor and historical prompt while the + * resized PTY frame is in flight, so remember the prompt relative to the + * bottom edge before fitAddon changes the row count. + */ + _captureLocalEchoPromptAnchor() { + const terminal = this.terminal; + const prompt = this._localEchoOverlay?.findPrompt?.(); + if (!terminal || !prompt || !this.activeSessionId) return false; + const rows = Math.max(1, terminal.rows || 1); + const promptBandStart = Math.max(0, rows - window.CodemanTerminalInput.TUI_PROMPT_BOTTOM_BAND_ROWS); + if (prompt.row < promptBandStart) return false; + this._localEchoPromptAnchor = { + sessionId: this.activeSessionId, + rowsFromBottom: rows - 1 - prompt.row, + col: prompt.col, + }; + return true; }, /** @@ -2074,11 +2162,12 @@ Object.assign(CodemanApp.prototype, { const settings = this.loadAppSettingsFromStorage(); const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null; const echoEnabled = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); - const shouldEnable = !!(echoEnabled && session); + const shouldEnable = !!(echoEnabled && session && session.mode !== 'shell'); if (this._localEchoEnabled && !shouldEnable) { this._localEchoOverlay?.clear(); } this._localEchoEnabled = shouldEnable; + this.terminal?.element?.classList.toggle('codeman-local-echo', shouldEnable); // Swap prompt finder based on session mode if (this._localEchoOverlay && session) { @@ -2110,28 +2199,125 @@ Object.assign(CodemanApp.prototype, { this._localEchoOverlay.clear(); this._localEchoEnabled = false; } else { - // Codex/Claude-style TUIs usually expose a ❯ prompt. During active - // redraws or compact mobile layouts that marker may not be present in - // the viewport, while xterm's cursor still marks the editable input - // position. Fall back to cursor coordinates so phone typing appears at - // the terminal cursor instead of disappearing into pending state. + // Codex/Claude-style TUIs expose an editable prompt as › or ❯. During + // initial buffer replay xterm's provisional cursor is at row zero; it + // is parser state, not an input anchor, so retain the draft invisibly + // until the authoritative frame has loaded. Once loaded, cursor fallback + // is allowed only near the bottom where these TUIs place marker-less + // input during compact redraws. this._localEchoOverlay.setPrompt({ type: 'custom', offset: 0, find: (terminal) => { try { + if (this._isLoadingBuffer) return null; const buf = terminal.buffer.active; - for (let row = terminal.rows - 1; row >= 0; row--) { + const rows = Math.max(1, terminal.rows || 1); + const cursorRow = Math.max(0, Math.min(rows - 1, buf.cursorY || 0)); + const cursorCol = Math.max(0, Math.min(terminal.cols - 1, buf.cursorX || 0)); + const promptBandStart = Math.max( + 0, + rows - window.CodemanTerminalInput.TUI_PROMPT_BOTTOM_BAND_ROWS + ); + const remembered = + this._localEchoPromptAnchor?.sessionId === this.activeSessionId + ? this._localEchoPromptAnchor + : null; + const isTouchDevice = + typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice(); + const cursorInPromptBand = cursorRow >= promptBandStart; + const rememberedRow = + remembered && + Number.isInteger(remembered.rowsFromBottom) && + remembered.rowsFromBottom >= 0 && + remembered.rowsFromBottom < rows + ? rows - 1 - remembered.rowsFromBottom + : null; + const flushedCount = this._localEchoOverlay?.getFlushed?.().count || 0; + const markerlessRowIsEditable = (row) => { + const line = buf.getLine(buf.viewportY + row); + if (!line) return false; + return flushedCount > 0 || !line.translateToString(true).trim(); + }; + let hasVisibleContent = false; + let hasContentBelow = false; + let outOfBandPromptCol = null; + for (let row = rows - 1; row >= 0; row--) { const line = buf.getLine(buf.viewportY + row); if (!line) continue; const text = line.translateToString(true); - const idx = text.lastIndexOf('\u276f'); - if (idx >= 0) return { row, col: idx + 2 }; + const prompt = text.match(/^(\s*)[\u203a\u276f]/); + const promptCol = prompt ? prompt[1].length + 2 : null; + if (promptCol !== null && row < promptBandStart && outOfBandPromptCol === null) { + outOfBandPromptCol = promptCol; + } + // Conversation history retains old prompt glyphs. Only treat a + // mobile marker as editable when it agrees with the live cursor. + // On a short keyboard viewport the whole screen can fall inside + // the bottom band, so band membership alone is not sufficient. + if ( + prompt && + ((row >= promptBandStart && + (!isTouchDevice || !cursorInPromptBand || row === cursorRow)) || + (!isTouchDevice && + !remembered && + Math.abs(row - cursorRow) <= 1 && + !hasContentBelow)) + ) { + const position = { row, col: promptCol }; + if (row >= promptBandStart) { + this._localEchoPromptAnchor = { + sessionId: this.activeSessionId, + rowsFromBottom: rows - 1 - row, + col: position.col, + }; + } + return position; + } + if (text.trim()) { + hasVisibleContent = true; + hasContentBelow = true; + } + } + if (!hasVisibleContent && cursorRow === 0 && cursorCol === 0) return null; + + // Keep local echo on xterm's visible cursor whenever that cursor + // is in the input band. This prevents a retained resize anchor + // from creating a second cursor on a different row. + if (cursorInPromptBand && markerlessRowIsEditable(cursorRow)) { + const position = { + row: cursorRow, + col: cursorCol, + }; + this._localEchoPromptAnchor = { + sessionId: this.activeSessionId, + rowsFromBottom: rows - 1 - cursorRow, + col: cursorCol, + }; + return position; } - return { - row: Math.max(0, Math.min(terminal.rows - 1, buf.cursorY)), - col: Math.max(0, Math.min(terminal.cols - 1, buf.cursorX)), + if (rememberedRow !== null && markerlessRowIsEditable(rememberedRow)) { + return { + row: rememberedRow, + col: Math.max(0, Math.min(terminal.cols - 1, remembered.col)), + }; + } + if (!isTouchDevice || outOfBandPromptCol === null) return null; + const rowsFromBottom = Math.min( + rows - 1, + window.CodemanTerminalInput.TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM + ); + const position = { + row: rows - 1 - rowsFromBottom, + col: Math.max(0, Math.min(terminal.cols - 1, outOfBandPromptCol)), + }; + if (!markerlessRowIsEditable(position.row)) return null; + this._localEchoPromptAnchor = { + sessionId: this.activeSessionId, + rowsFromBottom, + col: position.col, }; + return position; } catch { return null; } @@ -2149,7 +2335,7 @@ Object.assign(CodemanApp.prototype, { return; } _crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`); - this._sendInputAsync(this.activeSessionId, text); + this._terminalInputController.sendExternalText(text); }, /** @@ -2157,7 +2343,14 @@ Object.assign(CodemanApp.prototype, { * Strips markers and writes content atomically within a single frame. */ flushPendingWrites() { - if (this.pendingWrites.length === 0 || !this.terminal) return; + if ( + this._terminalWritesPaused || + this._terminalWriteInFlight || + this.pendingWrites.length === 0 || + !this.terminal + ) { + return; + } const _t0 = performance.now(); // xterm.js 6.0+ natively handles DEC 2026 synchronized output markers. @@ -2168,61 +2361,97 @@ Object.assign(CodemanApp.prototype, { const _joinedLen = joined.length; if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen / 1024).toFixed(0)}KB`); - // Per-frame byte budget to prevent main thread blocking. - // Large writes (141KB+) can freeze Chrome for 2+ minutes. - // Codex's TUI emits dense synchronized redraws during thinking/high-effort - // phases, so it gets a smaller first frame to keep per-frame xterm/WebGL - // stalls short; other modes keep the larger 64KB budget. + // Per-frame byte budget to prevent main thread blocking. Mobile gets a + // smaller budget so bursty TUI output advances in display-frame-sized + // steps; desktop Codex keeps 32KB and other desktop modes keep 64KB. const activeSession = this.activeSessionId && this.sessions ? this.sessions.get(this.activeSessionId) : null; - const MAX_FRAME_BYTES = activeSession?.mode === 'codex' ? 32768 : 65536; - let deferred = false; - // If the user recently scrolled up, remember the viewport so we can restore - // it after the write — Codex status redraws would otherwise jump it. + const isMobile = + typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType?.() === 'mobile'; + const MAX_FRAME_BYTES = isMobile + ? 16 * 1024 + : activeSession?.mode === 'codex' + ? 32 * 1024 + : 64 * 1024; + let writeLength = Math.min(_joinedLen, MAX_FRAME_BYTES); + if (_joinedLen > MAX_FRAME_BYTES) { + // Prefer the last complete synchronized-update block inside the budget. + // This publishes one coherent terminal state per display frame instead + // of cutting a redraw at an arbitrary byte and exposing visible ticks. + const syncEnd = '\x1b[?2026l'; + const boundary = joined.lastIndexOf( + syncEnd, + Math.max(0, MAX_FRAME_BYTES - syncEnd.length) + ); + if (boundary >= 0) writeLength = boundary + syncEnd.length; + } + const deferred = writeLength < _joinedLen; + const writeData = deferred ? joined.slice(0, writeLength) : joined; + if (deferred) { + this.pendingWrites.push(joined.slice(writeLength)); + } + + const terminal = this.terminal; + const sessionId = this.activeSessionId; + const renderEpoch = this._terminalRenderEpoch || 0; + const writeToken = {}; + this._terminalWriteInFlight = writeToken; + + // While the reader owns history, remember the viewport so Codex status + // redraws cannot move it. const preserveViewportY = - this._hasRecentUserScrollUp() && this.terminal.buffer?.active ? this.terminal.buffer.active.viewportY : null; + this._shouldPreserveTerminalScroll() && terminal.buffer?.active + ? terminal.buffer.active.viewportY + : null; + const followBottom = this._wasAtBottomBeforeWrite && !this._shouldPreserveTerminalScroll(); + const parseStartedAt = performance.now(); + + terminal.write(writeData, () => { + if (this._terminalWriteInFlight !== writeToken) return; + this._terminalWriteInFlight = null; + + const parseMs = performance.now() - parseStartedAt; + if (parseMs > 100) { + _crashDiag.log(`XTERM_PARSE: ${parseMs.toFixed(0)}ms for ${(writeData.length / 1024).toFixed(0)}KB`); + } - if (_joinedLen <= MAX_FRAME_BYTES) { - this.terminal.write(joined); - } else { - // Write first chunk now, defer rest to next frame - this.terminal.write(joined.slice(0, MAX_FRAME_BYTES)); - this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES)); - deferred = true; - if (!this.writeFrameScheduled) { - this.writeFrameScheduled = true; - this._safeYield(() => { - this.flushPendingWrites(); - this.writeFrameScheduled = false; - }); + // A session switch can reuse the same xterm instance while this callback + // is queued. Do not apply stale viewport or draft state to the new view. + if ( + terminal === this.terminal && + sessionId === this.activeSessionId && + renderEpoch === (this._terminalRenderEpoch || 0) + ) { + if ( + preserveViewportY !== null && + terminal.buffer?.active?.viewportY !== preserveViewportY && + typeof terminal.scrollToLine === 'function' + ) { + terminal.scrollToLine(preserveViewportY); + } else if (followBottom) { + terminal.scrollToBottom(); + } + if (this._localEchoOverlay?.hasPending) { + this._localEchoOverlay.rerender(); + } + if ( + !deferred && + this.pendingWrites.length === 0 && + typeof KeyboardHandler !== 'undefined' + ) { + KeyboardHandler.onTerminalFrameReady?.(); + } } - } - if ( - preserveViewportY !== null && - this.terminal.buffer?.active?.viewportY !== preserveViewportY && - typeof this.terminal.scrollToLine === 'function' - ) { - this.terminal.scrollToLine(preserveViewportY); - } - const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen; - const _dt = performance.now() - _t0; - if (_dt > 100 || deferred) - console.warn( - `[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame / 1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)` - ); - // Sticky scroll: if user was at bottom, keep them there after new output. - // Give manual scroll-up gestures a short grace window so high-frequency - // Codex status ticks do not snap the viewport back while the user is - // trying to inspect earlier output. - if (this._wasAtBottomBeforeWrite && !this._hasRecentUserScrollUp()) { - this.terminal.scrollToBottom(); - } + // Queue the next slice only after xterm has parsed this one. _safeYield() + // then gives a visible page a compositor frame before more parser work. + this._scheduleTerminalWriteFlush(); + }); - // Re-position local echo overlay after terminal writes — Ink redraws can - // move the ❯ prompt to a different row, making the overlay invisible. - if (this._localEchoOverlay?.hasPending) { - this._localEchoOverlay.rerender(); - } + const enqueueMs = performance.now() - _t0; + if (enqueueMs > 100 || deferred) + console.warn( + `[CRASH-DIAG] flushPendingWrites enqueue: ${enqueueMs.toFixed(0)}ms, ${(writeData.length / 1024).toFixed(0)}KB${deferred ? ', rest deferred' : ''} (total ${(_joinedLen / 1024).toFixed(0)}KB)` + ); // After Tab completion: detect the completed text in the overlay. // Use terminal.write('', callback) to defer detection until xterm.js @@ -2259,6 +2488,7 @@ Object.assign(CodemanApp.prototype, { self._tabCompletionFallback = null; } overlay.rerender(); + self._captureActiveSessionDraft(); } } else { // No text found yet — retry on next flush. @@ -2272,22 +2502,76 @@ Object.assign(CodemanApp.prototype, { } }, + _handleTerminalInputTab({ overlay, sessionId, text }) { + overlay?.clear?.(); + if (text) { + this._setSessionDraft(sessionId, { + pendingText: '', + flushedText: text, + cjkText: '', + updatedAt: Date.now(), + }); + } else { + this._clearSessionDraft(sessionId); + } + + let baseText = ''; + try { + const prompt = overlay?.findPrompt?.(); + if (prompt) { + const buffer = this.terminal.buffer.active; + const line = buffer.getLine( + buffer.viewportY + prompt.row + ); + if (line) { + baseText = line + .translateToString(true) + .slice(prompt.col + 2) + .trimEnd(); + } + } + } catch {} + this._tabCompletionBaseText = baseText; + this._sendInputAsync(sessionId, text + '\t'); + this._tabCompletionSessionId = sessionId; + this._tabCompletionRetries = 0; + + this._clearTimer('_tabCompletionFallback'); + this._tabCompletionFallback = setTimeout(() => { + this._tabCompletionFallback = null; + if ( + !this._tabCompletionSessionId || + this._tabCompletionSessionId !== + this.activeSessionId + ) { + return; + } + const liveOverlay = this._localEchoOverlay; + if (!liveOverlay || liveOverlay.pendingText) return; + this.terminal.write('', () => { + if (!this._tabCompletionSessionId) return; + liveOverlay.resetBufferDetection(); + const detected = liveOverlay.detectBufferText(); + if ( + detected && + detected !== this._tabCompletionBaseText + ) { + this._tabCompletionSessionId = null; + this._tabCompletionRetries = 0; + this._tabCompletionBaseText = null; + liveOverlay.rerender(); + this._captureActiveSessionDraft(); + } + }); + }, 300); + return true; + }, + /** - * Schedule cb via THREE racing primitives so data-pacing makes progress - * regardless of which scheduling primitive Chrome is throttling: - * 1. requestAnimationFrame — primary, fires at compositor rate - * (may be 0Hz when window is occluded / on backgrounded monitor). - * 2. setTimeout(50) — fallback for occluded-but-visible windows - * (clamped to 1Hz by Chrome's intensive wake-up throttling - * after ~5 min of no user interaction). - * 3. Worker postMessage — bypasses intensive throttling entirely; - * Workers are not subject to background-tab / idle-tab throttling - * (the React Scheduler trick). - * Whichever fires first wins; the others are no-ops thanks to the - * `done` guard. Without all three, chunkedTerminalWrite and the deferred - * path of flushPendingWrites stall indefinitely when the substrate is - * degraded (visible-but-occluded window, OR idle-throttled tab, OR - * background tab on a different monitor). + * Give visible pages a compositor opportunity before more terminal parser + * work. A timeout remains as the occluded-window fallback. Hidden pages use + * the Worker wake-up path because painting is irrelevant there and Chrome + * may heavily throttle both rAF and timers. */ _safeYield(cb) { let done = false; @@ -2296,9 +2580,13 @@ Object.assign(CodemanApp.prototype, { done = true; cb(); }; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + setTimeout(wrapped, 50); + this._workerYield(wrapped); + return; + } requestAnimationFrame(wrapped); - setTimeout(wrapped, 50); - this._workerYield(wrapped); + setTimeout(wrapped, 100); }, /** @@ -2332,6 +2620,276 @@ Object.assign(CodemanApp.prototype, { } }, + /** + * Capture only xterm's painted screen while leaving its real viewport and + * scrollbar exposed. During canonical history replay the clone stays fixed + * on the latest frame while the underlying terminal grows from the bottom. + */ + _captureTerminalHistoryReplayCover() { + if (typeof document === 'undefined' || typeof HTMLElement === 'undefined') return false; + const terminalElement = this.terminal?.element; + const screen = terminalElement?.querySelector?.('.xterm-screen'); + const rows = screen?.querySelector?.('.xterm-rows'); + if ( + !(terminalElement instanceof HTMLElement) || + !(screen instanceof HTMLElement) || + !(rows instanceof HTMLElement) + ) { + return false; + } + + const terminalRect = terminalElement.getBoundingClientRect(); + const screenRect = screen.getBoundingClientRect(); + if (screenRect.width < 1 || screenRect.height < 1) return false; + + const cover = document.createElement('div'); + cover.className = 'terminal-history-replay-cover'; + cover.setAttribute('aria-hidden', 'true'); + cover.style.left = `${screenRect.left - terminalRect.left}px`; + cover.style.top = `${screenRect.top - terminalRect.top}px`; + cover.style.width = `${screenRect.width}px`; + cover.style.height = `${screenRect.height}px`; + + const frame = document.createElement('div'); + frame.className = `${screen.className} terminal-history-replay-frame`; + frame.style.width = `${screenRect.width}px`; + frame.style.height = `${screenRect.height}px`; + frame.appendChild(rows.cloneNode(true)); + const localEcho = [...screen.children].find( + (child) => + child instanceof HTMLElement && + child.style.pointerEvents === 'none' && + child.style.zIndex === '7' && + child.textContent + ); + if (localEcho) frame.appendChild(localEcho.cloneNode(true)); + cover.appendChild(frame); + + const hasContent = Boolean(rows.textContent?.trim() || localEcho?.textContent?.trim()); + const previous = this._terminalHistoryReplayCover; + terminalElement.appendChild(cover); + this._terminalHistoryReplayCover = cover; + this._terminalHistoryReplayCoverHasContent = hasContent; + this._terminalHistoryReplayCoverVersion += 1; + previous?.remove(); + return true; + }, + + _beginTerminalHistoryReplayCover(owner) { + if (this._terminalTransportFreezeOwner !== owner) { + this._terminalTransportFreezeSessionId = null; + this._terminalTransportFreezeOwner = null; + } + this._discardTerminalHistoryReplayCover(); + this._terminalHistoryReplayCoverOwner = owner; + this._terminalHistoryReplayCoverComplete = false; + this._terminalHistoryReplayCoverCompleteAt = 0; + this._terminalHistoryReplayQuietUntil = 0; + this._captureTerminalHistoryReplayCover(); + }, + + /** + * Keep the last composited pane visible while reconnecting transports establish + * whether they belong to this server process or a replacement process. + */ + _freezeTerminalForTransportLoss(sessionId) { + if ( + !sessionId || + sessionId !== this.activeSessionId || + this.sessions?.get?.(sessionId)?.mode !== 'codex' + ) { + return false; + } + if (this._terminalTransportFreezeSessionId === sessionId) return true; + + this._terminalTransportFreezeSessionId = sessionId; + this._terminalTransportFreezeOwner = null; + if (!this._terminalHistoryReplayCover) { + const owner = `transport-loss-${++this._terminalTransportFreezeSeq}`; + this._terminalTransportFreezeOwner = owner; + this._beginTerminalHistoryReplayCover(owner); + } + if (!this._terminalHistoryReplayCover) { + this._terminalTransportFreezeSessionId = null; + this._terminalTransportFreezeOwner = null; + return false; + } + return true; + }, + + /** + * A same-process init makes reconnect output authoritative. Keep the frozen + * frame through one final quiet/paint fence before revealing it. + */ + _releaseTerminalTransportFreeze(sessionId, quietMs) { + if ( + !sessionId || + this._terminalTransportFreezeSessionId !== sessionId + ) { + return false; + } + const owner = this._terminalTransportFreezeOwner; + this._terminalTransportFreezeSessionId = null; + this._terminalTransportFreezeOwner = null; + this._deferTerminalHistoryReplayCover(sessionId, quietMs); + if (owner && this._terminalHistoryReplayCoverOwner === owner) { + this._completeTerminalHistoryReplayCover(owner); + } else { + this._tryFinishTerminalHistoryReplayCover(); + } + return true; + }, + + _replaceTerminalHistoryReplayCover(owner, { onlyIfEmpty = false } = {}) { + if (this._terminalHistoryReplayCoverOwner !== owner) return false; + if (onlyIfEmpty && this._terminalHistoryReplayCoverHasContent) { + this._alignTerminalHistoryReplayCover(); + return false; + } + return this._captureTerminalHistoryReplayCover(); + }, + + _alignTerminalHistoryReplayCover() { + if (typeof HTMLElement === 'undefined') return false; + const cover = this._terminalHistoryReplayCover; + const terminalElement = this.terminal?.element; + const screen = terminalElement?.querySelector?.('.xterm-screen'); + if ( + !(cover instanceof HTMLElement) || + !(terminalElement instanceof HTMLElement) || + !(screen instanceof HTMLElement) + ) { + return false; + } + const terminalRect = terminalElement.getBoundingClientRect(); + const screenRect = screen.getBoundingClientRect(); + if (screenRect.width < 1 || screenRect.height < 1) return false; + cover.style.left = `${screenRect.left - terminalRect.left}px`; + cover.style.top = `${screenRect.top - terminalRect.top}px`; + cover.style.width = `${screenRect.width}px`; + cover.style.height = `${screenRect.height}px`; + return true; + }, + + _waitForTerminalPaint() { + return new Promise((resolve) => { + this._safeYield(() => this._safeYield(resolve)); + }); + }, + + _completeTerminalHistoryReplayCover(owner) { + if (this._terminalHistoryReplayCoverOwner !== owner) return; + this._terminalHistoryReplayCoverComplete = true; + this._terminalHistoryReplayCoverCompleteAt = Date.now(); + this._tryFinishTerminalHistoryReplayCover(); + }, + + _deferTerminalHistoryReplayCover(sessionId, quietMs) { + if ( + !this._terminalHistoryReplayCover || + sessionId !== this.activeSessionId || + this.sessions?.get?.(sessionId)?.mode !== 'codex' + ) { + return; + } + const settleMs = Number.isFinite(quietMs) + ? quietMs + : this._serverRestartRecovery + ? CODEX_RESTART_RECOVERY_QUIET_MS + : CODEX_POST_SWITCH_QUIET_MS; + // A terminal.write('', callback) may already have armed removal behind two + // compositor yields. New output revokes that ownership before extending the + // quiet deadline, so the old callback cannot reveal an intermediate frame. + if (this._terminalHistoryReplayFencePending) { + this._terminalHistoryReplayFencePending = false; + this._terminalHistoryReplayCoverVersion += 1; + } + this._terminalHistoryReplayQuietUntil = Math.max( + this._terminalHistoryReplayQuietUntil || 0, + Date.now() + settleMs + ); + if (this._terminalHistoryReplayCoverComplete) this._tryFinishTerminalHistoryReplayCover(); + }, + + _tryFinishTerminalHistoryReplayCover() { + if ( + !this._terminalHistoryReplayCover || + !this._terminalHistoryReplayCoverComplete || + this._terminalHistoryReplayFencePending || + this._terminalTransportFreezeSessionId + ) { + return; + } + + const activeMode = this.activeSessionId + ? this.sessions?.get?.(this.activeSessionId)?.mode + : null; + const codexSettling = + activeMode === 'codex' && + (this._wsState === 'connecting' || Date.now() < (this._terminalHistoryReplayQuietUntil || 0)); + const pending = + this._isLoadingBuffer || + this._terminalWriteInFlight || + this.writeFrameScheduled || + this.pendingWrites?.length > 0 || + codexSettling; + const waitedMs = Date.now() - this._terminalHistoryReplayCoverCompleteAt; + const maxHoldMs = this._serverRestartRecovery + ? CODEX_RESTART_RECOVERY_MAX_HOLD_MS + : CODEX_POST_SWITCH_MAX_HOLD_MS; + if (pending && waitedMs < maxHoldMs) { + if (this._terminalHistoryReplayCoverCheckScheduled) return; + this._terminalHistoryReplayCoverCheckScheduled = true; + this._safeYield(() => { + this._terminalHistoryReplayCoverCheckScheduled = false; + this._tryFinishTerminalHistoryReplayCover(); + }); + return; + } + + // Keep the visible cover immutable. Re-cloning the final viewport into that + // cover would itself expose an intermediate frame before removal. xterm + // paints underneath the old frame; the double yield then reveals the + // settled viewport in one compositor handoff. New output revokes the fence + // through _deferTerminalHistoryReplayCover(). + const terminal = this.terminal; + const version = this._terminalHistoryReplayCoverVersion; + this._terminalHistoryReplayFencePending = true; + const removeAfterPaint = () => { + this._safeYield(() => { + this._safeYield(() => { + if ( + this._terminalHistoryReplayFencePending && + this._terminalHistoryReplayCoverVersion === version + ) { + const finishedRestartRecovery = this._serverRestartRecovery; + this._discardTerminalHistoryReplayCover(); + if (finishedRestartRecovery) { + this._serverRestartRecovery = false; + try { sessionStorage.removeItem(SERVER_RESTART_RECOVERY_KEY); } catch {} + } + } + }); + }); + }; + if (terminal?.write) terminal.write('', removeAfterPaint); + else removeAfterPaint(); + }, + + _discardTerminalHistoryReplayCover(owner) { + if (owner !== undefined && this._terminalHistoryReplayCoverOwner !== owner) return; + this._terminalHistoryReplayCover?.remove(); + this._terminalHistoryReplayCover = null; + this._terminalHistoryReplayCoverOwner = null; + this._terminalHistoryReplayCoverHasContent = false; + this._terminalHistoryReplayCoverComplete = false; + this._terminalHistoryReplayCoverCompleteAt = 0; + this._terminalHistoryReplayCoverCheckScheduled = false; + this._terminalHistoryReplayFencePending = false; + this._terminalHistoryReplayQuietUntil = 0; + this._terminalHistoryReplayCoverVersion += 1; + }, + scrollToLastNonEmptyLine() { if (!this.terminal?.buffer?.active) { this.terminal?.scrollToBottom?.(); @@ -2369,18 +2927,33 @@ Object.assign(CodemanApp.prototype, { * Uses _safeYield to spread work across frames; falls back to setTimeout * and a tick-Worker so progress continues on occluded / idle-throttled tabs. * @param {string} buffer - The full terminal buffer to write - * @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps) + * @param {number} chunkSize - Size of each chunk (default 32KB) + * @param {string|number} loadOwner - Optional owner for a wider buffer-load transaction + * @param {{ followBottom?: boolean }} options - Keep the live viewport pinned while scrollback grows * @returns {Promise} - Resolves when all chunks written */ - chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner) { + chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE, loadOwner, options = {}) { // Generation counter: if a newer chunkedTerminalWrite starts (tab switch), // older writes abort instead of continuing to push stale data into the terminal. const writeGen = ++this._chunkedWriteGen; - const bufferLoadOwner = this._beginBufferLoad(loadOwner); + // A caller-provided owner means a wider operation (selectSession) already + // owns the live-output gate. Do not close that transaction after this one + // replay; the caller still has resize/fetch/reconciliation work to finish. + const ownsBufferLoad = loadOwner == null; + const bufferLoadOwner = ownsBufferLoad ? this._beginBufferLoad() : loadOwner; + const finishOwnedBufferLoad = () => { + if (ownsBufferLoad) this._finishBufferLoad(bufferLoadOwner); + }; + const followBottom = options.followBottom === true; + const keepAtBottom = () => { + if (followBottom && this._chunkedWriteGen === writeGen) { + this.terminal?.scrollToBottom?.(); + } + }; return new Promise((resolve) => { if (!buffer || buffer.length === 0) { - this._finishBufferLoad(bufferLoadOwner); + finishOwnedBufferLoad(); resolve(); return; } @@ -2392,14 +2965,17 @@ Object.assign(CodemanApp.prototype, { const finish = () => { // Only finish if we're still the active write — a newer write owns buffer load state if (this._chunkedWriteGen === writeGen) { - this._finishBufferLoad(bufferLoadOwner); + finishOwnedBufferLoad(); } resolve(); }; // For small buffers, write directly — single-frame render is fast enough if (cleanBuffer.length <= chunkSize) { - this.terminal.write(cleanBuffer, finish); + this.terminal.write(cleanBuffer, () => { + keepAtBottom(); + finish(); + }); return; } @@ -2426,20 +3002,29 @@ Object.assign(CodemanApp.prototype, { return; } - const _ct0 = performance.now(); const chunk = cleanBuffer.slice(offset, offset + chunkSize); - this.terminal.write(chunk); - const _cdt = performance.now() - _ct0; - _chunkCount++; - if (_cdt > 50) - console.warn( - `[CRASH-DIAG] chunk #${_chunkCount} write took ${_cdt.toFixed(0)}ms (${chunk.length} bytes at offset ${offset})` - ); - offset += chunkSize; + const chunkOffset = offset; + const parseStartedAt = performance.now(); + this.terminal.write(chunk, () => { + if (this._chunkedWriteGen !== writeGen) { + resolve(); + return; + } + const parseMs = performance.now() - parseStartedAt; + _chunkCount++; + if (parseMs > 100) { + _crashDiag.log( + `XTERM_REPLAY_PARSE: ${parseMs.toFixed(0)}ms for ${chunk.length} bytes at ${chunkOffset}` + ); + } + offset += chunk.length; + keepAtBottom(); - // Schedule next chunk; rAF if possible, else setTimeout/Worker - // fallback so progress doesn't stall on occluded/unfocused windows. - this._safeYield(writeChunk); + // Schedule the next chunk only after xterm has parsed this one. + // Visible pages receive a compositor frame; hidden pages use the + // timer/Worker fallback so replay still completes. + this._safeYield(writeChunk); + }); }; // Start writing @@ -2447,33 +3032,360 @@ Object.assign(CodemanApp.prototype, { }); }, + _terminalSnapshotCursorFromHeaders(headers) { + if (headers?.get?.('x-codeman-terminal-format') !== 'stream-v1') return null; + const cursor = { + stream: headers.get('x-codeman-terminal-stream'), + generation: Number(headers.get('x-codeman-terminal-generation')), + start: Number(headers.get('x-codeman-terminal-start')), + end: Number(headers.get('x-codeman-terminal-end')), + }; + return this._isTerminalCursor(cursor) ? cursor : null; + }, + + _terminalHistoryPageFromHeaders(headers) { + const start = Number(headers?.get?.('x-codeman-history-start')); + const end = Number(headers?.get?.('x-codeman-history-end')); + const total = Number(headers?.get?.('x-codeman-history-total')); + const origin = headers?.get?.('x-codeman-history-origin') || ''; + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + !Number.isSafeInteger(total) || + start < 0 || + end < start || + total < end || + !origin + ) { + return null; + } + return { + start, + end, + total, + hasMoreBefore: headers.get('x-codeman-history-more-before') === '1', + hasMoreAfter: headers.get('x-codeman-history-more-after') === '1', + origin, + }; + }, + + /** + * Decode a compressed HTTP snapshot incrementally. Browser fetch exposes the + * losslessly decompressed bytes; TextDecoder preserves UTF-8 sequences split + * across response chunks, and xterm parsing stays serialized by awaiting each + * chunkedTerminalWrite call. + */ + async _readTerminalSnapshotResponse(response, options = {}) { + if (!response?.ok) { + throw new Error(`Terminal snapshot request failed (${response?.status ?? 'unknown'})`); + } + + const cursor = this._terminalSnapshotCursorFromHeaders(response.headers); + if (!cursor) { + const payload = await response.json(); + return { + ...(payload?.data ?? payload ?? {}), + streamed: false, + painted: false, + aborted: false, + }; + } + + const result = { + terminalBuffer: '', + status: response.headers.get('x-codeman-terminal-status') || undefined, + fullSize: Number(response.headers.get('x-codeman-terminal-full-size')) || 0, + truncated: response.headers.get('x-codeman-terminal-truncated') === '1', + source: response.headers.get('x-codeman-terminal-source') || 'history', + cursor, + historyPage: this._terminalHistoryPageFromHeaders(response.headers), + streamed: true, + painted: false, + aborted: false, + }; + const reader = response.body?.getReader?.(); + if (!reader) { + result.terminalBuffer = await response.text(); + if (options.paint && result.terminalBuffer) { + options.beforePaint?.(result); + await this.chunkedTerminalWrite( + result.terminalBuffer, + options.chunkSize ?? TERMINAL_CHUNK_SIZE, + options.loadOwner, + { followBottom: options.followBottom === true } + ); + result.painted = true; + } + return result; + } + + const decoder = new TextDecoder(); + const chunks = []; + const paintChunk = async (text) => { + if (!text) return; + chunks.push(text); + if (!options.paint) return; + if (!result.painted) { + options.beforePaint?.(result); + result.painted = true; + } + await this.chunkedTerminalWrite( + text, + options.chunkSize ?? TERMINAL_CHUNK_SIZE, + options.loadOwner, + { followBottom: options.followBottom === true } + ); + }; + + while (true) { + if (options.isCancelled?.()) { + result.aborted = true; + await reader.cancel().catch(() => {}); + return result; + } + const { done, value } = await reader.read(); + if (options.isCancelled?.()) { + result.aborted = true; + await reader.cancel().catch(() => {}); + return result; + } + if (done) break; + await paintChunk(decoder.decode(value, { stream: true })); + } + await paintChunk(decoder.decode()); + result.terminalBuffer = chunks.join(''); + return result; + }, + + _isTerminalActionSubmission(input) { + if (typeof input !== 'string' || !input || input.startsWith('\x1b[200~')) return false; + if (input === '\r' || input === '\n') return true; + return /\x1b\[<0;\d+;\d+[Mm]/.test(input); + }, + + _hasForegroundTerminalDecision(sessionId = this.activeSessionId) { + const hooks = sessionId ? this.pendingHooks?.get?.(sessionId) : null; + if (hooks?.has?.('permission_prompt') || hooks?.has?.('elicitation_dialog')) return true; + if ( + !sessionId || + sessionId !== this.activeSessionId || + !this.terminal || + !this._terminalViewportAtBottom() + ) { + return false; + } + + const buffer = this.terminal.buffer?.active; + if (!buffer?.getLine) return false; + const rows = Math.max(1, this.terminal.rows || 1); + let choices = 0; + let selectedChoice = false; + let selectionInstruction = false; + for (let row = 0; row < rows; row++) { + const line = buffer.getLine(buffer.viewportY + row)?.translateToString?.(true) || ''; + if (/^\s*(?:[❯›]\s*)?\d+[.)]\s+\S/.test(line)) choices += 1; + if (/^\s*[❯›]\s*\d+[.)]\s+\S/.test(line)) selectedChoice = true; + if ( + /(?:enter|return).*(?:select|confirm)|(?:select|confirm).*(?:enter|return)|esc\s+to\s+(?:cancel|go back)/i.test( + line + ) + ) { + selectionInstruction = true; + } + } + return choices >= 2 && (selectedChoice || selectionInstruction); + }, + + _shouldReconcileTerminalAction(sessionId, input) { + const isTouch = + typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.(); + const session = sessionId ? this.sessions?.get?.(sessionId) : null; + return Boolean( + isTouch && + sessionId === this.activeSessionId && + session?.mode !== 'shell' && + this._isTerminalActionSubmission(input) && + this._hasForegroundTerminalDecision(sessionId) + ); + }, + + _isTerminalFrameReconcileCurrent(request) { + return Boolean( + request && + request.id === this._terminalFrameReconcileSeq && + request.sessionId === this.activeSessionId + ); + }, + + _requestTerminalFrameReconcile(options = {}) { + const sessionId = this.activeSessionId; + const session = sessionId ? this.sessions?.get?.(sessionId) : null; + if (!sessionId || !this.terminal || session?.mode === 'shell') { + return Promise.resolve(false); + } + + const request = { + captureWhenUnchanged: options.captureWhenUnchanged === true, + id: ++this._terminalFrameReconcileSeq, + reason: options.reason || 'terminal-transition', + resizeOptions: options.resizeOptions || null, + sessionId, + settleMs: Math.max(0, Number(options.settleMs) || 0), + }; + this._terminalFrameReconcilePending = request; + if (!this._terminalFrameReconcilePromise) { + let trackedPromise; + trackedPromise = this._drainTerminalFrameReconciles().finally(() => { + if (this._terminalFrameReconcilePromise === trackedPromise) { + this._terminalFrameReconcilePromise = null; + } + }); + this._terminalFrameReconcilePromise = trackedPromise; + } + return this._terminalFrameReconcilePromise; + }, + + async _drainTerminalFrameReconciles() { + let result = false; + while (this._terminalFrameReconcilePending) { + const request = this._terminalFrameReconcilePending; + this._terminalFrameReconcilePending = null; + result = await this._runTerminalFrameReconcile(request); + } + return result; + }, + + async _runTerminalFrameReconcile(request) { + if (!this._isTerminalFrameReconcileCurrent(request) || this._isLoadingBuffer) return false; + + let loadOwner = this._beginBufferLoad(`frame-reconcile-${request.id}`); + let authoritative = false; + try { + if (request.resizeOptions) { + const dimensionsChanged = await this.sendResize(request.sessionId, request.resizeOptions); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + if (!dimensionsChanged && !request.captureWhenUnchanged) { + this._finishBufferLoad(loadOwner, { flushQueued: true }); + loadOwner = null; + await this._waitForTerminalPaint(); + if ( + this._isTerminalFrameReconcileCurrent(request) && + typeof KeyboardHandler !== 'undefined' + ) { + KeyboardHandler.onTerminalFrameAuthoritative?.(); + } + return false; + } + } + + if (request.settleMs > 0) { + await new Promise((resolve) => setTimeout(resolve, request.settleMs)); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + } + + const response = await fetch( + `/api/sessions/${request.sessionId}/terminal?latest=1` + + `&tail=${TERMINAL_LATEST_FRAME_SIZE}&format=stream` + ); + const latest = await this._readTerminalSnapshotResponse(response, { + paint: false, + isCancelled: () => !this._isTerminalFrameReconcileCurrent(request), + }); + if ( + latest.aborted || + !this._isTerminalFrameReconcileCurrent(request) || + latest.source !== 'mux-visible' || + !latest.terminalBuffer || + !this._isTerminalCursor(latest.cursor) + ) { + return false; + } + + // The pane snapshot includes everything accepted by the PTY through its + // cursor boundary. Drop pre-snapshot browser work, cross xterm's parser + // fence, then repaint only the visible rows so existing scrollback stays. + this.pendingWrites = []; + this.writeFrameScheduled = false; + this._clearTimer('flickerFilterTimeout'); + this.flickerFilterBuffer = ''; + this.flickerFilterActive = false; + await this._waitForTerminalParserFence(); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + + // A size-bounded WS transaction may have parsed its opening DEC-2026 + // marker while its closing fragment is still queued in pendingWrites. + // Close that transport transaction before dropping the queue, then paint + // the replacement pane inside a fresh synchronized update. The inert + // frame cover keeps both parser operations invisible to the user. + const syncStart = '\x1b[?2026h'; + const syncEnd = '\x1b[?2026l'; + await new Promise((resolve) => + this.terminal.write(`${syncEnd}${syncStart}\x1b[0m\x1b[H\x1b[2J`, resolve) + ); + await this.chunkedTerminalWrite( + latest.terminalBuffer, + TERMINAL_CHUNK_SIZE, + loadOwner, + { followBottom: true } + ); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + await new Promise((resolve) => this.terminal.write(syncEnd, resolve)); + + this._terminalScrollLocked = false; + this._wasAtBottomBeforeWrite = true; + this.terminal.scrollToBottom(); + await this._waitForTerminalPaint(); + if (!this._isTerminalFrameReconcileCurrent(request)) return false; + + this._finishBufferLoad(loadOwner, { snapshotCursor: latest.cursor }); + loadOwner = null; + this._syncMobileHelperTextareaToCursor?.(); + this._localEchoOverlay?.rerender?.(); + await this._waitForTerminalPaint(); + if (this._isTerminalFrameReconcileCurrent(request)) { + authoritative = true; + if (typeof KeyboardHandler !== 'undefined') { + KeyboardHandler.onTerminalFrameAuthoritative?.(); + } + } + _crashDiag.log(`FRAME_RECONCILE: ${request.reason}`); + return true; + } catch (err) { + console.warn(`Failed to reconcile terminal frame after ${request.reason}:`, err); + return false; + } finally { + if (loadOwner !== null) { + this._finishBufferLoad(loadOwner, { flushQueued: true }); + } + if ( + !authoritative && + this._isTerminalFrameReconcileCurrent(request) && + !this._terminalFrameReconcilePending && + typeof KeyboardHandler !== 'undefined' + ) { + KeyboardHandler.onTerminalFrameReady?.(); + } + } + }, + /** * Complete a buffer load: unblock live SSE writes. * Called when chunkedTerminalWrite finishes (or is skipped for empty buffers). * - * By default queued SSE events are DISCARDED, not flushed. For an established - * session the loaded buffer from the API is the source of truth up to the - * response timestamp; SSE events queued during the fetch+write overlap already - * appear in that buffer, so flushing them writes duplicate data (especially Ink - * cursor-up redraws), corrupting the terminal display. - * - * COD-144: a brand-new session is the exception. Its terminal fetch can resolve - * BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the - * prompt arrives only as a queued SSE event. Discarding it leaves the terminal - * blank until a tab-switch re-fetches a now-populated buffer. When the caller - * knows the load painted nothing (empty fetch + no cache), it passes - * `{ flushQueued: true }` so the queued events are REPLAYED through - * `batchTerminalWrite()` instead of dropped. Replay runs after `_isLoadingBuffer` - * is cleared, so the events write through normally and are not re-queued. + * Cursor-bearing events are reconciled against the snapshot boundary: covered + * output is discarded, output after the boundary is replayed, and a batch that + * crosses the boundary contributes only its uncovered suffix. Legacy events + * retain COD-144's empty-buffer `flushQueued` fallback. * * After unblocking, new SSE/WS events deliver subsequent output normally. * * @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op. - * @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events. + * @param {{ flushQueued?: boolean, snapshotCursor?: object }} [opts] */ _beginBufferLoad(owner) { if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0; const loadOwner = owner === undefined ? `buffer-${++this._bufferLoadSeq}` : owner; + this._terminalRenderEpoch = (this._terminalRenderEpoch || 0) + 1; this._bufferLoadOwner = loadOwner; this._isLoadingBuffer = true; this._loadBufferQueue = []; @@ -2488,16 +3400,81 @@ Object.assign(CodemanApp.prototype, { this._isLoadingBuffer = false; this._loadBufferQueue = null; this._bufferLoadOwner = null; - // COD-144: replay (rather than discard) queued live events when the load - // painted nothing — the queued prompt is the only content a new session has. - if (opts?.flushQueued && queued && queued.length) { - for (const data of queued) { - this.batchTerminalWrite(data); + if (queued && queued.length) { + for (const item of queued) { + if (opts?.snapshotCursor && typeof item === 'object' && item !== null) { + const replay = this._terminalEventAfterSnapshot(item, opts.snapshotCursor); + if (replay) this.batchTerminalWrite(replay.data, replay.cursor); + } else if (opts?.flushQueued) { + const data = typeof item === 'string' ? item : item.data; + const cursor = typeof item === 'string' ? undefined : item.cursor; + if (cursor) this.batchTerminalWrite(data, cursor); + else this.batchTerminalWrite(data); + } } } + // A user can type while selectSession() is replaying its initial frame. + // Local echo retains those characters but deliberately hides them while + // xterm's cursor is provisional. Re-anchor after all already-queued writes + // are parsed; queued live prompt events schedule their own later rerender. + const overlay = this._localEchoOverlay; + if (overlay?.hasPending && this.terminal?.write) { + this.terminal.write('', () => { + if (!this._isLoadingBuffer && overlay === this._localEchoOverlay && overlay.hasPending) { + overlay.rerender(); + } + }); + } return true; }, + _isTerminalCursor(cursor) { + return Boolean( + cursor && + typeof cursor.stream === 'string' && + cursor.stream.length > 0 && + Number.isSafeInteger(cursor.generation) && + cursor.generation >= 0 && + Number.isSafeInteger(cursor.start) && + Number.isSafeInteger(cursor.end) && + cursor.start >= 0 && + cursor.end >= cursor.start + ); + }, + + _terminalEventAfterSnapshot(item, snapshotCursor) { + const cursor = item?.cursor; + if (!this._isTerminalCursor(cursor) || !this._isTerminalCursor(snapshotCursor)) return null; + if (cursor.stream !== snapshotCursor.stream || cursor.generation < snapshotCursor.generation) return null; + if (cursor.generation > snapshotCursor.generation) return item; + if (cursor.end <= snapshotCursor.end) return null; + if (cursor.start >= snapshotCursor.end) return item; + + const coveredLength = snapshotCursor.end - cursor.start; + let suffix; + const syncStart = '\x1b[?2026h'; + const syncEnd = '\x1b[?2026l'; + const payloadStart = item.data.startsWith(syncStart) ? syncStart.length : 0; + const payloadEnd = item.data.endsWith(syncEnd) + ? item.data.length - syncEnd.length + : item.data.length; + const hasCursorAlignedPayload = + payloadEnd - payloadStart === cursor.end - cursor.start; + if (hasCursorAlignedPayload) { + // A WS transaction can span several size-bounded messages, so the item + // crossing the snapshot boundary may carry only one outer marker. Rewrap + // its uncovered payload as a complete synchronized update. + suffix = syncStart + item.data.slice(payloadStart + coveredLength, payloadEnd) + syncEnd; + } else { + suffix = item.data.slice(coveredLength); + } + + return { + data: suffix, + cursor: { ...cursor, start: snapshotCursor.end }, + }; + }, + // ═══════════════════════════════════════════════════════════════ // Terminal Controls // ═══════════════════════════════════════════════════════════════ @@ -2509,11 +3486,7 @@ Object.assign(CodemanApp.prototype, { /** Insert editable text at the active prompt without pressing Enter. */ insertTerminalText(text) { if (!this.activeSessionId || !text) return; - if (this._localEchoEnabled && this._localEchoOverlay) { - this._localEchoOverlay.appendText(text); - } else { - this.sendInput(text).catch(() => {}); - } + this._terminalInputController.insertText(text); this.terminal?.focus(); }, @@ -2525,27 +3498,9 @@ Object.assign(CodemanApp.prototype, { if (!this.activeSessionId) return; if (typeof CjkInput !== 'undefined') CjkInput.clear(); - if (this._inputFlushTimeout) { - clearTimeout(this._inputFlushTimeout); - this._inputFlushTimeout = null; - } - this._pendingInput = ''; - - if (this._localEchoEnabled && this._localEchoOverlay) { - const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' }; - this._localEchoOverlay.clear(); - this._localEchoOverlay.suppressBufferDetection(); - this._flushedOffsets?.delete(this.activeSessionId); - this._flushedTexts?.delete(this.activeSessionId); - if (flushed.count > 0) { - this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {}); - } - } else { - // In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U - // is the conventional kill-line key supported by shells and agent TUIs. - this.sendInput('\x15').catch(() => {}); - } - + this._terminalInputController.clearInput(); + this._flushedOffsets?.delete(this.activeSessionId); + this._flushedTexts?.delete(this.activeSessionId); this.showToast?.('Input cleared', 'success'); this.terminal?.focus(); }, @@ -2572,7 +3527,7 @@ Object.assign(CodemanApp.prototype, { // Force resize even when dimensions match the server's last known state — // another device may have changed the PTY size since this client last sent, // and force guarantees a SIGWINCH → Ink redraw at the current device's size. - await this.sendResize(this.activeSessionId, { force: true }); + await this.sendResize(this.activeSessionId, { force: true, takeControl: true }); this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success'); } catch (err) { @@ -2629,6 +3584,163 @@ Object.assign(CodemanApp.prototype, { } catch {} }, + _isMobileTerminalInputFocused() { + const active = document.activeElement; + return ( + active === this.terminal?.textarea || + active?.classList?.contains('xterm-helper-textarea') || + active?.id === 'cjkInput' + ); + }, + + /** + * Separate terminal input from TUI-owned content on touch devices. A hidden + * keyboard must not consume taps on expandable readbacks, tool results, or + * decision rows; those taps belong to the foreground CLI. The visible prompt + * row remains the deliberate keyboard target. + */ + _classifyMobileTerminalTap(clientX, clientY) { + if (!this._terminalViewportAtBottom()) return 'history'; + + const pos = this._clientPointToCell(clientX, clientY); + if (!pos || !this.terminal) return 'input'; + + const mouseMode = this.terminal.modes?.mouseTrackingMode; + const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; + if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input'; + + // Permission/elicitation prompts own the full live terminal until answered. + if (document.body?.classList?.contains('terminal-action-pending')) return 'content'; + + const buffer = this.terminal.buffer?.active; + if (!buffer?.getLine) return 'input'; + + const rows = Math.max(1, this.terminal.rows || 1); + const lines = []; + const wrappedRows = []; + let hasVisibleContent = false; + for (let row = 0; row < rows; row++) { + const line = buffer.getLine(buffer.viewportY + row); + const text = line?.translateToString?.(true) || ''; + lines.push(text); + wrappedRows.push(Boolean(line?.isWrapped)); + if (text.trim()) hasVisibleContent = true; + } + if (!hasVisibleContent) return 'input'; + + const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0)); + const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude'; + let promptRow = -1; + let menuSelectionVisible = false; + + if (mode === 'opencode') { + if (lines[cursorRow]?.includes('\u2503')) promptRow = cursorRow; + } else { + for (let row = rows - 1; row >= 0; row--) { + const promptMatch = lines[row].match(/^\s*[❯›]/); + if (!promptMatch) continue; + const tail = lines[row].slice(promptMatch[0].length).trim(); + // A highlighted numbered choice is a menu row, not an editable prompt. + const hasSiblingChoice = lines.some( + (line, choiceRow) => choiceRow !== row && /^\s+\d+[.)]\s/.test(line) + ); + if (/^\d+[.)]\s/.test(tail) && hasSiblingChoice) { + menuSelectionVisible = true; + break; + } + promptRow = row; + break; + } + } + + const tappedRow = pos.row - 1; + let logicalLineStart = tappedRow; + while (logicalLineStart > 0 && wrappedRows[logicalLineStart]) logicalLineStart--; + let logicalLineEnd = tappedRow; + while (logicalLineEnd + 1 < rows && wrappedRows[logicalLineEnd + 1]) logicalLineEnd++; + const tappedLine = lines.slice(logicalLineStart, logicalLineEnd + 1).join(''); + if ( + mode === 'claude' && + /^\s*[•·]\s*Working\b.*(?:background|esc to interrupt)/i.test(tappedLine) + ) { + return 'content'; + } + if (menuSelectionVisible) return 'content'; + if (promptRow >= 0) { + const inputEnd = cursorRow >= promptRow ? cursorRow : promptRow; + if (tappedRow >= promptRow && tappedRow <= inputEnd) return 'input'; + } else if ( + tappedRow === cursorRow || + tappedRow >= + Math.max( + 0, + rows - + window.CodemanTerminalInput + .TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM + ) + ) { + // During redraws a CLI can temporarily omit its prompt marker or place + // the cursor above a status footer. Keep the live cursor and a stable + // lower-screen focus band usable without turning transcript rows above + // that band into keyboard targets. + return 'input'; + } + + return 'content'; + }, + + _blurMobileTerminalInput() { + const active = document.activeElement; + if ( + active === this.terminal?.textarea || + active?.classList?.contains('xterm-helper-textarea') || + active?.id === 'cjkInput' + ) { + active.blur?.(); + } + }, + + _focusMobileTerminalInput() { + this._syncMobileHelperTextareaToCursor(); + const cjkInput = document.getElementById('cjkInput'); + if (cjkInput?.classList.contains('cjk-input-visible')) { + cjkInput.focus(); + } else { + this.terminal?.focus(); + } + }, + + _handleMobileTerminalTap(touch, startedWithTerminalFocus) { + if (!touch || !this.terminal) return 'history'; + const intent = this._classifyMobileTerminalTap(touch.clientX, touch.clientY); + if (intent === 'history') { + this._blurMobileTerminalInput(); + return intent; + } + + const mouseMode = this.terminal.modes?.mouseTrackingMode; + const mouseTrackingOn = !!mouseMode && mouseMode !== 'none'; + const shouldActivate = intent === 'content' || startedWithTerminalFocus; + if (shouldActivate && mouseTrackingOn) { + // xterm's mouse encoder owns live DECSET modes. The synthetic DOM click + // follows the same path as a desktop click. + this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY); + } else if (shouldActivate && this._sessionUsesServerMouseStrip()) { + // Claude/Codex/Gemini DECSETs are stripped from the browser stream, so + // report directly to the PTY while retaining local touch scrollback. + this._sendSyntheticSgrTap(touch.clientX, touch.clientY); + } + + if (intent === 'content') { + // A synthetic xterm click can focus its helper textarea. Blur after the + // report so collapsing a readback never opens or retains the keyboard. + this._blurMobileTerminalInput(); + } else { + this._focusMobileTerminalInput(); + } + return intent; + }, + // ═══════════════════════════════════════════════════════════════ // Synthetic tap → mouse report // ═══════════════════════════════════════════════════════════════ @@ -2760,6 +3872,15 @@ Object.assign(CodemanApp.prototype, { return this._terminalViewportAtBottom(); }, + // Claude keeps most transcript history inside its own TUI rather than xterm + // scrollback. On verified versions, route a touch drag through the same SGR + // wheel path as desktop. Codex keeps the existing local touch behavior. + _shouldForwardTouchScrollToApp() { + const session = this.sessions?.get(this.activeSessionId); + if (session?.mode !== 'claude') return false; + return this._shouldForwardWheelToApp({ shiftKey: false }); + }, + // Encode wheel ticks as SGR reports (button 64 = up, 65 = down) at the pointer // cell. Reports are coalesced into one fire-and-forget write per ~40ms: a // trackpad emits dozens of wheel events per second and each send becomes a @@ -2772,6 +3893,13 @@ Object.assign(CodemanApp.prototype, { if (!this.activeSessionId || !lines) return; const pos = this._clientPointToCell(clientX, clientY); if (!pos) return; + if (lines < 0) { + if (!this._terminalAppScrollSessions) this._terminalAppScrollSessions = new Set(); + this._terminalAppScrollSessions.add(this.activeSessionId); + if (typeof MobileNavigationPad !== 'undefined') { + MobileNavigationPad.syncJumpVisibility?.(); + } + } const btn = lines < 0 ? 64 : 65; const ticks = Math.min(Math.abs(lines), 5); const queued = this._wheelSgrQueue || ''; @@ -2815,6 +3943,73 @@ Object.assign(CodemanApp.prototype, { this._sendSyntheticSgrTap(ev.clientX, ev.clientY); }, + _handleTerminalSizingPointerDown(ev) { + if (!ev?.isTrusted || ev.button !== 0 || ev.isPrimary === false) return; + // Semantic terminal controls claim ownership when they emit their key. + // Skipping their pointerdown avoids a second refit/resize on normal taps + // that last longer than one animation frame. + if ( + typeof MobileTerminalControls !== 'undefined' && + MobileTerminalControls.isKeyControlTarget(ev.target) + ) { + return; + } + const isDesktopViewport = + typeof MobileDetection !== 'undefined' && + MobileDetection.getDeviceType?.() === 'desktop'; + const forceRedraw = + isDesktopViewport && + Boolean(ev.target?.closest?.('#terminalContainer')); + this._scheduleTerminalSizingClaim({ force: forceRedraw }); + }, + + _scheduleTerminalSizingClaim(options = {}) { + if (!this.activeSessionId || !this.fitAddon) return; + // Multiple focus/pointer signals can land in the same animation frame. + // Preserve the strongest request so an earlier passive focus claim cannot + // swallow a later explicit desktop terminal takeover. + if (options.force) this._terminalSizingClaimForce = true; + if (this._terminalSizingClaimFrame) return; + + this._terminalSizingClaimFrame = requestAnimationFrame(() => { + this._terminalSizingClaimFrame = null; + const force = this._terminalSizingClaimForce === true; + this._terminalSizingClaimForce = false; + if (document.visibilityState === 'hidden' || !this.activeSessionId || !this.fitAddon) return; + const keyboardVisible = + typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + this.sendResize(this.activeSessionId, { + ...(force ? { force: true } : {}), + takeControl: true, + refit: !keyboardVisible, + }).catch(() => {}); + }); + }, + + /** + * Send one terminal control key without focusing xterm. The active viewport + * requests PTY-size ownership while the key enters the existing reliable + * input queue immediately. + */ + sendTerminalKey(input) { + const sessionId = this.activeSessionId; + if (!sessionId || !input) return; + if (this._terminalSizingClaimFrame) { + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(this._terminalSizingClaimFrame); + } + this._terminalSizingClaimFrame = null; + } + this._terminalSizingClaimForce = false; + const keyboardVisible = + typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible; + this.sendResize(sessionId, { + takeControl: true, + refit: !keyboardVisible, + }).catch(() => {}); + this._terminalInputController.sendControl(input); + }, + _installMobileTapMouseGuard() { const el = this.terminal?.element; if (!el || el._codemanTapMouseGuardInstalled) return; @@ -2884,14 +4079,22 @@ Object.assign(CodemanApp.prototype, { /** * Send resize to a session with minimum dimension enforcement. * @param {string} sessionId - * @param {{ forceHttp?: boolean, force?: boolean }} [options] + * @param {{ forceHttp?: boolean, force?: boolean, takeControl?: boolean, refit?: boolean }} [options] * @returns {Promise} Whether dimensions changed from the last send */ async sendResize(sessionId, options = {}) { // Fit terminal to container before reading dimensions — ensures local // terminal size matches what we report to the server PTY. - if (this.fitAddon) this.fitAddon.fit(); - const dims = this.getTerminalDimensions(); + if (options.refit !== false && this.fitAddon) this.fitAddon.fit(); + const dims = + options.refit === false && + Number.isInteger(this.terminal?.cols) && + Number.isInteger(this.terminal?.rows) + ? { + cols: Math.max(this.terminal.cols, 40), + rows: Math.max(this.terminal.rows, 10), + } + : this.getTerminalDimensions(); if (!dims) return false; // Did the dimensions actually change since the last resize we sent? Callers // use this to skip work (e.g. the post-resize TUI-redraw settle) when no @@ -2916,6 +4119,7 @@ Object.assign(CodemanApp.prototype, { try { const msg = { t: 'z', c: dims.cols, r: dims.rows, v: viewportType }; if (options.force) msg.f = true; + if (options.takeControl) msg.a = true; this._ws.send(JSON.stringify(msg)); return changed; } catch { @@ -2924,6 +4128,7 @@ Object.assign(CodemanApp.prototype, { } const body = { ...dims, viewportType }; if (options.force) body.force = true; + if (options.takeControl) body.takeControl = true; await fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -2939,10 +4144,39 @@ Object.assign(CodemanApp.prototype, { */ async sendInput(input) { if (!this.activeSessionId || !input) return; - // Route through the durable, exactly-once delivery layer (useMux for the - // POST fallback) so voice / keyboard-accessory / paste input also survives a - // dropped link instead of being lost in a single best-effort fetch. - this._sendInputAsync(this.activeSessionId, input, { useMux: true }); + this._terminalInputController.sendExternalText(input); + }, + + /** Submit a command through the same draft and Enter ordering as typed input. */ + sendTerminalCommand(command) { + this._terminalInputController?.sendCommand(command); + }, + + /** + * Normalize clipboard line endings and optionally wrap them in the terminal's + * bracketed-paste protocol. Replayed TUI buffers often omit the one-time + * DECSET 2004 enable, so browser xterm state alone cannot be trusted here. + */ + _prepareTerminalPaste(text, bracketed) { + const normalized = String(text ?? '').replace(/\r?\n/g, '\r'); + return bracketed ? `\x1b[200~${normalized}\x1b[201~` : normalized; + }, + + /** + * Deliver clipboard/compose text as one ordered terminal paste operation. + * TUI sessions receive explicit bracketed-paste framing so embedded blank + * lines remain part of the draft instead of acting as Enter submissions. + * + * @param {string} text + * @param {{submit?: boolean}} [options] + */ + async sendPastedText(text, options = {}) { + const sessionId = this.activeSessionId; + if (!sessionId || !text) return; + const mode = this.sessions?.get(sessionId)?.mode; + const lineBreaks = String(text).match(/\r\n|\r|\n/g)?.length || 0; + _crashDiag.log(`PASTE_SEND mode=${mode || 'unknown'} len=${String(text).length} breaks=${lineBreaks}`); + this._terminalInputController.sendPaste(text, options); }, // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/voice-input.js b/src/web/public/voice-input.js index 4f79d038..e75ad9a2 100644 --- a/src/web/public/voice-input.js +++ b/src/web/public/voice-input.js @@ -569,12 +569,9 @@ const VoiceInput = { this._showComposeOverlay(trimmed); } } else { - // Direct mode: inject into local echo overlay if available, else send to PTY - if (app._localEchoEnabled && app._localEchoOverlay) { - app._localEchoOverlay.appendText(trimmed); - } else { - app.sendInput(trimmed).catch(() => {}); - } + // Direct mode still enters the controller so draft state and transport + // ordering stay coherent with keyboard input. + app.insertTerminalText(trimmed); this._showVoiceSendBtn(); setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150); } @@ -603,17 +600,7 @@ const VoiceInput = { // Click handler this._voiceSendHandler = () => { if (!app.activeSessionId) 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) { - const text = app._localEchoOverlay.pendingText || ''; - app._localEchoOverlay.clear(); - app._localEchoOverlay.suppressBufferDetection(); - if (text) app.sendInput(text).catch(() => {}); - setTimeout(() => app.sendInput('\r').catch(() => {}), 80); - } else { - app.sendInput('\r').catch(() => {}); - } + app.sendEnterKey(); // Blink then restore gear.classList.add('voice-send-blink'); setTimeout(() => this._hideVoiceSendBtn(), 400); @@ -656,7 +643,7 @@ const VoiceInput = { const send = () => { const val = textarea.value.trim(); overlay.remove(); - if (val) app.sendInput(val + '\r').catch(() => {}); + if (val) app.sendPastedText(val, { submit: true }).catch(() => {}); }; const cancel = () => overlay.remove(); const newInput = () => { diff --git a/src/web/public/webview-tabs.js b/src/web/public/webview-tabs.js index e092cea3..fc9036d6 100644 --- a/src/web/public/webview-tabs.js +++ b/src/web/public/webview-tabs.js @@ -153,6 +153,10 @@ Object.assign(CodemanApp.prototype, { const src = data.embedUrl || data.webview?.url || webview.url; this._mountWebviewFrame(id, src, data.webview || webview); this.activeWebviewId = id; + if (typeof MobileTerminalControls !== 'undefined') { + MobileTerminalControls.blurTerminalInput?.(); + MobileTerminalControls.syncVisibility?.(); + } this.hideWelcome?.(); document.querySelector('.main')?.classList.add('webview-active'); this.renderSessionTabs(); @@ -286,6 +290,36 @@ Object.assign(CodemanApp.prototype, { frame.classList.remove('active'); } this._updateActiveWebviewTab(); + if (typeof MobileTerminalControls !== 'undefined') { + MobileTerminalControls.syncVisibility?.(); + } + }, + + /** + * A web tab keeps activeSessionId subscribed in the background. Selecting that + * same session must still restore the hidden terminal surface and reclaim its + * viewport instead of taking selectSession()'s normal same-session fast path. + */ + _restoreTerminalFromWebview(sessionId, { takeControl = true } = {}) { + if (!this.activeWebviewId && !document.querySelector('.main.webview-active')) { + return false; + } + + this._hideWebviewLayer(); + this._updateActiveTabImmediate?.(sessionId); + this.renderSessionTabs?.(); + this._updateLocalEchoState?.(); + if (this._shouldFocusTerminalForTabSwitch?.()) this.terminal?.focus?.(); + + requestAnimationFrame(() => { + if (this.activeSessionId !== sessionId || this.activeWebviewId) return; + this.sendResize?.(sessionId, { + force: true, + ...(takeControl ? { takeControl: true } : {}), + })?.catch?.(() => {}); + this._scheduleTerminalRepaint?.(); + }); + return true; }, // ── Run-menu entries ────────────────────────────────────────────────────── diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index b48f9e74..faac9f16 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -31,11 +31,18 @@ import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../docume import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js'; +import { + getGitCommitDetails, + getGitDiffDetail, + getGitRepositoryOverview, + resolveRepositoryBrowseRoot, +} from '../../git-repository-browser.js'; import { CASES_DIR, canAccessOwned, findSessionOrFail, getAuthUser, + isWorkingDirAllowed, parseBody, validateSessionFilePath, } from '../route-helpers.js'; @@ -453,6 +460,30 @@ function appendDownloadFlag(url: string): string { return `${url}${url.includes('?') ? '&' : '?'}download=true`; } +async function resolveFileBrowserWorkingDir( + workingDir: string, + scope: string | undefined, + req: FastifyRequest +): Promise { + let resolvedRoot = workingDir; + if (scope) { + const repositoryRoot = await resolveRepositoryBrowseRoot(workingDir, scope); + if (repositoryRoot) { + resolvedRoot = repositoryRoot; + } else if (scope !== 'current') { + throw new Error('Repository worktree scope not found'); + } + } + if (!isWorkingDirAllowed(getAuthUser(req), resolvedRoot)) { + throw new Error('Repository worktree scope is outside the allowed workspace'); + } + return resolvedRoot; +} + +function appendFileBrowserScope(url: string, scope?: string): string { + return scope ? `${url}${url.includes('?') ? '&' : '?'}scope=${encodeURIComponent(scope)}` : url; +} + function getSessionAttachmentHistory( ctx: SessionPort & ConfigPort, sessionId: string, @@ -564,6 +595,73 @@ async function buildExternalAttachmentRouteItem( } export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void { + // Repository/worktree overview for the File Viewer. Git commands run against + // a server-resolved session path; clients select only opaque worktree ids. + app.get('/api/sessions/:id/repository', async (req) => { + const { id } = req.params as { id: string }; + const { scope } = req.query as { scope?: string }; + const session = findSessionOrFail(ctx, id, req); + try { + const selectedRoot = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + const user = getAuthUser(req); + const overview = await getGitRepositoryOverview(session.workingDir, scope); + overview.worktrees = overview.worktrees.filter((worktree) => isWorkingDirAllowed(user, worktree.path)); + if (overview.repositoryRoot && !isWorkingDirAllowed(user, overview.repositoryRoot)) { + overview.repositoryRoot = selectedRoot; + } + return { + success: true, + data: overview, + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + }); + + app.get('/api/sessions/:id/repository/commit', async (req) => { + const { id } = req.params as { id: string }; + const { scope, commit } = req.query as { scope?: string; commit?: string }; + const session = findSessionOrFail(ctx, id, req); + if (!commit) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing commit parameter'); + } + try { + const selectedRoot = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + return { + success: true, + data: await getGitCommitDetails(selectedRoot, 'current', commit), + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + }); + + app.get('/api/sessions/:id/repository/diff', async (req) => { + const { id } = req.params as { id: string }; + const { + scope, + path: filePath, + commit, + } = req.query as { + scope?: string; + path?: string; + commit?: string; + }; + const session = findSessionOrFail(ctx, id, req); + if (!filePath) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); + } + try { + const selectedRoot = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + return { + success: true, + data: await getGitDiffDetail(selectedRoot, 'current', filePath, commit), + }; + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + }); + // Lazy filesystem listing for the Link Existing and mobile input path pickers. app.get('/api/filesystem/browse', async (req, reply): Promise> => { const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query); @@ -740,12 +838,21 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; - const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; + const { depth, showHidden, scope } = req.query as { + depth?: string; + showHidden?: string; + scope?: string; + }; const session = findSessionOrFail(ctx, id, req); const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; - const workingDir = session.workingDir; + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } // Default excludes - large/generated directories const excludeDirs = new Set([ @@ -864,15 +971,33 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // Get file content for preview (File Browser) app.get('/api/sessions/:id/file-content', async (req) => { const { id } = req.params as { id: string }; - const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; + const { + path: filePath, + lines, + raw, + scope, + } = req.query as { + path?: string; + lines?: string; + raw?: string; + scope?: string; + }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); } - // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const validated = validateSessionFilePath(session.workingDir, filePath); + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + } catch (err) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err)); + } + + // Validate path is within the server-resolved workspace/worktree root + // (security: resolve symlinks to prevent traversal). + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); } @@ -936,7 +1061,10 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even ? 'audio' : null; - const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`; + const fileRawUrl = appendFileBrowserScope( + `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, + scope + ); if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) { // Return metadata for media/binary files (no text body) @@ -1017,7 +1145,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // Serve raw file content (for images/binary files) app.get('/api/sessions/:id/file-raw', async (req, reply) => { const { id } = req.params as { id: string }; - const { path: filePath, download } = req.query as { path?: string; download?: string }; + const { + path: filePath, + download, + scope, + } = req.query as { + path?: string; + download?: string; + scope?: string; + }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { @@ -1025,8 +1161,17 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even return; } - // Validate path is within working directory (security: resolve symlinks to prevent traversal) - const validated = validateSessionFilePath(session.workingDir, filePath); + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(session.workingDir, scope, req); + } catch (err) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err))); + return; + } + + // Validate path is within the server-resolved workspace/worktree root + // (security: resolve symlinks to prevent traversal). + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; @@ -1253,15 +1398,23 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw. app.get('/api/sessions/:id/file-preview', async (req, reply) => { const { id } = req.params as { id: string }; - const { path: filePath } = req.query as { path?: string }; - const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); - if (!workingDir) return; + const { path: filePath, scope } = req.query as { path?: string; scope?: string }; + const sessionWorkingDir = getKnownSessionWorkingDir(ctx, id, reply, req); + if (!sessionWorkingDir) return; if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(sessionWorkingDir, scope, req); + } catch (err) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err))); + return; + } + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); @@ -1271,7 +1424,9 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even const ext = filePath.split('.').pop()?.toLowerCase() || ''; if (ext !== 'docx' && ext !== 'pptx') { - reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`); + reply.redirect( + appendFileBrowserScope(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, scope) + ); return; } @@ -1281,15 +1436,23 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even // Serve a first-page thumbnail for a workspace-relative path. app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => { const { id } = req.params as { id: string }; - const { path: filePath } = req.query as { path?: string }; - const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); - if (!workingDir) return; + const { path: filePath, scope } = req.query as { path?: string; scope?: string }; + const sessionWorkingDir = getKnownSessionWorkingDir(ctx, id, reply, req); + if (!sessionWorkingDir) return; if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } + let workingDir: string; + try { + workingDir = await resolveFileBrowserWorkingDir(sessionWorkingDir, scope, req); + } catch (err) { + reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err))); + return; + } + const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 20636f8a..bae7e7bf 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -11,6 +11,7 @@ import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; import fs from 'node:fs/promises'; import { randomBytes } from 'node:crypto'; +import { Readable } from 'node:stream'; import { ApiErrorCode, createErrorResponse, @@ -25,6 +26,7 @@ import { SseEvent } from '../sse-events.js'; import { CreateSessionSchema, SessionNameSchema, + SessionWorkingDirectorySchema, SessionColorSchema, RunPromptSchema, SessionInputWithLimitSchema, @@ -65,7 +67,7 @@ import { updateCaseModel, stripCaseEnvKeys, applyStatusLineConfig, - refreshStaleHookSecret, + refreshStaleCodemanHooks, } from '../../hooks-config.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { imageWatcher } from '../../image-watcher.js'; @@ -142,6 +144,23 @@ const ERASE_SCROLLBACK_PATTERN = /\x1b\[3J/g; // strip existed can still carry them; strip on replay for parity. // eslint-disable-next-line no-control-regex const MOUSE_TRACKING_PATTERN = /\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g; +// Historical synchronized-output markers can span network chunks. Strip them +// before streaming so incremental replay cannot inherit a half-open sync block. +// eslint-disable-next-line no-control-regex +const DEC_SYNC_OUTPUT_PATTERN = /\x1b\[\?2026[hl]/g; +const TERMINAL_STREAM_CHUNK_BYTES = 32 * 1024; +const TERMINAL_HISTORY_PAGE_FALLBACK_BYTES = 1024 * 1024; + +function createTerminalSnapshotStream(data: string): Readable { + const bytes = Buffer.from(data, 'utf8'); + return Readable.from( + (function* () { + for (let offset = 0; offset < bytes.length; offset += TERMINAL_STREAM_CHUNK_BYTES) { + yield bytes.subarray(offset, offset + TERMINAL_STREAM_CHUNK_BYTES); + } + })() + ); +} /** * Strip redundant Ink spinner/status-bar redraw frames from the terminal buffer. @@ -445,7 +464,7 @@ export function registerSessionRoutes( // unconditional hook-secret gate keeps accepting its hook events. No-op for fresh // cases (writeHooksConfig already wrote the secret) and for non-Codeman/absent hooks. if ((body.mode ?? 'claude') === 'claude') { - await refreshStaleHookSecret(workingDir).catch(() => {}); + await refreshStaleCodemanHooks(workingDir).catch(() => {}); } // Check OpenCode availability if requested @@ -583,6 +602,39 @@ export function registerSessionRoutes( return { name: session.name }; }); + // ========== Set Session Working Directory ========== + + app.put('/api/sessions/:id/working-directory', async (req) => { + const { id } = req.params as { id: string }; + const body = parseBody(SessionWorkingDirectorySchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id, req); + const sessionState = session.toState(); + + if (sessionState.remote || sessionState.docker) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Working directory reassignment is available only for local sessions' + ); + } + if (!isWorkingDirAllowed(getAuthUser(req), body.workingDir)) { + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace'); + } + try { + if (!statSync(body.workingDir).isDirectory()) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); + } + } catch { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); + } + + session.setWorkingDir(body.workingDir); + if (session.imageWatcherEnabled) { + imageWatcher.watchSession(session.id, body.workingDir); + } + persistAndBroadcastSession(ctx, session); + return { workingDir: session.workingDir }; + }); + // ========== Set Session Color ========== app.put('/api/sessions/:id/color', async (req) => { @@ -931,10 +983,14 @@ export function registerSessionRoutes( app.post('/api/sessions/:id/resize', async (req) => { const { id } = req.params as { id: string }; - const { cols, rows, viewportType, force } = parseBody(ResizeSchema, req.body); + const { cols, rows, viewportType, force, takeControl } = parseBody(ResizeSchema, req.body); const session = findSessionOrFail(ctx, id, req); - session.resize(cols, rows, { viewportType, force }); + session.resize(cols, rows, { + viewportType, + force, + ...(takeControl === true ? { takeControl: true } : {}), + }); return {}; }); @@ -1568,23 +1624,100 @@ export function registerSessionRoutes( // ========== Get Terminal Buffer ========== // Query params: - // tail= - Only return last N bytes (faster initial load) - // full=1 - Full page reload: replay the entire tmux scrollback (COD-47) - app.get('/api/sessions/:id/terminal', async (req) => { + // tail= - Only return last N bytes (faster initial load) + // full=1 - Legacy full reload: replay the entire tmux scrollback (COD-47) + // latest=1 - Current rendered pane only, for a stable first frame + // historyPage=1 - One bounded physical-row page from retained tmux history + // before= - Page backward from this absolute retained-history row + // after= - Page forward from this absolute retained-history row + // lines= - Requested history-page size (server-clamped) + // format=stream - Raw streamed terminal body with metadata in response headers + app.get('/api/sessions/:id/terminal', async (req, reply) => { const { id } = req.params as { id: string }; - const query = req.query as { tail?: string; full?: string }; + const query = req.query as { + tail?: string; + full?: string; + latest?: string; + historyPage?: string; + before?: string; + after?: string; + lines?: string; + format?: string; + }; const session = findSessionOrFail(ctx, id, req); - // `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded - // the page and wants the whole scroll history back, so we capture the ENTIRE - // tmux scrollback and the user gets back history that scrolled off Codeman's - // byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy - // no-param callers (response-viewer fallback, clearTerminal refresh) — keep - // the fast visible-frame capture. - const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; + // `full=1` remains an explicit compatibility path for callers that require + // the entire tmux scrollback. The browser uses `historyPage=1` for ordinary + // cold loads, while tail/no-param callers retain the bounded legacy path. + let tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const isFullReload = query.full === '1' || query.full === 'true'; + const isLatestOnly = query.latest === '1' || query.latest === 'true'; + const isHistoryPage = query.historyPage === '1' || query.historyPage === 'true'; + const captureFullHistory = isFullReload && !isLatestOnly; const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig(); + if (isHistoryPage && session.muxName && typeof ctx.mux.captureActivePaneHistoryPage === 'function') { + const parsedLines = Number.parseInt(query.lines || '', 10); + const limit = Number.isSafeInteger(parsedLines) ? Math.max(100, Math.min(2000, parsedLines)) : 1000; + const parsedBefore = query.before === undefined ? undefined : Number.parseInt(query.before, 10); + const parsedAfter = query.after === undefined ? undefined : Number.parseInt(query.after, 10); + const before = Number.isSafeInteger(parsedBefore) ? parsedBefore : undefined; + const after = before === undefined && Number.isSafeInteger(parsedAfter) ? parsedAfter : undefined; + const page = ctx.mux.captureActivePaneHistoryPage(session.muxName, { + limit, + before, + after, + }); + + if (page) { + const cursor = session.terminalCursor; + if (query.format === 'stream') { + reply + .type('text/plain; charset=utf-8') + .header('X-Codeman-Terminal-Format', 'stream-v1') + .header('Cache-Control', 'no-store') + .header('X-Codeman-Terminal-Stream', cursor.stream) + .header('X-Codeman-Terminal-Generation', String(cursor.generation)) + .header('X-Codeman-Terminal-Start', String(cursor.start)) + .header('X-Codeman-Terminal-End', String(cursor.end)) + .header('X-Codeman-Terminal-Status', session.status) + .header('X-Codeman-Terminal-Full-Size', String(page.buffer.length)) + .header('X-Codeman-Terminal-Truncated', '0') + .header('X-Codeman-Terminal-Source', 'mux-history-page') + .header('X-Codeman-History-Start', String(page.start)) + .header('X-Codeman-History-End', String(page.end)) + .header('X-Codeman-History-Total', String(page.total)) + .header('X-Codeman-History-More-Before', page.hasMoreBefore ? '1' : '0') + .header('X-Codeman-History-More-After', page.hasMoreAfter ? '1' : '0') + .header('X-Codeman-History-Origin', page.origin); + return reply.send(createTerminalSnapshotStream(page.buffer)); + } + + return { + terminalBuffer: page.buffer, + status: session.status, + fullSize: page.buffer.length, + truncated: false, + source: 'mux-history-page', + cursor, + historyPage: { + start: page.start, + end: page.end, + total: page.total, + hasMoreBefore: page.hasMoreBefore, + hasMoreAfter: page.hasMoreAfter, + origin: page.origin, + }, + }; + } + } + // A transient tmux capture failure must remain bounded. Falling through + // with tailBytes=0 would return the entire in-memory PTY buffer and undo + // the bandwidth guarantee of the paging request. + if (isHistoryPage && tailBytes <= 0) { + tailBytes = TERMINAL_HISTORY_PAGE_FALLBACK_BYTES; + } + // Prepend the live tmux pane buffer so tab-switch replay shows the current // on-screen frame, not just the accumulated byte history. This matters for // TUI modes (codex/opencode) that repaint only their latest frame: the @@ -1597,14 +1730,14 @@ export function registerSessionRoutes( muxName && typeof ctx.mux.captureActivePaneBuffer === 'function' ? ctx.mux.captureActivePaneBuffer( muxName, - isFullReload + captureFullHistory ? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes } : undefined ) : null; const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0; const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer - ? isFullReload + ? captureFullHistory ? 'mux-full-history' : 'mux-visible' : 'history'; @@ -1615,11 +1748,13 @@ export function registerSessionRoutes( // history would replay the whole conversation twice: `\x1b[2J` clears only // the viewport, not xterm scrollback. The history+clear+frame concat stays // for the visible-frame path, where the single pane frame lacks history. - rawBuffer = isFullReload + rawBuffer = isLatestOnly ? liveMuxBuffer - : session.terminalBufferLength > 0 - ? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}` - : liveMuxBuffer; + : captureFullHistory + ? liveMuxBuffer + : session.terminalBufferLength > 0 + ? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}` + : liveMuxBuffer; } else { rawBuffer = session.terminalBuffer; } @@ -1688,12 +1823,30 @@ export function registerSessionRoutes( // Remove Ctrl+L and leading whitespace (cheap on tailed subset) cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, ''); + const cursor = session.terminalCursor; + if (query.format === 'stream') { + reply + .type('text/plain; charset=utf-8') + .header('X-Codeman-Terminal-Format', 'stream-v1') + .header('Cache-Control', 'no-store') + .header('X-Codeman-Terminal-Stream', cursor.stream) + .header('X-Codeman-Terminal-Generation', String(cursor.generation)) + .header('X-Codeman-Terminal-Start', String(cursor.start)) + .header('X-Codeman-Terminal-End', String(cursor.end)) + .header('X-Codeman-Terminal-Status', session.status) + .header('X-Codeman-Terminal-Full-Size', String(fullSize)) + .header('X-Codeman-Terminal-Truncated', truncated ? '1' : '0') + .header('X-Codeman-Terminal-Source', source); + return reply.send(createTerminalSnapshotStream(cleanBuffer.replace(DEC_SYNC_OUTPUT_PATTERN, ''))); + } + return { terminalBuffer: cleanBuffer, status: session.status, fullSize, truncated, source, + cursor, }; }); @@ -2170,7 +2323,7 @@ export function registerSessionRoutes( // now-unconditional hook-secret gate keeps accepting its hook events. No-op when // the hooks aren't ours or already carry the secret. Skipped for remote cases — // resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem. - await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); + await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); } // Docker cases: the workspace is a REAL host dir bind-mounted into the container. @@ -2186,7 +2339,7 @@ export function registerSessionRoutes( if (!existsSync(join(resolvedCasePath, '.claude', 'settings.local.json'))) { await writeHooksConfig(resolvedCasePath); } else { - await refreshStaleHookSecret(resolvedCasePath).catch(() => {}); + await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {}); } } catch { /* non-fatal — the session still runs, hooks may be degraded */ diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 021791fe..f4388f22 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -48,7 +48,7 @@ import { } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js'; +import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, InstanceControlPort } from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; @@ -128,7 +128,7 @@ export function resolveSpanUrl(hostHeader: string | undefined, fallbackPort = '3 export function registerSystemRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & InstanceControlPort ): void { const windowStatesPath = dataPath('subagent-window-states.json'); const parentMapPath = dataPath('subagent-parents.json'); @@ -141,6 +141,21 @@ export function registerSystemRoutes( app.get('/api/status', async (req) => ctx.getLightState(req.authUser)); + app.post('/api/system/shutdown', async (req, reply) => { + if (!isMultiUserMode() && !process.env.CODEMAN_PASSWORD) { + return reply + .code(403) + .send(createErrorResponse(ApiErrorCode.FORBIDDEN, 'Remote shutdown requires CODEMAN_PASSWORD authentication')); + } + if (!requireAdmin(req, reply)) return; + + const result = await ctx.requestInstanceShutdown(); + if (!result.accepted) { + return reply.code(409).send(createErrorResponse(ApiErrorCode.CONFLICT, result.reason)); + } + return reply.code(202).send(result); + }); + // ========== Tunnel ========== app.get('/api/tunnel/status', async () => ctx.tunnelManager.getStatus()); diff --git a/src/web/routes/ws-routes.ts b/src/web/routes/ws-routes.ts index 1b59d6eb..bc53e140 100644 --- a/src/web/routes/ws-routes.ts +++ b/src/web/routes/ws-routes.ts @@ -11,26 +11,33 @@ * paths remain fully functional. The frontend opts into WS when available and * falls back transparently. * - * Terminal output is micro-batched at 8ms to group Ink's rapid cursor-up redraws - * into single frames, preventing flicker from split ANSI sequences. This matches - * the SSE path's server-side batching (16-50ms) but at a shorter interval since - * WS has no Traefik buffering overhead. + * Terminal output is micro-batched to group Ink's rapid cursor-up redraws into + * single frames, preventing flicker from split ANSI sequences. Desktop keeps an + * 8ms/8KB low-latency budget. Mobile uses 50ms/16KB because synchronized xterm + * paints are substantially more expensive on phone CPUs; grouping before the + * DEC-2026 wrapper preserves every PTY byte while removing redundant paints. * * Protocol (all JSON text frames): * Server -> Client: - * {"t":"o","d":"..."} — terminal output + * {"t":"o","d":"...","cursor":{...}} — terminal output with an optional stream cursor * {"t":"c"} — clear terminal * {"t":"r"} — needs refresh (reload buffer) * {"t":"ia","seq":N} — input ACK (echoes the seq of an applied/deduped input frame) + * {"t":"ha"} — terminal-stream handoff ACK * Client -> Server: * {"t":"i","d":"...","seq":N,"cid":"..."} — input (keystroke or paste). seq+cid are * optional reliable-delivery tags: the server applies each * (cid,seq) at-most-once and ACKs with {"t":"ia","seq":N}. - * {"t":"z","c":N,"r":N,"f":bool} — resize terminal (f=true forces SIGWINCH even if dims unchanged) + * Input also reasserts the connection's last announced viewport. + * {"t":"z","c":N,"r":N,"v":"mobile|tablet|desktop","f":bool,"a":bool} + * — resize terminal. f forces SIGWINCH; a explicitly takes + * shared PTY sizing control for this viewport. + * {"t":"h"} — flush WS output and atomically hand terminal delivery to SSE */ import { FastifyInstance } from 'fastify'; import type { WebSocket } from 'ws'; +import type { TerminalCursor } from '../../session.js'; import type { SessionPort } from '../ports/session-port.js'; import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js'; import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js'; @@ -41,8 +48,17 @@ import { canAccessOwned, getAuthUser } from '../route-helpers.js'; * long enough to group Ink's rapid cursor-up redraw sequences into single frames. */ const WS_BATCH_INTERVAL_MS = 8; -/** Flush immediately when batch exceeds this size (bytes) for responsiveness. */ -const WS_BATCH_FLUSH_THRESHOLD = 16384; +/** Keep active-view redraw frames small enough for one smooth mobile paint. */ +const WS_BATCH_FLUSH_THRESHOLD = 8 * 1024; + +/** Phone-specific batching budget: reliably groups two ~30Hz source animation ticks. */ +const WS_MOBILE_BATCH_INTERVAL_MS = 50; + +/** Match the browser's mobile xterm parse budget while retaining progressive bulk output. */ +const WS_MOBILE_BATCH_FLUSH_THRESHOLD = 16 * 1024; + +/** Bound one phone render transaction without defeating the 50ms redraw grouping window. */ +const WS_MOBILE_BATCH_HARD_LIMIT = 256 * 1024; /** How often to ping each WebSocket client (ms). Detects stale connections that * TCP keepalive won't catch for minutes, especially through tunnels/proxies. */ @@ -61,6 +77,143 @@ const DEC_2026_END = '\x1b[?2026l'; /** Max concurrent WS connections per session. Prevents listener/bandwidth multiplication. */ const MAX_WS_PER_SESSION = 5; +function splitTerminalPayload( + data: string, + maxBytes: number, + flushSafeTail: boolean +): { frames: string[]; remainder: string } { + if (!data) return { frames: [], remainder: '' }; + const chunks: string[] = []; + let parserState: 'ground' | 'escape' | 'csi' | 'osc' | 'osc-escape' | 'control-string' | 'control-string-escape' = + 'ground'; + let controlSequence = ''; + let synchronizedUpdateOpen = false; + let start = 0; + let offset = 0; + let chunkBytes = 0; + let lastSafeOffset = 0; + while (offset < data.length) { + const codePoint = data.codePointAt(offset) ?? 0; + const codeUnits = codePoint > 0xffff ? 2 : 1; + const codePointBytes = codePoint <= 0x7f ? 1 : codePoint <= 0x7ff ? 2 : codePoint <= 0xffff ? 3 : 4; + + // Split before the next ground-state code point when it would exceed the + // target. Inside ANSI strings/sequences, correctness wins: wait until the + // parser returns to ground rather than injecting a sync marker mid-command. + if ( + parserState === 'ground' && + !synchronizedUpdateOpen && + chunkBytes > 0 && + chunkBytes + codePointBytes > maxBytes + ) { + chunks.push(data.slice(start, offset)); + start = offset; + chunkBytes = 0; + lastSafeOffset = start; + } + + chunkBytes += codePointBytes; + offset += codeUnits; + + switch (parserState) { + case 'ground': + if (codePoint === 0x1b) { + parserState = 'escape'; + controlSequence = '\x1b'; + } else if (codePoint === 0x9b) { + parserState = 'csi'; + controlSequence = '\x9b'; + } else if (codePoint === 0x9d) { + parserState = 'osc'; + } else if (codePoint === 0x90 || codePoint === 0x98 || codePoint === 0x9e || codePoint === 0x9f) { + parserState = 'control-string'; + } + break; + case 'escape': + controlSequence += String.fromCodePoint(codePoint); + if (codePoint === 0x5b) { + parserState = 'csi'; + } else if (codePoint === 0x5d) { + parserState = 'osc'; + controlSequence = ''; + } else if (codePoint === 0x50 || codePoint === 0x58 || codePoint === 0x5e || codePoint === 0x5f) { + parserState = 'control-string'; + controlSequence = ''; + } else if (codePoint < 0x20 || codePoint > 0x2f) { + parserState = 'ground'; + controlSequence = ''; + } + break; + case 'csi': + if (codePoint === 0x1b) { + parserState = 'escape'; + controlSequence = '\x1b'; + } else if (codePoint === 0x18 || codePoint === 0x1a) { + parserState = 'ground'; + controlSequence = ''; + } else { + controlSequence += String.fromCodePoint(codePoint); + if (codePoint >= 0x40 && codePoint <= 0x7e) { + if (controlSequence === '\x1b[?2026h' || controlSequence === '\x9b?2026h') { + synchronizedUpdateOpen = true; + } else if (controlSequence === '\x1b[?2026l' || controlSequence === '\x9b?2026l') { + synchronizedUpdateOpen = false; + } + parserState = 'ground'; + controlSequence = ''; + } + } + break; + case 'osc': + if (codePoint === 0x07 || codePoint === 0x9c) { + parserState = 'ground'; + } else if (codePoint === 0x1b) { + parserState = 'osc-escape'; + } + break; + case 'osc-escape': + if (codePoint === 0x5c || codePoint === 0x9c || codePoint === 0x07) { + parserState = 'ground'; + } else { + parserState = codePoint === 0x1b ? 'osc-escape' : 'osc'; + } + break; + case 'control-string': + if (codePoint === 0x9c) { + parserState = 'ground'; + } else if (codePoint === 0x1b) { + parserState = 'control-string-escape'; + } + break; + case 'control-string-escape': + if (codePoint === 0x5c || codePoint === 0x9c) { + parserState = 'ground'; + } else { + parserState = codePoint === 0x1b ? 'control-string-escape' : 'control-string'; + } + break; + } + + if (parserState === 'ground' && !synchronizedUpdateOpen) { + lastSafeOffset = offset; + if (chunkBytes >= maxBytes) { + chunks.push(data.slice(start, offset)); + start = offset; + chunkBytes = 0; + lastSafeOffset = start; + } + } + } + if (flushSafeTail && lastSafeOffset > start) { + chunks.push(data.slice(start, lastSafeOffset)); + start = lastSafeOffset; + } + return { + frames: chunks, + remainder: data.slice(start), + }; +} + /** * Track live WS connections per session, keyed by clientId (COD-137). * Replaces a bare counter that over-counted across the async-close gap on @@ -70,7 +223,22 @@ const MAX_WS_PER_SESSION = 5; */ const sessionWsRegistry = new WsConnectionRegistry(MAX_WS_PER_SESSION); -export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHostPolicy: () => HostPolicy): void { +export interface TerminalStreamCoordinator { + suspendSseTerminal(clientId: string, sessionId: string): void; + resumeSseTerminal(clientId: string, sessionId: string, recover: boolean): void; +} + +const NOOP_STREAM_COORDINATOR: TerminalStreamCoordinator = { + suspendSseTerminal: () => {}, + resumeSseTerminal: () => {}, +}; + +export function registerWsRoutes( + app: FastifyInstance, + ctx: SessionPort, + getHostPolicy: () => HostPolicy, + streamCoordinator: TerminalStreamCoordinator = NOOP_STREAM_COORDINATOR +): void { app.get<{ Params: { id: string }; Querystring: { cid?: string } }>( '/ws/sessions/:id/terminal', { websocket: true }, @@ -134,29 +302,91 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost } console.info('[ws] terminal open', { sessionId: id, wsCount: sessionWsRegistry.liveCount(id) }); + if (cid) { + streamCoordinator.suspendSseTerminal(cid, id); + } + + let transportReleased = false; + let detachTerminalTransport = () => {}; + const releaseTerminalTransport = (recover: boolean) => { + if (transportReleased || !cid) return; + transportReleased = true; + if (sessionWsRegistry.hasSocket(id, socket)) { + streamCoordinator.resumeSseTerminal(cid, id, recover); + } + }; + // Eagerly free the slot on error/terminate — don't wait for the async // 'close' (idempotent with the 'close' handler below). This is what kills // the reconnect over-count: the slot is released the instant the socket dies. socket.on('error', () => { + releaseTerminalTransport(true); sessionWsRegistry.unregister(id, socket); }); // Per-connection micro-batch state let batchChunks: string[] = []; let batchSize = 0; + let batchCursor: TerminalCursor | undefined; let batchTimer: ReturnType | null = null; + const batchIntervalMs = () => + announcedViewport === 'mobile' ? WS_MOBILE_BATCH_INTERVAL_MS : WS_BATCH_INTERVAL_MS; + const batchFlushThreshold = () => + announcedViewport === 'mobile' ? WS_MOBILE_BATCH_FLUSH_THRESHOLD : WS_BATCH_FLUSH_THRESHOLD; - const flushBatch = () => { + const flushBatch = (flushSafeTail = true) => { batchTimer = null; if (batchChunks.length === 0 || socket.readyState !== 1) { batchChunks = []; batchSize = 0; + batchCursor = undefined; return; } const data = batchChunks.join(''); - batchChunks = []; - batchSize = 0; - socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`); + const cursor = batchCursor; + const { frames, remainder } = splitTerminalPayload(data, batchFlushThreshold(), flushSafeTail); + batchChunks = remainder ? [remainder] : []; + batchSize = Buffer.byteLength(remainder, 'utf8'); + let cursorOffset = 0; + for (let index = 0; index < frames.length; index += 1) { + // Transport fragments from one flush are one render transaction. Keep + // the DEC synchronized update open across WS messages so xterm can + // parse bounded chunks without exposing each partial Codex redraw. + const synchronizedFrame = + (index === 0 ? DEC_2026_START : '') + frames[index] + (index === frames.length - 1 ? DEC_2026_END : ''); + const frameCursor = cursor + ? { + ...cursor, + start: cursor.start + cursorOffset, + end: cursor.start + cursorOffset + frames[index].length, + } + : undefined; + socket.send( + JSON.stringify({ + t: 'o', + d: synchronizedFrame, + ...(frameCursor ? { cursor: frameCursor } : {}), + }) + ); + cursorOffset += frames[index].length; + } + batchCursor = + cursor && remainder + ? { + ...cursor, + start: cursor.start + cursorOffset, + } + : undefined; + }; + const flushBatchNow = () => { + if (batchTimer) { + clearTimeout(batchTimer); + batchTimer = null; + } + // A threshold flush is still one logical PTY emission. Send its safe + // tail in the same synchronized transaction; splitTerminalPayload keeps + // only a genuinely incomplete ANSI/control sequence for the next event. + flushBatch(); }; // Per-connection desktop sizing claim — registered on the first @@ -164,7 +394,9 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost // can ignore small-viewport resizes only while a desktop is actually // connected (see Session._desktopSizeClaims). const sizingToken = Symbol('ws-desktop-sizing'); - let holdsDesktopClaim = false; + let announcedViewport: 'mobile' | 'tablet' | 'desktop' | undefined; + let announcedCols = 0; + let announcedRows = 0; // Attach message handler synchronously BEFORE any async work // (@fastify/websocket requirement to avoid dropped messages). @@ -181,14 +413,29 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null; const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true; if (apply) { - // Typed input from a claim-holding desktop keeps the claim "hot" - // and re-asserts the desktop layout after a mobile override. - if (holdsDesktopClaim) session.noteDesktopActivity(); + // The most recently active viewport wins the shared PTY. Reuse + // this socket's announced dimensions so ordinary soft-keyboard + // input can reclaim mobile sizing without refitting the page. + if (announcedViewport && announcedCols > 0 && announcedRows > 0) { + session.resize(announcedCols, announcedRows, { + viewportType: announcedViewport, + takeControl: true, + }); + } session.write(msg.d); } if (seq !== null && socket.readyState === 1) { socket.send(`{"t":"ia","seq":${seq}}`); } + } else if (msg.t === 'h') { + if (batchTimer) { + clearTimeout(batchTimer); + batchTimer = null; + } + flushBatch(); + detachTerminalTransport(); + releaseTerminalTransport(false); + if (socket.readyState === 1) socket.send('{"t":"ha"}'); } else if ( msg.t === 'z' && Number.isInteger(msg.c) && @@ -199,17 +446,22 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost msg.r <= 200 ) { const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined; + announcedViewport = viewportType; + announcedCols = msg.c; + announcedRows = msg.r; if (viewportType === 'desktop') { session.claimDesktopSizing(sizingToken); - holdsDesktopClaim = true; } else if (viewportType) { // The connection's viewport can change (e.g. browser window // narrowed past the tablet breakpoint) — drop a stale claim. session.releaseDesktopSizing(sizingToken); - holdsDesktopClaim = false; } const force = msg.f === true; - session.resize(msg.c, msg.r, { viewportType, force }); + session.resize(msg.c, msg.r, { + viewportType, + force, + ...(msg.a === true ? { takeControl: true } : {}), + }); } } catch { // Ignore malformed messages @@ -217,23 +469,31 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost }); // Terminal output -> micro-batched WS send - const onTerminal = (data: string) => { + const onTerminal = (data: string, cursor?: TerminalCursor) => { if (socket.readyState !== 1) return; + const cursorIsContiguous = + cursor && + batchCursor && + cursor.stream === batchCursor.stream && + cursor.generation === batchCursor.generation && + cursor.start === batchCursor.end; + if ( + batchChunks.length > 0 && + ((cursor && !batchCursor) || (!cursor && batchCursor) || (cursor && batchCursor && !cursorIsContiguous)) + ) { + flushBatchNow(); + } batchChunks.push(data); - batchSize += data.length; - - // Flush immediately for large batches (responsiveness during bulk output) - if (batchSize > WS_BATCH_FLUSH_THRESHOLD) { - if (batchTimer) { - clearTimeout(batchTimer); - } - flushBatch(); - return; + batchSize += Buffer.byteLength(data, 'utf8'); + if (cursor) { + batchCursor = batchCursor ? { ...batchCursor, end: cursor.end } : { ...cursor }; } + const immediateFlushLimit = announcedViewport === 'mobile' ? WS_MOBILE_BATCH_HARD_LIMIT : batchFlushThreshold(); + if (batchSize >= immediateFlushLimit) flushBatchNow(); // Start timer if not already running - if (!batchTimer) { - batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS); + if (batchChunks.length > 0 && !batchTimer) { + batchTimer = setTimeout(flushBatch, batchIntervalMs()); } }; @@ -259,6 +519,11 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost session.on('clearTerminal', onClearTerminal); session.on('needsRefresh', onNeedsRefresh); session.on('exit', onSessionExit); + detachTerminalTransport = () => { + session.off('terminal', onTerminal); + session.off('clearTerminal', onClearTerminal); + session.off('needsRefresh', onNeedsRefresh); + }; // Heartbeat: detect stale connections (especially through tunnels where // TCP RST can take minutes to propagate). @@ -278,6 +543,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost console.warn('[ws] terminal ping timeout — terminating', { sessionId: id }); // Free the slot eagerly — terminate()'s 'close' may lag, and a client // reconnecting after a stale-connection drop must not be over-counted. + releaseTerminalTransport(true); sessionWsRegistry.unregister(id, socket); socket.terminate(); }, WS_PONG_TIMEOUT_MS); @@ -288,12 +554,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost if (pongTimeout) clearTimeout(pongTimeout); if (batchTimer) clearTimeout(batchTimer); batchChunks = []; - session.off('terminal', onTerminal); - session.off('clearTerminal', onClearTerminal); - session.off('needsRefresh', onNeedsRefresh); + batchCursor = undefined; + detachTerminalTransport(); session.off('exit', onSessionExit); session.releaseDesktopSizing(sizingToken); + releaseTerminalTransport(code !== 4009); // Release this socket's slot. Idempotent and identity-matched: if this // socket was already superseded (a same-cid reconnect took its slot) or // eagerly unregistered on terminate/error, this is a no-op and the diff --git a/src/web/schemas.ts b/src/web/schemas.ts index a2034184..6ba6b1d9 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -182,6 +182,7 @@ const CodexConfigSchema = z .regex(/^[a-zA-Z0-9_-]+$/) .optional(), dangerouslyBypassApprovals: z.boolean().optional(), + animations: z.boolean().optional(), renderMode: z .enum(['scrollback', 'hybrid']) .optional() @@ -261,6 +262,7 @@ export const ResizeSchema = z.object({ rows: z.number().int().min(1).max(200), viewportType: z.enum(['mobile', 'tablet', 'desktop']).optional(), force: z.boolean().optional(), + takeControl: z.boolean().optional(), }); /** @@ -756,6 +758,7 @@ export const SettingsUpdateSchema = z allowedTools: z.string().max(2000).optional(), // Codex CLI settings codexDangerouslyBypassApprovals: z.boolean().optional(), + codexAnimationsEnabled: z.boolean().optional(), // Terminal history and retention terminalScrollbackLines: z .number() @@ -873,6 +876,11 @@ export const SessionNameSchema = z.object({ name: z.string().min(0).max(128), }); +/** PUT /api/sessions/:id/working-directory */ +export const SessionWorkingDirectorySchema = z.object({ + workingDir: safePathSchema, +}); + /** PUT /api/sessions/:id/color */ export const SessionColorSchema = z.object({ color: z.string().max(30), diff --git a/src/web/server.ts b/src/web/server.ts index ecbc71d0..0156c6c3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -43,7 +43,7 @@ import { hostname as getHostname } from 'node:os'; import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js'; import { getHookSecret } from '../config/hook-secret.js'; import { EventEmitter } from 'node:events'; -import { Session, isExternalCliMode, type BackgroundTask } from '../session.js'; +import { Session, isExternalCliMode, type BackgroundTask, type TerminalCursor } from '../session.js'; import type { ClaudeMode, SessionAttachmentHistoryItem, SessionState, WorkflowRunInfo } from '../types.js'; import { RespawnController, RespawnConfig } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; @@ -94,6 +94,12 @@ import { } from './respawn-event-wiring.js'; import { reconcileUpdateOnBoot } from './self-update.js'; +import { + detectInstanceShutdownStrategy, + startSupervisorShutdown, + type ResolvedShutdownStrategy, +} from './instance-shutdown.js'; +import type { InstanceShutdownResult } from './ports/index.js'; // Load version from package.json const require = createRequire(import.meta.url); @@ -170,7 +176,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // Bounded, predictable shape for SSE client identifiers: alphanumerics, `_`, `-`. // Length range covers crypto.randomUUID() (36 chars) plus any short stable IDs, // while capping growth of `sseClientsById` and blocking pathological inputs. -const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/; +const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_:-]{8,128}$/; function escapeHtmlText(value: string): string { return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); @@ -303,6 +309,7 @@ export class WebServer extends EventEmitter { teamRemoved: (config: unknown) => void; taskUpdated: (data: unknown) => void; } | null = null; + private instanceShutdownResult: Extract | null = null; constructor( port: number = 3000, https: boolean = false, @@ -644,9 +651,60 @@ export class WebServer extends EventEmitter { return self._orchestratorLoop; }, initOrchestratorLoop: () => this.initOrchestratorLoop(), + // InstanceControlPort + requestInstanceShutdown: this.requestInstanceShutdown.bind(this), }; } + async requestInstanceShutdown(): Promise { + if (this.instanceShutdownResult) { + return { ...this.instanceShutdownResult, alreadyScheduled: true }; + } + + const strategy = detectInstanceShutdownStrategy(); + if (strategy.kind === 'unsupported') { + return { accepted: false, reason: strategy.reason }; + } + + const result: Extract = { + accepted: true, + strategy: strategy.apiName, + alreadyScheduled: false, + }; + this.instanceShutdownResult = result; + + // app.inject()/browser tests exercise the complete request without killing + // their shared test process. + if (!this.testMode) { + const timer = setTimeout(() => this.executeInstanceShutdown(strategy), 250); + timer.unref(); + } + return result; + } + + private executeInstanceShutdown(strategy: Exclude): void { + if (strategy.kind === 'manual') { + void this.stop() + .then(() => process.exit(0)) + .catch((error: unknown) => { + this.instanceShutdownResult = null; + console.error('[Shutdown] Graceful shutdown failed:', getErrorMessage(error)); + }); + return; + } + + try { + const child = startSupervisorShutdown(strategy); + child.once('error', (error) => { + this.instanceShutdownResult = null; + console.error('[Shutdown] Failed to stop supervisor:', getErrorMessage(error)); + }); + } catch (error) { + this.instanceShutdownResult = null; + console.error('[Shutdown] Failed to start supervisor stop:', getErrorMessage(error)); + } + } + /** * Current Host/Origin allowlist policy. Read per request so a tunnel started at * runtime (PUT /api/settings) is reflected without a restart. @@ -793,14 +851,12 @@ export class WebServer extends EventEmitter { // POST /api/events/subscribe without reconnecting. const query = req.query as { sessions?: string; clientId?: string }; let sessionFilter: Set | null = null; - if (query.sessions) { + if (query.sessions !== undefined) { const ids = query.sessions .split(',') .map((s) => s.trim()) .filter(Boolean); - if (ids.length > 0) { - sessionFilter = new Set(ids); - } + sessionFilter = new Set(ids); } const clientId = typeof query.clientId === 'string' && SSE_CLIENT_ID_RE.test(query.clientId) ? query.clientId : undefined; @@ -832,7 +888,7 @@ export class WebServer extends EventEmitter { // Live subscription update — change a connected client's session filter // without forcing an SSE reconnect. Body: { clientId, sessions: string[] | null } - // Empty/null sessions array = remove filter (receive all session:terminal events). + // Empty array = receive no terminal events; null = legacy unfiltered mode. this.app.post('/api/events/subscribe', (req, reply) => { const body = (req.body || {}) as { clientId?: string; sessions?: string[] | null }; if (typeof body.clientId !== 'string' || !SSE_CLIENT_ID_RE.test(body.clientId)) { @@ -939,7 +995,14 @@ export class WebServer extends EventEmitter { this.cronService.init(); registerCronRoutes(this.app, { ...ctx, cron: this.cronService }); - registerWsRoutes(this.app, ctx, () => this.getHostPolicy()); + registerWsRoutes(this.app, ctx, () => this.getHostPolicy(), { + suspendSseTerminal: (clientId, sessionId) => { + this.sse.suspendClientSession(clientId, sessionId); + }, + resumeSseTerminal: (clientId, sessionId, recover) => { + this.sse.resumeClientSession(clientId, sessionId, recover); + }, + }); } /** @@ -1880,6 +1943,9 @@ export class WebServer extends EventEmitter { const result = { version: APP_VERSION, + // Stable for this server process. Clients use it to distinguish a harmless + // SSE reconnect from a deploy/restart that requires fresh frontend assets. + serverStartedAt: this.serverStartTime, sessions: this.getLightSessionsState(), scheduledRuns: Array.from(this.scheduledRuns.values()), respawnStatus, @@ -1965,8 +2031,8 @@ export class WebServer extends EventEmitter { return undefined; } - private batchTerminalData(sessionId: string, data: string): void { - this.sse.batchTerminalData(sessionId, data); + private batchTerminalData(sessionId: string, data: string, cursor?: TerminalCursor): void { + this.sse.batchTerminalData(sessionId, data, cursor); } private batchTaskUpdate(sessionId: string, task: BackgroundTask): void { diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 3f040e7d..7c225fd3 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -22,6 +22,7 @@ import type { RalphTrackerState, RalphTodoItem, ActiveBashTool, + TerminalCursor, } from '../session.js'; import type { RalphStatusBlock, CircuitBreakerStatus } from '../types.js'; import { SseEvent } from './sse-events.js'; @@ -30,7 +31,7 @@ import { fileStreamManager } from '../file-stream-manager.js'; /** Stored listener references for session cleanup (prevents memory leaks) */ export interface SessionListenerRefs { - terminal: (data: string) => void; + terminal: (data: string, cursor?: TerminalCursor) => void; clearTerminal: () => void; needsRefresh: () => void; message: (msg: ClaudeMessage) => void; @@ -65,7 +66,7 @@ export interface SessionListenerRefs { /** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */ interface SessionListenerDeps { broadcast(event: string, data: unknown): void; - batchTerminalData(sessionId: string, data: string): void; + batchTerminalData(sessionId: string, data: string, cursor?: TerminalCursor): void; batchTaskUpdate(sessionId: string, task: BackgroundTask): void; broadcastSessionStateDebounced(sessionId: string): void; sendPushNotifications(event: string, data: Record): void; @@ -91,8 +92,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe // ─── Terminal Output ───────────────────────────────────── /** Batches PTY output → broadcasts `session:terminal` at 16-50ms intervals */ - terminal: (data) => { - deps.batchTerminalData(session.id, data); + terminal: (data, cursor) => { + deps.batchTerminalData(session.id, data, cursor); }, /** Broadcasts `session:clearTerminal` — tells clients to wipe their xterm buffer (after mux attach) */ diff --git a/src/web/sse-stream-manager.ts b/src/web/sse-stream-manager.ts index 32c65f71..9faab384 100644 --- a/src/web/sse-stream-manager.ts +++ b/src/web/sse-stream-manager.ts @@ -16,7 +16,7 @@ */ import type { FastifyReply } from 'fastify'; -import type { BackgroundTask } from '../session.js'; +import type { BackgroundTask, TerminalCursor } from '../session.js'; import type { AuthUser } from '../types.js'; import { CleanupManager, StaleExpirationMap } from '../utils/index.js'; import { SseEvent } from './sse-events.js'; @@ -71,6 +71,10 @@ export class SseStreamManager { private sseClients: Map | null> = new Map(); /** Optional client-supplied IDs → reply, for live filter updates without reconnecting */ private sseClientsById: Map = new Map(); + /** Reverse lookup used by per-client terminal transport suspension. */ + private sseClientIds: Map = new Map(); + /** Sessions temporarily owned by a client's WebSocket transport. */ + private suspendedClientSessions: Map> = new Map(); /** Per-client identity (multi-user); absent for single-user clients → no filtering. */ private sseClientIdentity: Map = new Map(); /** SSE clients connecting from non-localhost (i.e. through tunnel) */ @@ -85,6 +89,7 @@ export class SseStreamManager { // ─── Terminal Batching ────────────────────────────────── private terminalBatches: Map = new Map(); private terminalBatchSizes: Map = new Map(); // Running total avoids O(n) reduce per push + private terminalBatchCursors: Map = new Map(); private terminalBatchTimers: Map = new Map(); // Per-session timers (staggered flushes) // Adaptive batching: track rapid events to extend batch window (per-session) // StaleExpirationMap auto-cleans entries for sessions that stop generating output @@ -148,8 +153,10 @@ export class SseStreamManager { this.remoteSseClients.delete(prev); this.backpressuredClients.delete(prev); this.sseClientIdentity.delete(prev); + this.sseClientIds.delete(prev); } this.sseClientsById.set(clientId, reply); + this.sseClientIds.set(reply, clientId); } } @@ -158,6 +165,7 @@ export class SseStreamManager { this.remoteSseClients.delete(reply); this.backpressuredClients.delete(reply); this.sseClientIdentity.delete(reply); + this.sseClientIds.delete(reply); // Clear any clientId mappings pointing at this reply for (const [id, r] of this.sseClientsById) { if (r === reply) this.sseClientsById.delete(id); @@ -189,11 +197,44 @@ export class SseStreamManager { updateClientFilter(clientId: string, sessions: string[] | null): boolean { const reply = this.sseClientsById.get(clientId); if (!reply || !this.sseClients.has(reply)) return false; - const filter = sessions && sessions.length > 0 ? new Set(sessions) : null; + const filter = sessions === null ? null : new Set(sessions); this.sseClients.set(reply, filter); return true; } + /** + * Transfer one client's session stream from SSE to WebSocket without a gap. + * Any batch created before the WS listener attached is delivered first. + */ + suspendClientSession(clientId: string, sessionId: string): boolean { + this.flushSessionTerminalBatch(sessionId); + let sessions = this.suspendedClientSessions.get(clientId); + if (!sessions) { + sessions = new Set(); + this.suspendedClientSessions.set(clientId, sessions); + } + sessions.add(sessionId); + return this.sseClientsById.has(clientId); + } + + /** + * Transfer a session back to SSE. Flush while it is still suppressed so bytes + * already delivered over WS cannot be replayed from an in-flight SSE batch. + */ + resumeClientSession(clientId: string, sessionId: string, recover: boolean): boolean { + this.flushSessionTerminalBatch(sessionId); + const sessions = this.suspendedClientSessions.get(clientId); + sessions?.delete(sessionId); + if (sessions?.size === 0) this.suspendedClientSessions.delete(clientId); + + const reply = this.sseClientsById.get(clientId); + if (!reply || !this.sseClients.has(reply)) return false; + if (recover) { + this.sendSSE(reply, SseEvent.SessionNeedsRefresh, { id: sessionId }); + } + return true; + } + /** Send a single SSE event to a specific client. */ sendSSE(reply: FastifyReply, event: string, data: unknown): void { try { @@ -285,10 +326,25 @@ export class SseStreamManager { // Batch terminal data for better performance (60fps) // Uses per-session timers with adaptive intervals to prevent thundering herd: // each session flushes independently rather than all sessions flushing in one burst. - batchTerminalData(sessionId: string, data: string): void { + batchTerminalData(sessionId: string, data: string, cursor?: TerminalCursor): void { // Skip if server is stopping if (this._isStopping) return; + const currentSize = this.terminalBatchSizes.get(sessionId) ?? 0; + const currentCursor = this.terminalBatchCursors.get(sessionId); + const cursorIsContiguous = + cursor && + currentCursor && + cursor.stream === currentCursor.stream && + cursor.generation === currentCursor.generation && + cursor.start === currentCursor.end; + if ( + currentSize > 0 && + ((cursor && !currentCursor) || (!cursor && currentCursor) || (cursor && currentCursor && !cursorIsContiguous)) + ) { + this.flushSessionTerminalBatch(sessionId); + } + let chunks = this.terminalBatches.get(sessionId); if (!chunks) { chunks = []; @@ -298,6 +354,15 @@ export class SseStreamManager { const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0; const totalLength = prevSize + data.length; this.terminalBatchSizes.set(sessionId, totalLength); + if (cursor) { + const batchCursor = this.terminalBatchCursors.get(sessionId); + this.terminalBatchCursors.set(sessionId, { + stream: cursor.stream, + generation: cursor.generation, + start: batchCursor?.start ?? cursor.start, + end: cursor.end, + }); + } // Adaptive batching: detect rapid events and extend batch window (per-session) const now = Date.now(); @@ -346,6 +411,7 @@ export class SseStreamManager { if (this._isStopping) { this.terminalBatches.delete(sessionId); this.terminalBatchSizes.delete(sessionId); + this.terminalBatchCursors.delete(sessionId); return; } const chunks = this.terminalBatches.get(sessionId); @@ -362,10 +428,13 @@ export class SseStreamManager { // Fast path: build SSE message directly without JSON.stringify on wrapper object. // Only the terminal data string needs escaping; sessionId is a UUID (safe to template). const escapedData = JSON.stringify(syncData); + const cursor = this.terminalBatchCursors.get(sessionId); + const cursorField = cursor ? `,"cursor":${JSON.stringify(cursor)}` : ''; // Append tunnel padding for immediate Cloudflare proxy flush — // terminal data is high-frequency and latency-sensitive. const padding = this._isTunnelActive ? SSE_PADDING : ''; - const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n` + padding; + const message = + `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}${cursorField}}\n\n` + padding; // Raw terminal bytes are the highest-value payload: resolve the session owner // ONCE and withhold the batch from any non-admin who is not the owner (fail // closed if the owner is unknown). No-op for identity-less single-user clients. @@ -374,12 +443,15 @@ export class SseStreamManager { for (const [client, filter] of this.sseClients) { // Skip clients that have a session filter and aren't subscribed to this session if (filter && !filter.has(sessionId)) continue; + const clientId = this.sseClientIds.get(client); + if (clientId && this.suspendedClientSessions.get(clientId)?.has(sessionId)) continue; if (!this.canDeliver(client, termHint)) continue; this.sendSSEPreformatted(client, message); } } this.terminalBatches.delete(sessionId); this.terminalBatchSizes.delete(sessionId); + this.terminalBatchCursors.delete(sessionId); } // ========== Task Update Batching ========== @@ -501,6 +573,11 @@ export class SseStreamManager { this.sseClients.delete(client); this.remoteSseClients.delete(client); this.backpressuredClients.delete(client); + const clientId = this.sseClientIds.get(client); + if (clientId && this.sseClientsById.get(clientId) === client) { + this.sseClientsById.delete(clientId); + } + this.sseClientIds.delete(client); } if (deadClients.length > 0) { @@ -514,6 +591,7 @@ export class SseStreamManager { cleanupSessionBatches(sessionId: string): void { this.terminalBatches.delete(sessionId); this.terminalBatchSizes.delete(sessionId); + this.terminalBatchCursors.delete(sessionId); const batchTimer = this.terminalBatchTimers.get(sessionId); if (batchTimer) { clearTimeout(batchTimer); @@ -545,6 +623,9 @@ export class SseStreamManager { } } this.sseClients.clear(); + this.sseClientsById.clear(); + this.sseClientIds.clear(); + this.suspendedClientSessions.clear(); this.remoteSseClients.clear(); this.backpressuredClients.clear(); @@ -555,6 +636,7 @@ export class SseStreamManager { this.terminalBatchTimers.clear(); this.terminalBatches.clear(); this.terminalBatchSizes.clear(); + this.terminalBatchCursors.clear(); this.taskUpdateBatches.clear(); this.stateUpdatePending.clear(); diff --git a/src/web/ws-connection-registry.ts b/src/web/ws-connection-registry.ts index f73d229c..b59e1866 100644 --- a/src/web/ws-connection-registry.ts +++ b/src/web/ws-connection-registry.ts @@ -103,17 +103,23 @@ export class WsConnectionRegistry e.socket === socket); - if (idx === -1) return; + if (idx === -1) return false; entries.splice(idx, 1); if (entries.length === 0) { this.bySession.delete(sessionId); } else { this.bySession.set(sessionId, entries); } + return true; + } + + /** True only while `socket` owns a live registry slot for the session. */ + hasSocket(sessionId: string, socket: S): boolean { + return this.bySession.get(sessionId)?.some((entry) => entry.socket === socket) ?? false; } /** Number of live entries for a session (0 if none). */ diff --git a/test/codex-snapshot-replay.test.ts b/test/codex-snapshot-replay.test.ts index 4eade8e9..551d46bb 100644 --- a/test/codex-snapshot-replay.test.ts +++ b/test/codex-snapshot-replay.test.ts @@ -29,50 +29,77 @@ describe('xterm snapshot/replay (codex tab-switch)', () => { it('declares the snapshot-restore flag before selectSession uses it', () => { const source = appSource(); const selectStart = source.indexOf('async selectSession(sessionId, options = {})'); + const warmDeclaration = source.indexOf('const canWarmRestore = targetWasWarm && snapshotWasInMemory;', selectStart); const declaration = source.indexOf('let restoredSnapshot = false;', selectStart); - const snapshotBranch = source.indexOf("if (snapshot && !sessionIsBusy && session?.mode !== 'shell')", selectStart); - const rewriteDecision = source.indexOf( - 'restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer', + const snapshotBranch = source.indexOf( + "if (snapshot && (canWarmRestore || !sessionIsBusy) && session?.mode !== 'shell')", selectStart ); + const rewriteDecision = source.indexOf('const needsRewrite', selectStart); expect(selectStart).toBeGreaterThan(-1); + expect(warmDeclaration).toBeGreaterThan(selectStart); + expect(warmDeclaration).toBeLessThan(snapshotBranch); expect(declaration).toBeGreaterThan(selectStart); expect(declaration).toBeLessThan(snapshotBranch); expect(declaration).toBeLessThan(rewriteDecision); }); - it('uses xterm snapshots as first paint but still fetches the canonical terminal frame', () => { + it('fetches after a non-warm snapshot but skips the canonical replay for a valid warm restore', () => { const source = appSource(); const snapshotRestore = source.indexOf('SNAPSHOT_RESTORE:'); - const cacheRestore = source.indexOf('Instant cache restore', snapshotRestore); + const warmDelta = source.indexOf('WARM_DELTA:', snapshotRestore); + const cacheRestore = source.indexOf('Instant byte-cache restore', snapshotRestore); + const warmFetchGate = source.indexOf('if (!usedWarmRestore)', cacheRestore); const fetchStart = source.indexOf("FETCH_START'", snapshotRestore); const needsRewrite = source.indexOf('const needsRewrite', fetchStart); + const rewriteDecision = source.indexOf('if (needsRewrite)', needsRewrite); const snapshotBlock = source.slice(snapshotRestore, cacheRestore); - const postSnapshotRestore = source.slice(snapshotRestore, needsRewrite + 160); + const postSnapshotRestore = source.slice(snapshotRestore, rewriteDecision); expect(snapshotRestore).toBeGreaterThan(-1); + expect(warmDelta).toBeGreaterThan(snapshotRestore); expect(cacheRestore).toBeGreaterThan(snapshotRestore); + expect(warmFetchGate).toBeGreaterThan(cacheRestore); expect(fetchStart).toBeGreaterThan(cacheRestore); + expect(fetchStart).toBeGreaterThan(warmFetchGate); expect(needsRewrite).toBeGreaterThan(fetchStart); - // Snapshot restore must NOT short-circuit the canonical fetch. + expect(rewriteDecision).toBeGreaterThan(needsRewrite); + // Snapshot restoration itself does not end the outer load transaction. expect(snapshotBlock).not.toContain('this._finishBufferLoad();'); expect(postSnapshotRestore).toContain('restoredSnapshot'); - expect(postSnapshotRestore).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer'); + expect(postSnapshotRestore).toContain('paintedLatestFrame'); + expect(postSnapshotRestore).toContain('clearedForBusy'); + expect(postSnapshotRestore).toContain('canonicalBuffer !== cachedBuffer'); + }); + + it('flushes live output queued during a warm restore instead of discarding it', () => { + const source = appSource(); + const warmRestore = source.indexOf("WARM_RESTORE_DONE'"); + const finish = source.indexOf('_finishBufferLoad(bufferLoadOwner', warmRestore); + const finishBlock = source.slice(finish, finish + 180); + + expect(warmRestore).toBeGreaterThan(-1); + expect(finish).toBeGreaterThan(warmRestore); + expect(finishBlock).toContain('bufferWasEmpty || usedWarmRestore'); }); it('forces replay after clearing a busy tab even when the fetched frame matches cache', () => { const source = appSource(); - const cacheRestore = source.indexOf('Instant cache restore'); + const cacheRestore = source.indexOf('Instant byte-cache restore'); const busyClear = source.indexOf('CACHE_SKIP_BUSY', cacheRestore); const needsRewrite = source.indexOf('const needsRewrite', busyClear); - const replayBlock = source.slice(cacheRestore, needsRewrite + 160); + const rewriteDecision = source.indexOf('if (needsRewrite)', needsRewrite); + const replayBlock = source.slice(cacheRestore, rewriteDecision); expect(cacheRestore).toBeGreaterThan(-1); expect(busyClear).toBeGreaterThan(cacheRestore); expect(needsRewrite).toBeGreaterThan(busyClear); + expect(rewriteDecision).toBeGreaterThan(needsRewrite); expect(replayBlock).toContain('clearedForBusy'); - expect(replayBlock).toContain('restoredSnapshot || clearedForBusy || data.terminalBuffer !== cachedBuffer'); + expect(replayBlock).toContain('paintedLatestFrame'); + expect(replayBlock).toContain('restoredSnapshot'); + expect(replayBlock).toContain('canonicalBuffer !== cachedBuffer'); }); it('loads the SerializeAddon and keeps a per-session snapshot map', () => { @@ -82,6 +109,15 @@ describe('xterm snapshot/replay (codex tab-switch)', () => { expect(terminalSource).toContain('this.terminal.loadAddon(this._serializeAddon)'); }); + it('keeps a stable bounded history range across repeated session switches', () => { + const source = appSource(); + const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); + + expect(constants).toContain('const TERMINAL_SNAPSHOT_SCROLLBACK = 10_000;'); + expect(source).toContain('this._serializeAddon.serialize({ scrollback: TERMINAL_SNAPSHOT_SCROLLBACK })'); + expect(source).not.toContain('this._serializeAddon.serialize({ scrollback: 1000 })'); + }); + it('evicts the in-memory snapshot cache and persists with a bounded localStorage budget', () => { const source = appSource(); // In-memory cache is LRU-bounded… diff --git a/test/codex-terminal-output.test.ts b/test/codex-terminal-output.test.ts index 1e35ec1a..9a757d92 100644 --- a/test/codex-terminal-output.test.ts +++ b/test/codex-terminal-output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Session } from '../src/session.js'; +import { Session, type TerminalCursor } from '../src/session.js'; type SessionInternals = { _handleTerminalOutput(data: string): void; @@ -11,6 +11,22 @@ function handleOutput(session: Session, data: string): void { } describe('Codex terminal output filtering', () => { + it('emits contiguous terminal cursor ranges and advances the generation after a clear', () => { + const session = new Session({ workingDir: '/tmp', mode: 'codex' }); + const cursors: TerminalCursor[] = []; + session.on('terminal', (_data, cursor: TerminalCursor) => cursors.push(cursor)); + + handleOutput(session, 'abc'); + handleOutput(session, 'de'); + session.clearBuffers(); + handleOutput(session, 'next'); + + expect(cursors[0]).toMatchObject({ generation: 0, start: 0, end: 3 }); + expect(cursors[1]).toMatchObject({ stream: cursors[0].stream, generation: 0, start: 3, end: 5 }); + expect(cursors[2]).toMatchObject({ stream: cursors[0].stream, generation: 1, start: 0, end: 4 }); + expect(session.terminalCursor).toEqual(cursors[2]); + }); + it('keeps browser scrollback guards but skips Codeman row repair in hybrid render mode', () => { const session = new Session({ workingDir: '/tmp', mode: 'codex', codexConfig: { renderMode: 'hybrid' } }); (session as unknown as SessionInternals)._ptyRows = 63; diff --git a/test/frontend-public-tooling.test.ts b/test/frontend-public-tooling.test.ts index 0be11f45..0303d933 100644 --- a/test/frontend-public-tooling.test.ts +++ b/test/frontend-public-tooling.test.ts @@ -1,6 +1,6 @@ -import { execFileSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { relative, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; const repoRoot = resolve(import.meta.dirname, '..'); @@ -20,12 +20,35 @@ describe('frontend public asset tooling', () => { expect(appJs.includes(0)).toBe(false); }); - it('runs the public asset check script', () => { - expect(() => { - execFileSync('npm', ['run', 'check:public-assets', '--silent'], { - cwd: repoRoot, - stdio: 'pipe', - }); - }).not.toThrow(); + it('exposes self-contained asset discovery and NUL checks', async () => { + const checker = (await import(pathToFileURL(resolve(repoRoot, 'scripts/check-public-assets.mjs')).href)) as { + collectTextAssets: (directory: string) => string[]; + findNullByte: (data: Buffer) => number; + }; + const files = checker + .collectTextAssets(resolve(repoRoot, 'src/web/public')) + .map((file) => relative(repoRoot, file)); + + expect(files).toContain('src/web/public/terminal-input-state.js'); + expect(files).toContain('src/web/public/terminal-input-controller.js'); + expect(checker.findNullByte(Buffer.from('valid source'))).toBe(-1); + expect(checker.findNullByte(Buffer.from([0x61, 0, 0x62]))).toBe(1); + }); + + it('routes interactive producers through the terminal input facade', () => { + const producerPaths = [ + 'src/web/public/image-input.js', + 'src/web/public/keyboard-accessory.js', + 'src/web/public/session-ui.js', + 'src/web/public/voice-input.js', + ]; + + for (const path of producerPaths) { + const source = readFileSync(resolve(repoRoot, path), 'utf8'); + expect(source, `${path} bypasses TerminalInputController via sendInput()`).not.toMatch( + /\b(?:app|this)\.sendInput\(/ + ); + expect(source, `${path} bypasses TerminalInputController transport`).not.toContain('._sendInputAsync('); + } }); }); diff --git a/test/git-repository-browser.test.ts b/test/git-repository-browser.test.ts new file mode 100644 index 00000000..83719ee4 --- /dev/null +++ b/test/git-repository-browser.test.ts @@ -0,0 +1,164 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + discoverGitRepository, + getGitCommitDetails, + getGitDiffDetail, + getGitRepositoryOverview, + resolveRepositoryBrowseRoot, +} from '../src/git-repository-browser.js'; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + }, + }).trim(); +} + +describe('git-repository-browser', () => { + let fixtureRoot: string; + let mainWorktree: string; + let featureWorktree: string; + let nestedWorkingDir: string; + let initialCommit: string; + + beforeEach(() => { + fixtureRoot = mkdtempSync(join(tmpdir(), 'codeman-git-browser-')); + mainWorktree = join(fixtureRoot, 'main-repo'); + featureWorktree = join(fixtureRoot, 'feature-worktree'); + mkdirSync(mainWorktree); + + git(mainWorktree, 'init', '-b', 'main'); + git(mainWorktree, 'config', 'user.name', 'Codeman Test'); + git(mainWorktree, 'config', 'user.email', 'codeman@example.invalid'); + mkdirSync(join(mainWorktree, 'src')); + writeFileSync(join(mainWorktree, 'README.md'), 'initial\n'); + writeFileSync(join(mainWorktree, 'src', 'app.ts'), 'export const value = 1;\n'); + git(mainWorktree, 'add', '.'); + git(mainWorktree, 'commit', '-m', 'initial commit'); + initialCommit = git(mainWorktree, 'rev-parse', 'HEAD'); + + git(mainWorktree, 'worktree', 'add', '-b', 'feature/mobile-view', featureWorktree); + nestedWorkingDir = join(featureWorktree, 'src'); + writeFileSync(join(featureWorktree, 'README.md'), 'initial\nmobile change\n'); + writeFileSync(join(featureWorktree, 'new note.md'), 'untracked line\n'); + }); + + afterEach(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + it('discovers the repository root and sibling worktrees from a nested session path', async () => { + const repository = await discoverGitRepository(nestedWorkingDir); + + expect(repository).not.toBeNull(); + expect(repository?.repositoryRoot).toBe(mainWorktree); + expect(repository?.worktrees).toHaveLength(2); + expect(repository?.worktrees.find((worktree) => worktree.main)?.path).toBe(mainWorktree); + expect(repository?.worktrees.find((worktree) => worktree.current)).toMatchObject({ + path: featureWorktree, + branch: 'feature/mobile-view', + }); + await expect(resolveRepositoryBrowseRoot(nestedWorkingDir, 'current')).resolves.toBe(featureWorktree); + }); + + it('returns compact current changes, commit history, and lazy diff content', async () => { + renameSync(join(featureWorktree, 'src', 'app.ts'), join(featureWorktree, 'src', 'mobile app.ts')); + git(featureWorktree, 'add', '-A', 'src'); + const overview = await getGitRepositoryOverview(nestedWorkingDir, 'current'); + + expect(overview.available).toBe(true); + expect(overview.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'README.md', status: 'modified', code: 'M' }), + expect.objectContaining({ path: 'new note.md', status: 'untracked', code: '?' }), + expect.objectContaining({ + path: 'src/mobile app.ts', + oldPath: 'src/app.ts', + status: 'renamed', + code: 'R', + }), + ]) + ); + expect(overview.commits[0]).toMatchObject({ + hash: initialCommit, + subject: 'initial commit', + author: 'Codeman Test', + }); + + const trackedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'README.md'); + expect(trackedDiff).toMatchObject({ + path: 'README.md', + beforeContent: 'initial\n', + afterContent: 'initial\nmobile change\n', + additions: 1, + deletions: 0, + binary: false, + }); + expect(trackedDiff.patch).toContain('+mobile change'); + + const untrackedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'new note.md'); + expect(untrackedDiff.beforeExists).toBe(false); + expect(untrackedDiff.afterContent).toBe('untracked line\n'); + expect(untrackedDiff.additions).toBe(1); + expect(untrackedDiff.patch).toContain('--- /dev/null'); + + const renamedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'src/mobile app.ts'); + expect(renamedDiff).toMatchObject({ + oldPath: 'src/app.ts', + beforeContent: 'export const value = 1;\n', + afterContent: 'export const value = 1;\n', + }); + }); + + it('loads commit file changes and rejects scopes or paths outside the repository', async () => { + const details = await getGitCommitDetails(nestedWorkingDir, 'current', initialCommit); + expect(details.changes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'README.md', status: 'added' }), + expect.objectContaining({ path: 'src/app.ts', status: 'added' }), + ]) + ); + + const committedDiff = await getGitDiffDetail(nestedWorkingDir, 'current', 'src/app.ts', initialCommit); + expect(committedDiff.afterContent).toBe('export const value = 1;\n'); + expect(committedDiff.beforeExists).toBe(false); + + await expect(resolveRepositoryBrowseRoot(nestedWorkingDir, 'forged-scope')).rejects.toThrow('scope not found'); + await expect(getGitDiffDetail(nestedWorkingDir, 'current', '../outside.txt')).rejects.toThrow( + 'outside the selected worktree' + ); + }); + + it('rejects symlink escapes and bounds binary or oversized previews', async () => { + const outsideSecret = join(fixtureRoot, 'outside-secret.txt'); + writeFileSync(outsideSecret, 'do not expose\n'); + symlinkSync(outsideSecret, join(featureWorktree, 'secret-link.txt')); + writeFileSync(join(featureWorktree, 'binary.dat'), Buffer.from([0x00, 0x01, 0x02])); + writeFileSync(join(featureWorktree, 'large.txt'), 'x'.repeat(1024 * 1024 + 1)); + + await expect(getGitDiffDetail(nestedWorkingDir, 'current', 'secret-link.txt')).rejects.toThrow( + 'resolves outside the selected worktree' + ); + + const binary = await getGitDiffDetail(nestedWorkingDir, 'current', 'binary.dat'); + expect(binary).toMatchObject({ + binary: true, + afterContent: null, + truncated: false, + }); + + const large = await getGitDiffDetail(nestedWorkingDir, 'current', 'large.txt'); + expect(large).toMatchObject({ + afterContent: null, + truncated: true, + }); + }); +}); diff --git a/test/hook-secret-selfheal.test.ts b/test/hook-secret-selfheal.test.ts index f3c54b48..892bf32e 100644 --- a/test/hook-secret-selfheal.test.ts +++ b/test/hook-secret-selfheal.test.ts @@ -1,10 +1,10 @@ /** - * COD-91 — `refreshStaleHookSecret` self-heal. + * COD-91 — `refreshStaleCodemanHooks` self-heal. * * Making the hook-event secret unconditionally required (PR #127) would silently 401 the * hook curls baked into cases created before the secret header existed (COD-54). Those * curls live in `.claude/settings.local.json` and `writeHooksConfig` only runs at case - * CREATION, so existing cases never refresh. `refreshStaleHookSecret` regenerates the + * CREATION, so existing cases never refresh. `refreshStaleCodemanHooks` regenerates the * hooks block on session spawn — but ONLY when the case already holds Codeman's own * pre-secret hook curls, never clobbering a user's customizations. * @@ -15,7 +15,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { refreshStaleHookSecret } from '../src/hooks-config.js'; +import { refreshStaleCodemanHooks } from '../src/hooks-config.js'; const SECRET_HEADER = 'X-Codeman-Hook-Secret'; @@ -41,7 +41,7 @@ function staleCodemanHooks() { }; } -describe('refreshStaleHookSecret', () => { +describe('refreshStaleCodemanHooks', () => { let dir: string; let settingsPath: string; @@ -60,7 +60,7 @@ describe('refreshStaleHookSecret', () => { settingsPath, JSON.stringify({ env: { CLAUDE_CODE_FOO: '1' }, model: 'opus', hooks: staleCodemanHooks() }, null, 2) ); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); const after = JSON.parse(readFileSync(settingsPath, 'utf-8')); expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER); @@ -73,11 +73,11 @@ describe('refreshStaleHookSecret', () => { it('leaves a hooks block that already carries the secret unchanged', async () => { // Seed with a current block by healing a stale one first, then re-heal: second pass must no-op. writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2)); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); const healed = readFileSync(settingsPath, 'utf-8'); expect(healed).toContain(SECRET_HEADER); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite }); @@ -88,19 +88,60 @@ describe('refreshStaleHookSecret', () => { 2 ); writeFileSync(settingsPath, foreign); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(readFileSync(settingsPath, 'utf-8')).toBe(foreign); }); + it('preserves user handlers and events in a mixed stale configuration', async () => { + const hooks = staleCodemanHooks(); + hooks.Stop[0].hooks.push({ + type: 'command', + command: './notify-user.sh', + timeout: 10, + }); + const customPostToolUse = { + matcher: 'Write', + hooks: [{ type: 'command', command: './format.sh' }], + }; + const customEvent = [ + { + hooks: [{ type: 'command', command: './audit.sh' }], + }, + ]; + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + ...hooks, + PostToolUse: [customPostToolUse], + CustomEvent: customEvent, + }, + }, + null, + 2 + ) + ); + + await refreshStaleCodemanHooks(dir); + + const after = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(JSON.stringify(after.hooks)).toContain(SECRET_HEADER); + expect(JSON.stringify(after.hooks)).toContain('CODEMAN_BACKGROUND_REWAKE_V1'); + expect(JSON.stringify(after.hooks.Stop)).toContain('./notify-user.sh'); + expect(after.hooks.PostToolUse).toEqual(expect.arrayContaining([customPostToolUse])); + expect(after.hooks.CustomEvent).toEqual(customEvent); + }); + it('is a no-op when settings.local.json is absent (does not create one)', async () => { - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(existsSync(settingsPath)).toBe(false); }); it('leaves a malformed settings file untouched', async () => { const garbage = '{ not valid json'; writeFileSync(settingsPath, garbage); - await refreshStaleHookSecret(dir); + await refreshStaleCodemanHooks(dir); expect(readFileSync(settingsPath, 'utf-8')).toBe(garbage); }); }); diff --git a/test/hooks-config.test.ts b/test/hooks-config.test.ts index 513d14ad..ebb0b63d 100644 --- a/test/hooks-config.test.ts +++ b/test/hooks-config.test.ts @@ -9,7 +9,13 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { generateHooksConfig, writeHooksConfig } from '../src/hooks-config.js'; +import { spawn } from 'node:child_process'; +import { + generateBackgroundWakeScript, + generateHooksConfig, + refreshStaleCodemanHooks, + writeHooksConfig, +} from '../src/hooks-config.js'; describe('generateHooksConfig', () => { it('should return an object with hooks key', () => { @@ -29,6 +35,30 @@ describe('generateHooksConfig', () => { expect(config.hooks.Stop).toHaveLength(1); }); + it('should configure a self-contained Bash background-task rewake hook', () => { + const config = generateHooksConfig(); + const postToolHooks = config.hooks.PostToolUse as Array<{ + matcher: string; + hooks: Array<{ + type: string; + command: string; + args: string[]; + asyncRewake: boolean; + timeout: number; + }>; + }>; + + expect(postToolHooks).toHaveLength(1); + expect(postToolHooks[0].matcher).toBe('Bash'); + expect(postToolHooks[0].hooks[0]).toMatchObject({ + type: 'command', + command: 'node', + asyncRewake: true, + }); + expect(postToolHooks[0].hooks[0].args).toEqual(['-e', generateBackgroundWakeScript()]); + expect(postToolHooks[0].hooks[0].timeout).toBeGreaterThanOrEqual(3600); + }); + it('should configure idle_prompt matcher', () => { const config = generateHooksConfig(); const notifHooks = config.hooks.Notification as Array<{ matcher?: string }>; @@ -157,7 +187,42 @@ describe('writeHooksConfig', () => { expect(parsed.hooks).toBeDefined(); }); - it('should overwrite existing hooks key', async () => { + it('should upgrade Codeman-owned hooks that predate background rewake', async () => { + const claudeDir = join(testDir, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + mkdirSync(claudeDir, { recursive: true }); + const oldHooks = generateHooksConfig().hooks; + delete oldHooks.PostToolUse; + writeFileSync(settingsPath, JSON.stringify({ hooks: oldHooks }, null, 2)); + + await refreshStaleCodemanHooks(testDir); + + const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(parsed.hooks.PostToolUse).toHaveLength(1); + expect(JSON.stringify(parsed.hooks.PostToolUse)).toContain('CODEMAN_BACKGROUND_REWAKE_V1'); + }); + + it('should not add rewake hooks to a user-owned hook configuration', async () => { + const claudeDir = join(testDir, '.claude'); + const settingsPath = join(claudeDir, 'settings.local.json'); + mkdirSync(claudeDir, { recursive: true }); + const userHooks = { + PostToolUse: [ + { + matcher: 'Write', + hooks: [{ type: 'command', command: './format.sh' }], + }, + ], + }; + writeFileSync(settingsPath, JSON.stringify({ hooks: userHooks }, null, 2)); + + await refreshStaleCodemanHooks(testDir); + + const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')); + expect(parsed.hooks).toEqual(userHooks); + }); + + it('should preserve user hook events while installing Codeman hooks', async () => { const claudeDir = join(testDir, '.claude'); mkdirSync(claudeDir, { recursive: true }); writeFileSync(join(claudeDir, 'settings.local.json'), JSON.stringify({ hooks: { oldHook: [] } }, null, 2)); @@ -165,7 +230,7 @@ describe('writeHooksConfig', () => { await writeHooksConfig(testDir); const parsed = JSON.parse(readFileSync(join(claudeDir, 'settings.local.json'), 'utf-8')); - expect(parsed.hooks.oldHook).toBeUndefined(); + expect(parsed.hooks.oldHook).toEqual([]); expect(parsed.hooks.Notification).toBeDefined(); }); @@ -187,6 +252,82 @@ describe('writeHooksConfig', () => { }); }); +describe('background task rewake helper', () => { + const testDir = join(tmpdir(), 'codeman-background-rewake-test-' + Date.now()); + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + function runHelper(input: Record): Promise<{ code: number | null; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['-e', generateBackgroundWakeScript()], { + stdio: ['pipe', 'ignore', 'pipe'], + }); + let stderr = ''; + const timeout = setTimeout(() => { + child.kill(); + reject(new Error('background rewake helper timed out')); + }, 5000); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', reject); + child.on('close', (code) => { + clearTimeout(timeout); + resolve({ code, stderr }); + }); + child.stdin.end(JSON.stringify(input)); + }); + } + + it('exits without waiting for an ordinary Bash result', async () => { + const result = await runHelper({ + transcript_path: join(testDir, 'transcript.jsonl'), + tool_response: { stdout: 'ordinary command completed' }, + }); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + }); + + it('exits 2 when the matching background command completes', async () => { + const transcriptPath = join(testDir, 'transcript.jsonl'); + writeFileSync(transcriptPath, ''); + + const resultPromise = runHelper({ + transcript_path: transcriptPath, + tool_response: { + stdout: 'Command running in background with ID: bg-test-1. Output is being written to: /tmp/bg-test-1.output.', + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + writeFileSync( + transcriptPath, + JSON.stringify({ + type: 'queue-operation', + operation: 'enqueue', + content: + '\nbg-test-1\ncompleted\n' + + '/tmp/bg-test-1.output\n', + }) + '\n' + ); + + const result = await resultPromise; + expect(result.code).toBe(2); + expect(result.stderr).toContain('bg-test-1'); + expect(result.stderr).toContain('completed'); + expect(result.stderr).toContain('/tmp/bg-test-1.output'); + }); +}); + // ========== Hook Event API Integration Tests ========== // Port 3130 reserved for hooks integration tests diff --git a/test/input-cjk.test.ts b/test/input-cjk.test.ts index 54b250b7..70afe6c2 100644 --- a/test/input-cjk.test.ts +++ b/test/input-cjk.test.ts @@ -66,6 +66,7 @@ const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebK function loadCjkHarness({ ua = ANDROID_UA }: { ua?: string } = {}) { const textarea = makeTextarea(); const sent: string[] = []; + const pasted: string[] = []; const windowObj: Record = {}; const documentObj = { getElementById: (id: string) => (id === 'cjkInput' ? textarea : null), @@ -88,10 +89,13 @@ function loadCjkHarness({ ua = ANDROID_UA }: { ua?: string } = {}) { vm.runInContext(src, context, { filename: 'input-cjk.js' }); const CjkInput = vm.runInContext('CjkInput', context); - CjkInput.init({ send: (text: string) => sent.push(text) }); + CjkInput.init({ + send: (text: string) => sent.push(text), + paste: (text: string) => pasted.push(text), + }); textarea.fire('focus'); - return { CjkInput, textarea, sent, windowObj, documentObj }; + return { CjkInput, textarea, sent, pasted, windowObj, documentObj }; } describe('CJK input module', () => { @@ -184,6 +188,53 @@ describe('CJK input module', () => { expect(textarea.value).toBe(PHANTOM); }); + it('routes multiline clipboard text through the dedicated paste boundary', () => { + const { textarea, sent, pasted } = loadCjkHarness(); + const preventDefault = vi.fn(); + + textarea.fire('paste', { + preventDefault, + clipboardData: { + getData: (type: string) => (type === 'text/plain' ? 'first\n\nsecond' : ''), + }, + }); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(pasted).toEqual(['first\n\nsecond']); + expect(sent).toEqual([]); + expect(textarea.value).toBe(PHANTOM); + }); + + it('routes Android input-only paste events through the dedicated paste boundary', () => { + const { textarea, sent, pasted } = loadCjkHarness(); + textarea.value = PHANTOM + 'first\n\nReferences\nmore after references'; + + textarea.fire('input', { + isComposing: false, + inputType: 'insertFromPaste', + }); + + expect(pasted).toEqual(['first\n\nReferences\nmore after references']); + expect(sent).toEqual([]); + expect(textarea.value).toBe(PHANTOM); + }); + + it('keeps multiline text bracketed when Enter wins the race with the paste event', () => { + const { textarea, sent, pasted } = loadCjkHarness(); + textarea.value = PHANTOM + 'first\n\nReferences\nmore after references'; + + textarea.fire('keydown', { + key: 'Enter', + ctrlKey: false, + altKey: false, + metaKey: false, + }); + + expect(pasted).toEqual(['first\n\nReferences\nmore after references']); + expect(sent).toEqual(['\r']); + expect(textarea.value).toBe(PHANTOM); + }); + it('re-tapping the focused empty field restarts the IME session (wedged-IME recovery)', () => { const { textarea, documentObj } = loadCjkHarness(); documentObj.activeElement = textarea; @@ -265,6 +316,21 @@ describe('CJK input module', () => { expect(textarea.valueWrites).toBe(before); }); + it('round-trips a restored CJK draft without submitting it', () => { + const { CjkInput, textarea, sent, pasted } = loadCjkHarness(); + + CjkInput.restorePendingText('未提交\n草稿'); + + expect(CjkInput.getPendingText()).toBe('未提交\n草稿'); + expect(textarea.value).toBe(PHANTOM + '未提交\n草稿'); + expect(sent).toEqual([]); + expect(pasted).toEqual([]); + + CjkInput.clear(); + expect(CjkInput.getPendingText()).toBe(''); + expect(textarea.value).toBe(PHANTOM); + }); + it('routes terminal.focus() to the CJK field while it is visible (focus router)', () => { // Regression guard for the intermittent "Chinese input goes nowhere" bug: // session-select / SSE-reconnect paths call terminal.focus(), which lands @@ -283,9 +349,9 @@ describe('CJK input module', () => { // redraws), which arrive while e.g. the rename input or search box has // focus — refocusing on those steals focus mid-typing. Guard: focus must // be on xterm's own textarea AND the data must not be a query reply. - const selfHeal = src.slice(src.indexOf('Self-heal'), src.indexOf('CJK regain-focus')); - expect(selfHeal).toContain('document.activeElement === this.terminal.textarea'); - expect(selfHeal).toContain('shouldSuppressTerminalQueryResponse(data)'); + expect(src).toMatch( + /document\.activeElement === this\.terminal\.textarea[\s\S]*?!window\.CodemanTerminalInput\?\.shouldSuppressTerminalQueryResponse\([\s\S]*?data[\s\S]*?\)[\s\S]*?CJK regain-focus \(onData swallowed input\)/ + ); }); it('sends text plus carriage return on Enter', () => { diff --git a/test/instance-shutdown.test.ts b/test/instance-shutdown.test.ts new file mode 100644 index 00000000..7c51564d --- /dev/null +++ b/test/instance-shutdown.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSupervisorShutdownCommand, + parseCodemanSystemdMembership, + parseLaunchdPid, + resolveInstanceShutdownStrategy, +} from '../src/web/instance-shutdown.js'; + +describe('instance shutdown supervisor detection', () => { + it('accepts only a Codeman service from the current systemd cgroup', () => { + expect( + parseCodemanSystemdMembership('0::/user.slice/user-1000.slice/user@1000.service/app.slice/codeman-web.service\n') + ).toEqual({ unit: 'codeman-web.service', scope: 'user' }); + expect(parseCodemanSystemdMembership('0::/system.slice/docker.service\n')).toBeNull(); + }); + + it('resolves a user systemd service without probing a global service', () => { + expect( + resolveInstanceShutdownStrategy({ + platform: 'linux', + pid: 42, + uid: 1000, + cgroupText: '0::/user.slice/user-1000.slice/user@1000.service/app.slice/codeman-beta.service\n', + systemdMainPid: () => 42, + }) + ).toEqual({ + kind: 'systemd', + apiName: 'systemd-user', + scope: 'user', + unit: 'codeman-beta.service', + }); + }); + + it('does not pretend a non-root process can stop a system service', () => { + expect( + resolveInstanceShutdownStrategy({ + platform: 'linux', + pid: 42, + uid: 1000, + cgroupText: '0::/system.slice/codeman-web.service\n', + systemdMainPid: () => 42, + }) + ).toMatchObject({ kind: 'unsupported' }); + }); + + it('treats an unsupervised process as a manual graceful shutdown', () => { + expect( + resolveInstanceShutdownStrategy({ + platform: 'linux', + pid: 42, + uid: 1000, + cgroupText: '0::/user.slice/user-1000.slice/session-2.scope\n', + }) + ).toEqual({ kind: 'manual', apiName: 'manual' }); + }); + + it('does not stop a service inherited by a nested preview process', () => { + expect( + resolveInstanceShutdownStrategy({ + platform: 'linux', + pid: 84, + uid: 1000, + cgroupText: '0::/user.slice/user-1000.slice/user@1000.service/app.slice/codeman-web.service\n', + systemdMainPid: () => 42, + }) + ).toEqual({ kind: 'manual', apiName: 'manual' }); + }); + + it('uses launchd only when its exact job owns the current PID', () => { + const output = (target: string) => (target === 'gui/501/com.codeman.web' ? '{\n pid = 73\n}\n' : null); + + expect( + resolveInstanceShutdownStrategy({ + platform: 'darwin', + pid: 73, + uid: 501, + launchdPrint: output, + }) + ).toEqual({ + kind: 'launchd', + apiName: 'launchd-user', + target: 'gui/501/com.codeman.web', + }); + expect( + resolveInstanceShutdownStrategy({ + platform: 'darwin', + pid: 74, + uid: 501, + launchdPrint: output, + }) + ).toEqual({ kind: 'manual', apiName: 'manual' }); + }); + + it('parses launchd PID output defensively', () => { + expect(parseLaunchdPid('state = running\npid = 123\n')).toBe(123); + expect(parseLaunchdPid('last exit code = 123\n')).toBeNull(); + expect(parseLaunchdPid(null)).toBeNull(); + }); + + it('builds supervisor commands without a shell', () => { + expect( + buildSupervisorShutdownCommand({ + kind: 'systemd', + apiName: 'systemd-user', + scope: 'user', + unit: 'codeman-web.service', + }) + ).toEqual({ + command: 'systemctl', + args: ['--user', '--no-block', 'stop', 'codeman-web.service'], + }); + expect( + buildSupervisorShutdownCommand({ + kind: 'launchd', + apiName: 'launchd-user', + target: 'gui/501/com.codeman.web', + }) + ).toEqual({ + command: 'launchctl', + args: ['bootout', 'gui/501/com.codeman.web'], + }); + }); +}); diff --git a/test/mobile-header-buttons-policy.test.ts b/test/mobile-header-buttons-policy.test.ts index 36497334..9bc9a59f 100644 --- a/test/mobile-header-buttons-policy.test.ts +++ b/test/mobile-header-buttons-policy.test.ts @@ -29,16 +29,20 @@ const PUBLIC = join(HERE, '../src/web/public'); // Matches the device the browser-based test emulates (iPhone 14 Pro = 393px CSS). const PHONE_WIDTH = 393; -// Header buttons intentionally kept VISIBLE in the phone header. Empty today: the -// mobile header is deliberately minimal and essential controls (settings, case) -// live in the toolbar. Add a class here ONLY with a justifying comment. -const MOBILE_VISIBLE_ALLOWLIST = new Set([]); +// Header buttons intentionally kept VISIBLE in the phone header. +const MOBILE_VISIBLE_ALLOWLIST = new Set(); // Buttons we expect to STAY hidden on phones — an explicit lock so a future edit // that removes a hide rule fails loudly (not silently). The attachments button is // NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so // it's excluded from the default-visible enumeration rather than mobile-hidden. -const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer']; +const KNOWN_PHONE_HIDDEN = [ + 'btn-settings', + 'btn-lifecycle-log', + 'btn-session-manager', + 'btn-file-viewer', + 'btn-instance-shutdown', +]; function attrOf(openTag: string, name: string): string { const m = openTag.match(new RegExp(`${name}="([^"]*)"`)); diff --git a/test/mobile-navigation-pad.test.ts b/test/mobile-navigation-pad.test.ts new file mode 100644 index 00000000..00f9fd12 --- /dev/null +++ b/test/mobile-navigation-pad.test.ts @@ -0,0 +1,422 @@ +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const SOURCE = readFileSync(new URL('../src/web/public/keyboard-accessory.js', import.meta.url), 'utf8'); + +type NavigationPad = { + element: HTMLElement | null; + init: (enabled: boolean) => void; + syncVisibility: () => void; + syncJumpVisibility: () => void; +}; + +type AccessoryBar = { + element: HTMLElement | null; +}; + +type MobileControls = { + init: (enabled: boolean) => void; + cleanup: () => void; + configureFeedback: (settings?: Record, defaults?: Record) => void; + feedback: (action: string) => void; + resolveEnabled: ( + settings?: Record, + defaults?: Record, + isTouchDevice?: boolean + ) => boolean; +}; + +function dispatchPointer(win: JSDOM['window'], target: Element, type: string, pointerId: number, clientY = 0): void { + const event = new win.MouseEvent(type, { + bubbles: true, + cancelable: true, + clientY, + detail: 1, + }); + Object.defineProperty(event, 'pointerId', { value: pointerId }); + target.dispatchEvent(event); +} + +function dispatchVolumeKey(win: JSDOM['window'], type: 'keydown' | 'keyup', key: string): KeyboardEvent { + const event = new win.KeyboardEvent(type, { + key, + code: key, + bubbles: true, + cancelable: true, + }); + win.document.body.dispatchEvent(event); + return event; +} + +function loadHarness() { + const dom = new JSDOM('', { + url: 'http://localhost/', + runScripts: 'outside-only', + }); + const win = dom.window as unknown as Window & + typeof globalThis & { + MobileDetection: { + isTouchDevice: () => boolean; + getDeviceType: () => string; + }; + KeyboardHandler: { keyboardVisible: boolean }; + app: { + activeSessionId: string | null; + terminal: { + focus: ReturnType; + scrollToBottom: ReturnType; + }; + sendTerminalKey: ReturnType; + jumpTerminalToLatest: ReturnType; + isTerminalAtBottom: ReturnType; + }; + }; + + win.MobileDetection = { + isTouchDevice: () => true, + getDeviceType: () => 'mobile', + }; + win.KeyboardHandler = { keyboardVisible: false }; + const vibrate = vi.fn(); + Object.defineProperty(win.navigator, 'vibrate', { + configurable: true, + value: vibrate, + }); + win.app = { + activeSessionId: 'session-1', + terminal: { + focus: vi.fn(), + scrollToBottom: vi.fn(), + }, + sendTerminalKey: vi.fn(), + jumpTerminalToLatest: vi.fn(), + isTerminalAtBottom: vi.fn(() => true), + }; + win.fetch = vi.fn(async () => ({ ok: true, status: 200 })) as unknown as typeof fetch; + win.eval(` + ${SOURCE} + window.__testMobileNavigationPad = MobileNavigationPad; + window.__testKeyboardAccessoryBar = KeyboardAccessoryBar; + window.__testMobileTerminalControls = MobileTerminalControls; + `); + + const pad = (win as unknown as { __testMobileNavigationPad: NavigationPad }).__testMobileNavigationPad; + const accessory = (win as unknown as { __testKeyboardAccessoryBar: AccessoryBar }).__testKeyboardAccessoryBar; + const controls = (win as unknown as { __testMobileTerminalControls: MobileControls }).__testMobileTerminalControls; + controls.init(true); + + return { + dom, + win, + pad, + accessory, + controls, + sendKey: win.app.sendTerminalKey, + vibrate, + }; +} + +describe('MobileTerminalControls settings migration', () => { + let harness: ReturnType; + + beforeEach(() => { + harness = loadHarness(); + }); + + afterEach(() => { + harness?.controls.cleanup(); + harness?.dom.window.close(); + }); + + it.each([ + [{ mobileTerminalControlsEnabled: true }, {}, true, true], + [{ mobileTerminalControlsEnabled: false }, {}, true, false], + [{ mobileNavigationPadEnabled: true }, {}, true, true], + [{ mobileNavigationPadEnabled: false }, { mobileTerminalControlsEnabled: true }, true, false], + [{ extendedKeyboardBar: true }, {}, false, true], + [{ extendedKeyboardBar: false }, {}, true, false], + [{ extendedKeyboardBar: false }, {}, false, false], + [{}, { mobileTerminalControlsEnabled: true }, false, true], + ])( + 'resolves canonical and legacy settings without changing old false semantics', + (settings, defaults, touchDevice, expected) => { + expect(harness.controls.resolveEnabled(settings, defaults, touchDevice)).toBe(expected); + } + ); +}); + +describe('MobileNavigationPad', () => { + let harness: ReturnType; + + beforeEach(() => { + harness = loadHarness(); + }); + + afterEach(() => { + harness?.controls.cleanup(); + harness?.dom.window.close(); + }); + + it('shows only for an active phone session while the keyboard is hidden', () => { + const { pad, win } = harness; + expect(pad.element?.classList.contains('visible')).toBe(true); + expect(win.document.body.classList.contains('mobile-nav-visible')).toBe(true); + expect(pad.element?.getAttribute('aria-hidden')).toBe('false'); + + win.KeyboardHandler.keyboardVisible = true; + pad.syncVisibility(); + expect(pad.element?.classList.contains('visible')).toBe(false); + + win.KeyboardHandler.keyboardVisible = false; + win.app.activeSessionId = null; + pad.syncVisibility(); + expect(pad.element?.classList.contains('visible')).toBe(false); + }); + + it('hides behind active dialogs and restores itself when they close', async () => { + const { pad, win } = harness; + const modal = win.document.createElement('div'); + modal.className = 'modal'; + win.document.body.appendChild(modal); + + modal.classList.add('active'); + await Promise.resolve(); + expect(pad.element?.classList.contains('visible')).toBe(false); + expect(pad.element?.getAttribute('aria-hidden')).toBe('true'); + expect(win.document.body.classList.contains('mobile-nav-visible')).toBe(false); + + modal.classList.remove('active'); + await Promise.resolve(); + expect(pad.element?.classList.contains('visible')).toBe(true); + expect(pad.element?.getAttribute('aria-hidden')).toBe('false'); + }); + + it('tracks dynamically inserted and inline-display dialogs', async () => { + const { pad, win } = harness; + const dynamicModal = win.document.createElement('div'); + dynamicModal.className = 'modal active'; + win.document.body.appendChild(dynamicModal); + await Promise.resolve(); + expect(pad.element?.classList.contains('visible')).toBe(false); + + dynamicModal.remove(); + await Promise.resolve(); + expect(pad.element?.classList.contains('visible')).toBe(true); + + const inlineModal = win.document.createElement('div'); + inlineModal.className = 'modal'; + inlineModal.style.display = 'flex'; + win.document.body.appendChild(inlineModal); + await Promise.resolve(); + expect(pad.element?.classList.contains('visible')).toBe(false); + + inlineModal.style.display = 'none'; + await Promise.resolve(); + expect(pad.element?.classList.contains('visible')).toBe(true); + }); + + it('sends one arrow on release without focusing the terminal', () => { + const { pad, sendKey, win } = harness; + const up = pad.element!.querySelector('[data-nav-key="up"]')!; + + dispatchPointer(win, up, 'pointerdown', 1); + expect(sendKey).not.toHaveBeenCalled(); + dispatchPointer(win, up, 'pointerup', 1); + + expect(sendKey).toHaveBeenCalledTimes(1); + expect(sendKey).toHaveBeenCalledWith('\x1b[A'); + expect(win.app.terminal.focus).not.toHaveBeenCalled(); + }); + + it('provides configurable haptic feedback for accepted controls', () => { + const { controls, pad, vibrate, win } = harness; + const up = pad.element!.querySelector('[data-nav-key="up"]')!; + + controls.configureFeedback({ + mobileControlHaptics: true, + mobileControlSound: false, + }); + dispatchPointer(win, up, 'pointerdown', 1); + dispatchPointer(win, up, 'pointerup', 1); + expect(vibrate).toHaveBeenCalledWith(10); + + controls.configureFeedback({ + mobileControlHaptics: false, + mobileControlSound: false, + }); + dispatchPointer(win, up, 'pointerdown', 2); + dispatchPointer(win, up, 'pointerup', 2); + expect(vibrate).toHaveBeenCalledTimes(1); + }); + + it('plays an optional short Web Audio tone', () => { + const { controls, win } = harness; + const start = vi.fn(); + const stop = vi.fn(); + const setFrequency = vi.fn(); + const setGain = vi.fn(); + const rampGain = vi.fn(); + class FakeAudioContext { + currentTime = 1; + state = 'running'; + destination = {}; + createOscillator() { + return { + type: 'sine', + frequency: { setValueAtTime: setFrequency }, + connect: vi.fn(), + start, + stop, + }; + } + createGain() { + return { + gain: { + setValueAtTime: setGain, + exponentialRampToValueAtTime: rampGain, + }, + connect: vi.fn(), + }; + } + } + Object.defineProperty(win, 'AudioContext', { + configurable: true, + value: FakeAudioContext, + }); + + controls.configureFeedback({ + mobileControlHaptics: false, + mobileControlSound: true, + }); + controls.feedback('enter'); + + expect(setFrequency).toHaveBeenCalledWith(760, 1); + expect(setGain).toHaveBeenCalledWith(0.025, 1); + expect(rampGain).toHaveBeenCalledWith(0.0001, 1.035); + expect(start).toHaveBeenCalledWith(1); + expect(stop).toHaveBeenCalledWith(1.04); + }); + + it('turns a simultaneous Up+Down press into Enter without leaking arrows', () => { + const { pad, sendKey, win } = harness; + const up = pad.element!.querySelector('[data-nav-key="up"]')!; + const down = pad.element!.querySelector('[data-nav-key="down"]')!; + + dispatchPointer(win, up, 'pointerdown', 1); + dispatchPointer(win, down, 'pointerdown', 2); + expect(sendKey).toHaveBeenCalledTimes(1); + expect(sendKey).toHaveBeenCalledWith('\r'); + expect(pad.element?.classList.contains('chord-active')).toBe(true); + + dispatchPointer(win, up, 'pointerup', 1); + dispatchPointer(win, down, 'pointerup', 2); + expect(sendKey).toHaveBeenCalledTimes(1); + expect(pad.element?.classList.contains('chord-active')).toBe(false); + }); + + it('uses exposed hardware volume keys without focusing the terminal', () => { + const { sendKey, win } = harness; + + const down = dispatchVolumeKey(win, 'keydown', 'AudioVolumeUp'); + expect(down.defaultPrevented).toBe(true); + expect(sendKey).not.toHaveBeenCalled(); + + const up = dispatchVolumeKey(win, 'keyup', 'AudioVolumeUp'); + expect(up.defaultPrevented).toBe(true); + expect(sendKey).toHaveBeenCalledOnce(); + expect(sendKey).toHaveBeenCalledWith('\x1b[A'); + expect(win.app.terminal.focus).not.toHaveBeenCalled(); + }); + + it('turns simultaneous volume directions into one Enter without leaking arrows', () => { + const { pad, sendKey, win } = harness; + + dispatchVolumeKey(win, 'keydown', 'AudioVolumeUp'); + dispatchVolumeKey(win, 'keydown', 'AudioVolumeDown'); + expect(sendKey).toHaveBeenCalledOnce(); + expect(sendKey).toHaveBeenCalledWith('\r'); + expect(pad.element?.classList.contains('chord-active')).toBe(true); + + dispatchVolumeKey(win, 'keyup', 'AudioVolumeUp'); + dispatchVolumeKey(win, 'keyup', 'AudioVolumeDown'); + expect(sendKey).toHaveBeenCalledOnce(); + expect(pad.element?.classList.contains('chord-active')).toBe(false); + }); + + it('leaves volume keys alone while the mobile controls are unavailable', async () => { + const { pad, sendKey, win } = harness; + const modal = win.document.createElement('div'); + modal.className = 'modal'; + win.document.body.appendChild(modal); + modal.classList.add('active'); + await Promise.resolve(); + expect(pad.element?.classList.contains('visible')).toBe(false); + + const down = dispatchVolumeKey(win, 'keydown', 'AudioVolumeDown'); + const up = dispatchVolumeKey(win, 'keyup', 'AudioVolumeDown'); + + expect(down.defaultPrevented).toBe(false); + expect(up.defaultPrevented).toBe(false); + expect(sendKey).not.toHaveBeenCalled(); + }); + + it('keeps an explicit Enter button available for one-finger and switch input', () => { + const { pad, sendKey } = harness; + const enter = pad.element!.querySelector('[data-nav-key="enter"]') as HTMLButtonElement; + + enter.click(); + + expect(sendKey).toHaveBeenCalledTimes(1); + expect(sendKey).toHaveBeenCalledWith('\r'); + }); + + it('maps vertical swipes on the bar to arrows and ignores short movement', () => { + const { pad, sendKey, win } = harness; + const bar = pad.element!; + + dispatchPointer(win, bar, 'pointerdown', 10, 100); + dispatchPointer(win, bar, 'pointerup', 10, 50); + dispatchPointer(win, bar, 'pointerdown', 11, 50); + dispatchPointer(win, bar, 'pointerup', 11, 100); + dispatchPointer(win, bar, 'pointerdown', 12, 50); + dispatchPointer(win, bar, 'pointerup', 12, 70); + + expect(sendKey).toHaveBeenNthCalledWith(1, '\x1b[A'); + expect(sendKey).toHaveBeenNthCalledWith(2, '\x1b[B'); + expect(sendKey).toHaveBeenCalledTimes(2); + }); + + it('drops canceled pointers without sending or leaving a pressed state', () => { + const { pad, sendKey, win } = harness; + const down = pad.element!.querySelector('[data-nav-key="down"]')!; + + dispatchPointer(win, down, 'pointerdown', 5); + dispatchPointer(win, down, 'pointercancel', 5); + + expect(sendKey).not.toHaveBeenCalled(); + expect(down.classList.contains('pressed')).toBe(false); + }); + + it('shows jump-to-latest only away from the bottom and never focuses xterm', () => { + const { pad, accessory, sendKey, win } = harness; + const jump = pad.element!.querySelector('[data-nav-key="jump-bottom"]') as HTMLButtonElement; + + expect(jump).not.toBeNull(); + expect(jump.hidden).toBe(true); + expect(accessory.element!.querySelector('[data-action="jump-bottom"]')).toBeNull(); + + win.app.isTerminalAtBottom.mockReturnValue(false); + pad.syncJumpVisibility(); + expect(jump.hidden).toBe(false); + jump.click(); + + expect(win.app.jumpTerminalToLatest).toHaveBeenCalledOnce(); + expect(win.app.terminal.focus).not.toHaveBeenCalled(); + expect(sendKey).not.toHaveBeenCalled(); + + win.app.isTerminalAtBottom.mockReturnValue(true); + pad.syncJumpVisibility(); + expect(jump.hidden).toBe(true); + }); +}); diff --git a/test/mobile/file-viewer.test.ts b/test/mobile/file-viewer.test.ts new file mode 100644 index 00000000..65ad512a --- /dev/null +++ b/test/mobile/file-viewer.test.ts @@ -0,0 +1,568 @@ +/** + * Repository-aware File Viewer browser tests. + * + * Port 3211 is reserved in helpers/constants.ts. + */ + +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { BrowserContext, Page } from 'playwright'; +import { createDevicePage, closeAllBrowsers } from './helpers/browser.js'; +import { PORTS } from './helpers/constants.js'; +import { createTestServer, stopTestServer } from './helpers/server.js'; +import { REPRESENTATIVE_DEVICES } from './devices.js'; + +const PORT = PORTS.FILE_VIEWER; +const BASE_URL = `http://localhost:${PORT}`; + +describe('Mobile File Viewer', () => { + let server: Awaited>; + let page: Page; + let context: BrowserContext; + + beforeAll(async () => { + server = await createTestServer(PORT); + }); + + afterAll(async () => { + await stopTestServer(server); + await closeAllBrowsers(); + }); + + beforeEach(async () => { + const result = await createDevicePage(REPRESENTATIVE_DEVICES['standard-phone'], BASE_URL, 'chromium'); + page = result.page; + context = result.context; + }); + + afterEach(async () => { + await page + .evaluate(() => { + const testWindow = window as typeof window & { + __fileViewerOriginalFetch?: typeof window.fetch; + }; + if (testWindow.__fileViewerOriginalFetch) { + window.fetch = testWindow.__fileViewerOriginalFetch; + delete testWindow.__fileViewerOriginalFetch; + } + if (app.fileBrowserAutoRefreshTimer) { + clearInterval(app.fileBrowserAutoRefreshTimer); + app.fileBrowserAutoRefreshTimer = null; + } + app.closeFilePreview(); + }) + .catch(() => {}); + await context.close(); + }); + + it('keeps a rapid tab switch on the new session and defaults to its worktree root', async () => { + const state = await page.evaluate(async () => { + const testWindow = window as typeof window & { + __fileViewerOriginalFetch?: typeof window.fetch; + }; + testWindow.__fileViewerOriginalFetch = window.fetch; + + const repository = (session: string) => ({ + success: true, + data: { + available: true, + repositoryRoot: `/repos/${session}`, + selectedScopeId: `${session}-current`, + worktrees: [ + { + id: `${session}-current`, + path: `/repos/${session}`, + name: session, + branch: 'main', + head: 'a'.repeat(40), + current: true, + main: true, + locked: false, + }, + { + id: `${session}-sibling`, + path: `/worktrees/${session}-feature`, + name: `${session}-feature`, + branch: 'feature/mobile', + head: 'b'.repeat(40), + current: false, + main: false, + locked: false, + }, + ], + changes: [], + commits: [], + }, + }); + const files = (session: string) => ({ + success: true, + data: { + root: `/repos/${session}`, + tree: [ + { + name: `${session}.txt`, + path: `${session}.txt`, + type: 'file', + size: 12, + extension: 'txt', + }, + ], + totalFiles: 1, + totalDirectories: 0, + truncated: false, + }, + }); + + window.fetch = async (input) => { + const url = String(input); + const session = url.includes('session-a') ? 'session-a' : 'session-b'; + await new Promise((resolve) => setTimeout(resolve, session === 'session-a' ? 100 : 5)); + const payload = url.includes('/repository?') ? repository(session) : files(session); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + app.fileBrowserData = null; + app.fileBrowserSessionId = null; + app.fileBrowserView = 'files'; + app.activeSessionId = 'session-a'; + const firstLoad = app.loadFileBrowser('session-a'); + app.activeSessionId = 'session-b'; + const secondLoad = app.loadFileBrowser('session-b'); + await Promise.allSettled([firstLoad, secondLoad]); + + const scope = document.getElementById('fileBrowserScope') as HTMLSelectElement; + return { + sessionId: app.fileBrowserSessionId, + scopeId: app.fileBrowserScopeId, + root: app.fileBrowserData?.root, + treeText: document.getElementById('fileBrowserTree')?.textContent, + scopeOptions: Array.from(scope.options).map((option) => option.textContent), + selectedScope: scope.value, + }; + }); + + expect(state).toMatchObject({ + sessionId: 'session-b', + scopeId: 'session-b-current', + root: '/repos/session-b', + selectedScope: 'session-b-current', + }); + expect(state.treeText).toContain('session-b.txt'); + expect(state.treeText).not.toContain('session-a.txt'); + expect(state.scopeOptions).toHaveLength(2); + expect(state.scopeOptions[0]).toContain('(root)'); + expect(state.scopeOptions[1]).toContain('(worktree)'); + }); + + it('updates repository ownership immediately when switching sessions with the viewer open or closed', async () => { + const state = await page.evaluate(async () => { + const originalFetch = window.fetch; + const originalLoadFileBrowser = app.loadFileBrowser; + const loads: string[] = []; + + window.fetch = async (input, init) => { + const url = String(input); + if (url.includes('/terminal?')) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const sessionId = url.split('/')[3]; + return new Response( + JSON.stringify({ + data: { + terminalBuffer: `${sessionId} ready`, + truncated: false, + }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + return originalFetch(input, init); + }; + app.loadFileBrowser = async (sessionId: string) => { + loads.push(sessionId); + }; + + for (const sessionId of ['file-view-a', 'file-view-b']) { + app.sessions.set(sessionId, { + id: sessionId, + name: sessionId, + mode: 'shell', + status: 'idle', + pid: 1, + workingDir: `/repos/${sessionId}`, + }); + } + app.sessionOrder = ['file-view-a', 'file-view-b']; + app._initialFullBufferLoad = false; + app.activeSessionId = 'file-view-a'; + app.fileBrowserSessionId = 'file-view-a'; + app.fileBrowserScopeId = 'file-view-a-scope'; + + const panel = document.getElementById('fileBrowserPanel')!; + panel.classList.add('visible'); + const settings = app.loadAppSettingsFromStorage(); + settings.showFileBrowser = true; + app.saveAppSettingsToStorage(settings); + + const openSwitch = app.selectSession('file-view-b'); + await new Promise((resolve) => setTimeout(resolve, 20)); + const openState = { + activeSessionId: app.activeSessionId, + fileBrowserSessionId: app.fileBrowserSessionId, + scopeId: app.fileBrowserScopeId, + loads: [...loads], + }; + await openSwitch; + + panel.classList.remove('visible'); + settings.showFileBrowser = false; + app.saveAppSettingsToStorage(settings); + app.fileBrowserSessionId = 'file-view-b'; + app.fileBrowserScopeId = 'file-view-b-scope'; + + const closedSwitch = app.selectSession('file-view-a'); + await new Promise((resolve) => setTimeout(resolve, 20)); + const closedState = { + activeSessionId: app.activeSessionId, + fileBrowserSessionId: app.fileBrowserSessionId, + scopeId: app.fileBrowserScopeId, + loads: [...loads], + }; + await closedSwitch; + + window.fetch = originalFetch; + app.loadFileBrowser = originalLoadFileBrowser; + + return { openState, closedState }; + }); + + expect(state.openState).toMatchObject({ + activeSessionId: 'file-view-b', + fileBrowserSessionId: 'file-view-b', + scopeId: 'current', + loads: ['file-view-b'], + }); + expect(state.closedState).toMatchObject({ + activeSessionId: 'file-view-a', + fileBrowserSessionId: 'file-view-a', + scopeId: 'current', + loads: ['file-view-b'], + }); + }); + + it('reassigns the active session work path and reloads the repository at current scope', async () => { + const state = await page.evaluate(async () => { + const originalFetch = window.fetch; + const requests: Array<{ url: string; method: string; body?: string }> = []; + window.fetch = async (input, init) => { + const url = String(input); + const method = init?.method || 'GET'; + requests.push({ url, method, body: typeof init?.body === 'string' ? init.body : undefined }); + if (url.endsWith('/working-directory')) { + return new Response(JSON.stringify({ success: true, data: { workingDir: '/repos/reassigned' } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + const repository = { + success: true, + data: { + available: true, + repositoryRoot: '/repos/reassigned', + selectedScopeId: 'reassigned-current', + worktrees: [ + { + id: 'reassigned-current', + path: '/repos/reassigned', + name: 'reassigned', + branch: 'main', + head: 'a'.repeat(40), + current: true, + main: true, + locked: false, + }, + ], + changes: [], + commits: [], + }, + }; + const files = { + success: true, + data: { + root: '/repos/reassigned', + tree: [], + totalFiles: 0, + totalDirectories: 0, + truncated: false, + }, + }; + return new Response(JSON.stringify(url.includes('/repository?') ? repository : files), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + app.sessions.set('workdir-session', { + id: 'workdir-session', + name: 'Workspace', + mode: 'claude', + status: 'idle', + pid: 1, + workingDir: '/repos/stale', + }); + app.activeSessionId = 'workdir-session'; + app.fileBrowserSessionId = 'workdir-session'; + document.getElementById('fileBrowserPanel')?.classList.add('visible'); + + app.openFileBrowserWorkingDirectoryEditor(); + const modal = document.getElementById('workingDirectoryModal')!; + const input = document.getElementById('workingDirectoryInput') as HTMLInputElement; + const initialValue = input.value; + input.value = '/repos/reassigned'; + await app.saveFileBrowserWorkingDirectory(new Event('submit')); + + const result = { + initialValue, + modalOpen: modal.classList.contains('active'), + workingDir: app.sessions.get('workdir-session')?.workingDir, + scopeId: app.fileBrowserScopeId, + requests, + }; + window.fetch = originalFetch; + return result; + }); + + expect(state.initialValue).toBe('/repos/stale'); + expect(state.modalOpen).toBe(false); + expect(state.workingDir).toBe('/repos/reassigned'); + expect(state.scopeId).toBe('reassigned-current'); + expect(state.requests[0]).toMatchObject({ + url: '/api/sessions/workdir-session/working-directory', + method: 'PUT', + body: JSON.stringify({ workingDir: '/repos/reassigned' }), + }); + expect(state.requests.some((request) => request.url.includes('/repository?scope=current'))).toBe(true); + }); + + it('renders current changes and switches between compact and full diff on a phone', async () => { + await page.evaluate(() => { + const testWindow = window as typeof window & { + __fileViewerOriginalFetch?: typeof window.fetch; + }; + testWindow.__fileViewerOriginalFetch = window.fetch; + window.fetch = async (input) => { + const url = String(input); + if (!url.includes('/repository/diff?')) { + throw new Error(`Unexpected URL: ${url}`); + } + return new Response( + JSON.stringify({ + success: true, + data: { + path: 'src/app.ts', + commit: null, + label: 'Working tree · src/app.ts', + patch: + 'diff --git a/src/app.ts b/src/app.ts\n' + + '--- a/src/app.ts\n' + + '+++ b/src/app.ts\n' + + '@@ -1,2 +1,3 @@\n' + + ' alpha\n' + + '-old\n' + + '+new\n' + + '+extra\n', + beforeContent: 'alpha\nold\n', + afterContent: 'alpha\nnew\nextra\n', + beforeExists: true, + afterExists: true, + binary: false, + truncated: false, + additions: 2, + deletions: 1, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); + }; + + app.activeSessionId = 'diff-session'; + app.fileBrowserSessionId = 'diff-session'; + app.fileBrowserScopeId = 'scope-diff'; + app.fileBrowserRepositoryData = { + available: true, + repositoryRoot: '/repo', + selectedScopeId: 'scope-diff', + worktrees: [ + { + id: 'scope-diff', + path: '/repo', + name: 'repo', + branch: 'main', + head: 'a'.repeat(40), + current: true, + main: true, + locked: false, + }, + ], + changes: [ + { + path: 'src/app.ts', + code: 'M', + status: 'modified', + staged: false, + unstaged: true, + additions: 2, + deletions: 1, + binary: false, + }, + ], + commits: [], + }; + document.getElementById('fileBrowserPanel')?.classList.add('visible'); + app.switchFileBrowserView('changes'); + }); + + await expect.poll(() => page.locator('.repo-change-row').count()).toBe(1); + await expect.poll(() => page.locator('#fileBrowserChangesCount').textContent()).toBe('1'); + await page.locator('.repo-change-row').click(); + await expect.poll(() => page.locator('.repository-diff').isVisible()).toBe(true); + await expect.poll(() => page.locator('.repository-diff-line.diff-add').count()).toBe(2); + await expect.poll(() => page.locator('.repository-diff-line.diff-del').count()).toBe(1); + + await page.locator('.file-preview-mode-btn[data-mode="full"]').click(); + await expect.poll(() => page.locator('.repository-diff-full').isVisible()).toBe(true); + await expect.poll(() => page.locator('#filePreviewBody').textContent()).toContain('alpha'); + await expect.poll(() => page.locator('#filePreviewBody').textContent()).toContain('old'); + await expect.poll(() => page.locator('#filePreviewBody').textContent()).toContain('new'); + + const bounds = await page.evaluate(() => { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + const panel = document.getElementById('fileBrowserPanel')!.getBoundingClientRect(); + const preview = document.querySelector('.file-preview-window')!.getBoundingClientRect(); + return { + viewport, + panel: { left: panel.left, right: panel.right, top: panel.top, bottom: panel.bottom }, + preview: { + left: preview.left, + right: preview.right, + top: preview.top, + bottom: preview.bottom, + }, + }; + }); + expect(bounds.panel.left).toBeGreaterThanOrEqual(0); + expect(bounds.panel.right).toBeLessThanOrEqual(bounds.viewport.width); + expect(bounds.panel.bottom).toBeLessThanOrEqual(bounds.viewport.height); + expect(bounds.preview.left).toBeGreaterThanOrEqual(0); + expect(bounds.preview.right).toBeLessThanOrEqual(bounds.viewport.width); + expect(bounds.preview.bottom).toBeLessThanOrEqual(bounds.viewport.height); + }); + + it('expands commit history and opens a committed file diff', async () => { + const commit = 'c'.repeat(40); + await page.evaluate((commitHash) => { + const testWindow = window as typeof window & { + __fileViewerOriginalFetch?: typeof window.fetch; + }; + testWindow.__fileViewerOriginalFetch = window.fetch; + window.fetch = async (input) => { + const url = String(input); + const payload = url.includes('/repository/commit?') + ? { + success: true, + data: { + hash: commitHash, + shortHash: commitHash.slice(0, 8), + author: 'Agent', + authoredAt: '2026-07-27T10:00:00Z', + subject: 'Add mobile history', + changes: [ + { + path: 'src/history.ts', + code: 'A', + status: 'added', + staged: true, + unstaged: false, + additions: null, + deletions: null, + binary: false, + }, + ], + }, + } + : { + success: true, + data: { + path: 'src/history.ts', + commit: commitHash, + label: `${commitHash.slice(0, 8)} · src/history.ts`, + patch: + 'diff --git a/src/history.ts b/src/history.ts\n' + + '--- /dev/null\n' + + '+++ b/src/history.ts\n' + + '@@ -0,0 +1 @@\n' + + '+export const history = true;\n', + beforeContent: null, + afterContent: 'export const history = true;\n', + beforeExists: false, + afterExists: true, + binary: false, + truncated: false, + additions: 1, + deletions: 0, + }, + }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + + app.activeSessionId = 'history-session'; + app.fileBrowserSessionId = 'history-session'; + app.fileBrowserScopeId = 'scope-history'; + app.fileBrowserCommitCache.clear(); + app.fileBrowserExpandedCommit = null; + app.fileBrowserRepositoryData = { + available: true, + repositoryRoot: '/repo', + selectedScopeId: 'scope-history', + worktrees: [ + { + id: 'scope-history', + path: '/repo', + name: 'repo', + branch: 'main', + head: commitHash, + current: true, + main: true, + locked: false, + }, + ], + changes: [], + commits: [ + { + hash: commitHash, + shortHash: commitHash.slice(0, 8), + author: 'Agent', + authoredAt: '2026-07-27T10:00:00Z', + subject: 'Add mobile history', + }, + ], + }; + document.getElementById('fileBrowserPanel')?.classList.add('visible'); + app.switchFileBrowserView('history'); + }, commit); + + await expect.poll(() => page.locator('.repo-commit-summary').textContent()).toContain('Add mobile history'); + await page.locator('.repo-commit-summary').click(); + await expect.poll(() => page.locator('.repo-commit-file').textContent()).toContain('src/history.ts'); + await page.locator('.repo-commit-file').click(); + await expect.poll(() => page.locator('#filePreviewFooter').textContent()).toContain(commit.slice(0, 8)); + await expect.poll(() => page.locator('.repository-diff-line.diff-add').count()).toBe(1); + }); +}); diff --git a/test/mobile/header-buttons.test.ts b/test/mobile/header-buttons.test.ts index c84ba8b5..72420f62 100644 --- a/test/mobile/header-buttons.test.ts +++ b/test/mobile/header-buttons.test.ts @@ -35,12 +35,60 @@ describe('Header button visibility (E2E)', () => { await page.waitForTimeout(WAIT.PAGE_SETTLE); await assertHidden(page, '.btn-icon-header.btn-settings'); await assertHidden(page, '.btn-icon-header.btn-lifecycle-log'); + await assertHidden(page, '.btn-icon-header.btn-file-viewer'); + await assertHidden(page, '#instanceShutdownBtn'); + }); + + it('confirms instance shutdown without restoring terminal keyboard focus', async () => { + const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL); + await page.waitForTimeout(WAIT.PAGE_SETTLE); + await page.route('**/api/system/shutdown', async (route) => { + await route.fulfill({ + status: 202, + contentType: 'application/json', + body: JSON.stringify({ + accepted: true, + strategy: 'manual', + alreadyScheduled: false, + }), + }); + }); + + await page.locator('.xterm-helper-textarea').focus(); + await page.locator('#instanceShutdownBtn').click(); + await assertVisible(page, '#instanceShutdownModal'); + + const opened = await page.evaluate(() => ({ + activeId: document.activeElement?.id || '', + terminalFocused: document.activeElement?.classList.contains('xterm-helper-textarea') || false, + modalZ: Number.parseInt(getComputedStyle(document.getElementById('instanceShutdownModal')!).zIndex, 10), + headerZ: Number.parseInt(getComputedStyle(document.querySelector('.header')!).zIndex, 10), + })); + expect(opened.activeId).toBe('instanceShutdownCancelBtn'); + expect(opened.terminalFocused).toBe(false); + expect(opened.modalZ).toBeGreaterThan(opened.headerZ); + + await page.locator('#instanceShutdownCancelBtn').click(); + expect( + await page.evaluate(() => document.activeElement?.classList.contains('xterm-helper-textarea') || false) + ).toBe(false); + + await page.locator('#instanceShutdownBtn').click(); + const responsePromise = page.waitForResponse( + (response) => response.url().endsWith('/api/system/shutdown') && response.request().method() === 'POST' + ); + await page.locator('#instanceShutdownConfirmBtn').click(); + expect((await responsePromise).status()).toBe(202); + await page.waitForFunction(() => + document.getElementById('instanceShutdownStatus')?.textContent?.includes('Codeman is stopping') + ); }); it('keeps the opt-in attachments button HIDDEN by default on a desktop-class viewport', async () => { const { page } = await createDevicePage(REPRESENTATIVE_DEVICES['large-tablet'], BASE_URL); await page.waitForTimeout(WAIT.PAGE_SETTLE); await assertHidden(page, '#attachmentsHistoryBtn'); + await assertVisible(page, '#instanceShutdownBtn'); }); it('shows the attachments button once the setting is enabled', async () => { diff --git a/test/mobile/helpers/constants.ts b/test/mobile/helpers/constants.ts index b2dda4d0..d50e436e 100644 --- a/test/mobile/helpers/constants.ts +++ b/test/mobile/helpers/constants.ts @@ -9,6 +9,8 @@ export const PORTS = { VISUAL_REGRESSION: 3206, ACCESSIBILITY: 3207, HEADER_BUTTONS: 3208, + NAVIGATION_PAD: 3209, + FILE_VIEWER: 3211, } as const; // CSS Selectors @@ -36,6 +38,7 @@ export const SELECTORS = { // Keyboard KEYBOARD_ACCESSORY: '.keyboard-accessory-bar', KEYBOARD_ACCESSORY_VISIBLE: '.keyboard-accessory-bar.visible', + MOBILE_NAVIGATION: '.mobile-terminal-nav', // Terminal TERMINAL_CONTAINER: '.terminal-container', @@ -68,6 +71,7 @@ export const KEYBOARD = { SHOW_THRESHOLD: 150, // heightDiff > 150px triggers show HIDE_THRESHOLD: 100, // heightDiff < 100px triggers hide TYPICAL_IOS_HEIGHT: 336, + TYPICAL_ANDROID_HEIGHT: 300, FOCUSIN_DELAY: 400, ANIMATION_DELAY: 150, RESIZE_DELAY_SHOW: 150, diff --git a/test/mobile/helpers/keyboard-sim.ts b/test/mobile/helpers/keyboard-sim.ts index 83079c3d..2be20790 100644 --- a/test/mobile/helpers/keyboard-sim.ts +++ b/test/mobile/helpers/keyboard-sim.ts @@ -50,12 +50,7 @@ export async function showKeyboardViaCDP(page: Page, keyboardHeight: number): Pr const cdp = await getCDP(page); const viewport = page.viewportSize()!; const newHeight = viewport.height - keyboardHeight; - await setVisualViewportHeight( - cdp, - viewport.width, - newHeight, - 1, - ); + await setVisualViewportHeight(cdp, viewport.width, newHeight, 1); await page.waitForTimeout(100); await page.evaluate(`(function(newH, origH) { @@ -148,10 +143,7 @@ export async function setupViewportMock(page: Page): Promise { const realVV = window.visualViewport; if (!realVV) return; - const origDesc = Object.getOwnPropertyDescriptor( - Object.getPrototypeOf(realVV), - 'height', - ); + const origDesc = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(realVV), 'height'); Object.defineProperty(realVV, 'height', { get() { @@ -228,9 +220,11 @@ export async function showKeyboardViaDOM(page: Page, keyboardHeight: number): Pr export async function hideKeyboardViaDOM(page: Page): Promise { try { await page.evaluate(`(function() { - document.body.classList.remove('keyboard-visible'); + document.body.classList.remove('keyboard-visible', 'keyboard-opening'); if (typeof KeyboardHandler !== 'undefined') { KeyboardHandler.keyboardVisible = false; + clearTimeout(KeyboardHandler._keyboardOpeningTimer); + KeyboardHandler._keyboardOpeningTimer = null; if (typeof KeyboardHandler.resetLayout === 'function') KeyboardHandler.resetLayout(); } @@ -258,7 +252,7 @@ export async function hideKeyboardViaDOM(page: Page): Promise { export async function showKeyboard( page: Page, keyboardHeight: number, - options: KeyboardSimOptions = {}, + options: KeyboardSimOptions = {} ): Promise { const { preferredLayer, isChromium = true } = options; @@ -282,18 +276,21 @@ export async function showKeyboard( } /** Hide keyboard using the best available simulation layer */ -export async function hideKeyboard( - page: Page, - options: KeyboardSimOptions = {}, -): Promise { +export async function hideKeyboard(page: Page, options: KeyboardSimOptions = {}): Promise { const { preferredLayer, isChromium = true } = options; if (preferredLayer) { let success = false; switch (preferredLayer) { - case 'cdp': success = await hideKeyboardViaCDP(page); break; - case 'mock': success = await hideKeyboardViaMock(page); break; - case 'dom': success = await hideKeyboardViaDOM(page); break; + case 'cdp': + success = await hideKeyboardViaCDP(page); + break; + case 'mock': + success = await hideKeyboardViaMock(page); + break; + case 'dom': + success = await hideKeyboardViaDOM(page); + break; } return { layer: preferredLayer, success }; } @@ -314,7 +311,7 @@ async function tryLayer( page: Page, keyboardHeight: number, layer: KeyboardLayer, - isChromium: boolean, + isChromium: boolean ): Promise { switch (layer) { case 'cdp': diff --git a/test/mobile/helpers/server.ts b/test/mobile/helpers/server.ts index ffa4e58f..c7ea24d9 100644 --- a/test/mobile/helpers/server.ts +++ b/test/mobile/helpers/server.ts @@ -12,7 +12,8 @@ export async function createTestServer(port: number): Promise { return server; } -export async function stopTestServer(server: WebServer): Promise { +export async function stopTestServer(server?: WebServer): Promise { + if (!server) return; await server.stop(); for (const [port, s] of servers) { if (s === server) { diff --git a/test/mobile/keyboard.test.ts b/test/mobile/keyboard.test.ts index 5032f653..920ecc5f 100644 --- a/test/mobile/keyboard.test.ts +++ b/test/mobile/keyboard.test.ts @@ -23,7 +23,7 @@ import { assertHidden, getCSSProperty, } from './helpers/assertions.js'; -import { REPRESENTATIVE_DEVICES } from './devices.js'; +import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js'; import type { WebServer } from '../src/web/server.js'; const PORT = PORTS.KEYBOARD; @@ -127,6 +127,40 @@ describe('Virtual Keyboard', () => { expect(visible).toBe(false); }); + it('accumulates incremental keyboard-animation shrink without lowering its baseline', async () => { + const initial = await getKeyboardState(page); + const baseline = initial.initialViewportHeight; + + for (const shrink of [70, 140]) { + await page.evaluate(`(function(height, fullHeight) { + var vv = window.visualViewport; + Object.defineProperty(vv, 'height', { + get: function() { return height; }, + configurable: true, + }); + Object.defineProperty(window, 'innerHeight', { + get: function() { return fullHeight; }, + configurable: true, + }); + KeyboardHandler.handleViewportResize(); + })(${baseline - shrink}, ${baseline})`); + expect(await getKeyboardVisible(page)).toBe(false); + expect((await getKeyboardState(page)).initialViewportHeight).toBe(baseline); + } + + await page.evaluate(`(function(height) { + var vv = window.visualViewport; + Object.defineProperty(vv, 'height', { + get: function() { return height; }, + configurable: true, + }); + KeyboardHandler.handleViewportResize(); + })(${baseline - 210})`); + + expect(await getKeyboardVisible(page)).toBe(true); + await assertHasClass(page, 'body', BODY_CLASSES.KEYBOARD_VISIBLE); + }); + it('dismissing keyboard restores original state', async () => { // Show await showKeyboardViaCDP(page, KEYBOARD.TYPICAL_IOS_HEIGHT); @@ -222,43 +256,222 @@ describe('Virtual Keyboard', () => { await context.close(); }); - it('toolbar remains below terminal when keyboard show shrinks the app viewport', async () => { + it('hides Codeman control rows so the terminal reaches the phone keyboard edge', async () => { await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); const layout = await page.evaluate(() => { const toolbar = document.querySelector('.toolbar') as HTMLElement | null; const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null; - const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null; + const terminal = document.getElementById('terminalContainer'); + const appEl = document.querySelector('.app') as HTMLElement | null; + const main = document.querySelector('.main') as HTMLElement | null; const toolbarRect = toolbar?.getBoundingClientRect(); const accessoryRect = accessory?.getBoundingClientRect(); - const terminalRect = terminalWrap?.getBoundingClientRect(); + const terminalRect = terminal?.getBoundingClientRect(); + const appRect = appEl?.getBoundingClientRect(); return { + toolbarDisplay: toolbar ? getComputedStyle(toolbar).display : '', + accessoryDisplay: accessory ? getComputedStyle(accessory).display : '', toolbarTransform: toolbar?.style.transform ?? '', accessoryTransform: (accessory as HTMLElement | null)?.style.transform ?? '', - toolbarTop: toolbarRect?.top ?? 0, - accessoryTop: accessoryRect?.top ?? 0, + toolbarHeight: toolbarRect?.height ?? 0, + accessoryHeight: accessoryRect?.height ?? 0, terminalBottom: terminalRect?.bottom ?? 0, + appBottom: appRect?.bottom ?? 0, + mainPadding: main ? parseFloat(getComputedStyle(main).paddingBottom) : -1, }; }); + expect(layout.toolbarDisplay).toBe('none'); + expect(layout.accessoryDisplay).toBe('none'); expect(layout.toolbarTransform).toBe(''); expect(layout.accessoryTransform).toBe(''); - expect(layout.accessoryTop).toBeGreaterThanOrEqual(layout.terminalBottom - 4); - expect(layout.toolbarTop).toBeGreaterThan(layout.accessoryTop); + expect(layout.toolbarHeight).toBe(0); + expect(layout.accessoryHeight).toBe(0); + expect(layout.mainPadding).toBe(0); + expect(Math.abs(layout.terminalBottom - layout.appBottom)).toBeLessThanOrEqual(1); }); - it('accessory bar gets .visible class', async () => { + it('keeps the accessory bar hidden on phones while the keyboard is open', async () => { await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); - const hasVisible = await page.evaluate(() => { + const accessoryState = await page.evaluate(() => { const bar = document.querySelector('.keyboard-accessory-bar'); - return bar?.classList.contains('visible') ?? false; + return { + hasVisibleClass: bar?.classList.contains('visible') ?? false, + display: bar ? getComputedStyle(bar).display : '', + }; + }); + expect(accessoryState.hasVisibleClass).toBe(false); + expect(accessoryState.display).toBe('none'); + }); + + it('copies a whole raw response chunk on double-click without stealing control actions', async () => { + const state = await page.evaluate(async () => { + const body = document.createElement('div') as HTMLDivElement & { + _codemanCopyText?: string; + _rvCopyFeedbackTimer?: ReturnType; + }; + body.className = 'response-viewer-body'; + + const reply = document.createElement('div') as HTMLDivElement & { + _codemanCopyText?: string; + _rvCopyFeedbackTimer?: ReturnType; + }; + reply.className = 'rv-message rv-msg-assistant'; + reply._codemanCopyText = '**Raw reply**\n\n```js\nconst answer = 42;\n```'; + reply.innerHTML = + '
Claude
' + + '

Rendered reply

' + + ''; + body.appendChild(reply); + document.body.appendChild(body); + + const copied: string[] = []; + const originalCopy = app._copyText; + const originalToast = app.showToast; + const originalFeedback = MobileTerminalControls.feedback; + app._copyText = async (text: string) => { + copied.push(text); + return true; + }; + app.showToast = () => {}; + MobileTerminalControls.feedback = () => {}; + app._bindResponseViewerInteractions(body); + + const replyText = reply.querySelector('.rv-text')!; + const replyDispatchResult = replyText.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + detail: 2, + }) + ); + await Promise.resolve(); + + reply.querySelector('button')!.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + detail: 2, + }) + ); + await Promise.resolve(); + + const replyFeedback = reply.classList.contains('rv-copy-feedback'); + const touchAction = getComputedStyle(reply).touchAction; + if (reply._rvCopyFeedbackTimer) clearTimeout(reply._rvCopyFeedbackTimer); + + body.innerHTML = '

Rendered last response

'; + body._codemanCopyText = '# Raw last response'; + body.querySelector('p')!.dispatchEvent( + new MouseEvent('dblclick', { + bubbles: true, + cancelable: true, + detail: 2, + }) + ); + await Promise.resolve(); + + if (body._rvCopyFeedbackTimer) clearTimeout(body._rvCopyFeedbackTimer); + app._copyText = originalCopy; + app.showToast = originalToast; + MobileTerminalControls.feedback = originalFeedback; + body.remove(); + + return { + copied, + replyDefaultPrevented: !replyDispatchResult, + replyFeedback, + touchAction, + }; + }); + + expect(state).toEqual({ + copied: ['**Raw reply**\n\n```js\nconst answer = 42;\n```', '# Raw last response'], + replyDefaultPrevented: true, + replyFeedback: true, + touchAction: 'manipulation', + }); + }); + + it('copies a whole response chunk from two real mobile touch taps', async () => { + await page.evaluate(() => { + const testWindow = window as typeof window & { + __rvDoubleTapCopyTest?: { + copied: string[]; + originalCopy: typeof app._copyText; + originalToast: typeof app.showToast; + originalFeedback: typeof MobileTerminalControls.feedback; + }; + }; + const body = document.createElement('div'); + body.id = 'rv-double-tap-copy-test'; + body.className = 'response-viewer-body'; + body.style.cssText = + 'position:fixed;inset:16px auto auto 16px;width:280px;height:120px;z-index:2147483647;background:#111'; + + const reply = document.createElement('div') as HTMLDivElement & { + _codemanCopyText?: string; + }; + reply.className = 'rv-message rv-msg-assistant'; + reply._codemanCopyText = 'Raw touch reply'; + reply.innerHTML = '
Rendered touch reply
'; + body.appendChild(reply); + document.body.appendChild(body); + + testWindow.__rvDoubleTapCopyTest = { + copied: [], + originalCopy: app._copyText, + originalToast: app.showToast, + originalFeedback: MobileTerminalControls.feedback, + }; + app._copyText = async (text: string) => { + testWindow.__rvDoubleTapCopyTest!.copied.push(text); + return true; + }; + app.showToast = () => {}; + MobileTerminalControls.feedback = () => {}; + app._bindResponseViewerInteractions(body); }); - expect(hasVisible).toBe(true); + + try { + const replyText = page.locator('#rv-double-tap-copy-test .rv-text'); + await replyText.tap(); + await page.waitForTimeout(80); + await replyText.tap(); + await page.waitForTimeout(50); + + const copied = await page.evaluate(() => { + const testWindow = window as typeof window & { + __rvDoubleTapCopyTest?: { copied: string[] }; + }; + return testWindow.__rvDoubleTapCopyTest?.copied ?? []; + }); + expect(copied).toEqual(['Raw touch reply']); + } finally { + await page.evaluate(() => { + const testWindow = window as typeof window & { + __rvDoubleTapCopyTest?: { + originalCopy: typeof app._copyText; + originalToast: typeof app.showToast; + originalFeedback: typeof MobileTerminalControls.feedback; + }; + }; + const state = testWindow.__rvDoubleTapCopyTest; + if (state) { + app._copyText = state.originalCopy; + app.showToast = state.originalToast; + MobileTerminalControls.feedback = state.originalFeedback; + } + document.getElementById('rv-double-tap-copy-test')?.remove(); + delete testWindow.__rvDoubleTapCopyTest; + }); + } }); - it('main padding increases on keyboard show', async () => { + it('clears main padding when the phone keyboard opens', async () => { const initialPadding = await page.evaluate(() => { const main = document.querySelector('.main') as HTMLElement | null; return main ? getComputedStyle(main).paddingBottom : '0px'; @@ -273,7 +486,62 @@ describe('Virtual Keyboard', () => { return main ? main.style.paddingBottom : ''; }); const newPx = parseFloat(newPadding) || 0; - expect(newPx).toBeGreaterThan(initialPx); + expect(initialPx).toBeGreaterThan(0); + expect(newPx).toBe(0); + }); + + it('locks the handheld app as soon as terminal focus requests the keyboard', async () => { + const state = await page.evaluate(() => { + const focusTarget = document.createElement('button'); + document.body.appendChild(focusTarget); + focusTarget.focus(); + KeyboardHandler.keyboardVisible = false; + KeyboardHandler._terminalInputRequested = false; + document.body.classList.remove('keyboard-visible', 'keyboard-opening'); + + app.terminal.focus(); + + const appElement = document.querySelector('.app'); + const result = { + terminalRequested: KeyboardHandler._terminalInputRequested, + openingClass: document.body.classList.contains('keyboard-opening'), + appPosition: appElement ? getComputedStyle(appElement).position : '', + }; + clearTimeout(KeyboardHandler._keyboardOpeningTimer); + KeyboardHandler._keyboardOpeningTimer = null; + focusTarget.remove(); + return result; + }); + + expect(state).toEqual({ + terminalRequested: true, + openingClass: true, + appPosition: 'fixed', + }); + }); + + it('marks keyboard-driven resizing as active viewport control', async () => { + const call = await page.evaluate(`(async function() { + var originalSendResize = app.sendResize; + var captured = null; + app.activeSessionId = 'mobile-keyboard-takeover'; + app.sendResize = function(sessionId, options) { + captured = { sessionId: sessionId, options: options }; + return Promise.resolve(false); + }; + try { + KeyboardHandler._sendTerminalResize(); + await Promise.resolve(); + return captured; + } finally { + app.sendResize = originalSendResize; + } + })()`); + + expect(call).toEqual({ + sessionId: 'mobile-keyboard-takeover', + options: { takeControl: true, refit: false }, + }); }); it('does not reserve the keyboard height as visible terminal dead space', async () => { @@ -284,12 +552,15 @@ describe('Virtual Keyboard', () => { const main = document.querySelector('.main') as HTMLElement | null; const appEl = document.querySelector('.app') as HTMLElement | null; const terminalWrap = document.querySelector('.terminal-wrap') as HTMLElement | null; + const terminal = document.getElementById('terminalContainer'); const toolbar = document.querySelector('.toolbar') as HTMLElement | null; const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null; return { appHeight: appEl?.getBoundingClientRect().height ?? 0, + appBottom: appEl?.getBoundingClientRect().bottom ?? 0, mainPaddingBottom: main ? parseFloat(main.style.paddingBottom || '0') : 0, terminalHeight: terminalWrap?.getBoundingClientRect().height ?? 0, + terminalBottom: terminal?.getBoundingClientRect().bottom ?? 0, toolbarHeight: toolbar?.getBoundingClientRect().height ?? 0, accessoryHeight: accessory?.getBoundingClientRect().height ?? 0, visualViewportHeight: window.visualViewport?.height ?? window.innerHeight, @@ -297,8 +568,9 @@ describe('Virtual Keyboard', () => { }); expect(layout.appHeight).toBeLessThanOrEqual(layout.visualViewportHeight + 2); - expect(layout.mainPaddingBottom).toBeLessThan(KEYBOARD.TYPICAL_IOS_HEIGHT); - expect(layout.mainPaddingBottom).toBeGreaterThanOrEqual(layout.toolbarHeight + layout.accessoryHeight - 4); + expect(layout.mainPaddingBottom).toBe(0); + expect(layout.toolbarHeight + layout.accessoryHeight).toBe(0); + expect(Math.abs(layout.terminalBottom - layout.appBottom)).toBeLessThanOrEqual(1); expect(layout.terminalHeight).toBeGreaterThan(160); }); @@ -323,13 +595,178 @@ describe('Virtual Keyboard', () => { expect(mainPadding).toBe(''); }); - it('accessory bar has the simple-mode action buttons', async () => { + it('reveals and focuses the live terminal input as soon as its keyboard opens', async () => { + const state = await page.evaluate(() => { + const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal); + const originalFocusInput = app._focusMobileTerminalInput.bind(app); + let bottomRestores = 0; + let inputFocusRestores = 0; + app.terminal.scrollToBottom = () => { + bottomRestores++; + }; + app._focusMobileTerminalInput = () => { + inputFocusRestores++; + }; + app._terminalScrollLocked = true; + app._wasAtBottomBeforeWrite = false; + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); + + KeyboardHandler.onKeyboardShow(); + const immediate = { + bottomRestores, + inputFocusRestores, + scrollLocked: app._terminalScrollLocked, + followsBottom: app._wasAtBottomBeforeWrite, + }; + + clearTimeout(KeyboardHandler._viewportSettleTimer); + KeyboardHandler._viewportSettleTimer = null; + KeyboardHandler._settleScrollToBottom = false; + KeyboardHandler._settleFocusInput = false; + app.terminal.scrollToBottom = originalScrollToBottom; + app._focusMobileTerminalInput = originalFocusInput; + return immediate; + }); + + expect(state).toEqual({ + bottomRestores: 1, + inputFocusRestores: 1, + scrollLocked: false, + followsBottom: true, + }); + }); + + it('coalesces keyboard animation frames into one final terminal fit', async () => { + const result = await page.evaluate(async () => { + const originalFit = app.fitAddon.fit.bind(app.fitAddon); + const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler); + const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal); + const originalFocusInput = app._focusMobileTerminalInput.bind(app); + let fits = 0; + let resizes = 0; + let bottomRestores = 0; + let inputFocusRestores = 0; + app.fitAddon.fit = () => { + fits++; + }; + KeyboardHandler._sendTerminalResize = () => { + resizes++; + }; + app.terminal.scrollToBottom = () => { + bottomRestores++; + }; + app._focusMobileTerminalInput = () => { + inputFocusRestores++; + }; + app._terminalScrollLocked = true; + app._wasAtBottomBeforeWrite = false; + KeyboardHandler.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); + + KeyboardHandler._scheduleViewportSettle({ scrollToBottom: true, focusInput: true }); + await new Promise((resolve) => setTimeout(resolve, 30)); + KeyboardHandler._scheduleViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, 30)); + KeyboardHandler._scheduleViewportSettle(); + await new Promise((resolve) => setTimeout(resolve, 50)); + const beforeFinalSettle = { fits, resizes, bottomRestores, inputFocusRestores }; + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS)); + const afterFinalSettle = { + fits, + resizes, + bottomRestores, + inputFocusRestores, + scrollLocked: app._terminalScrollLocked, + followsBottom: app._wasAtBottomBeforeWrite, + }; + + app.fitAddon.fit = originalFit; + KeyboardHandler._sendTerminalResize = originalSendResize; + app.terminal.scrollToBottom = originalScrollToBottom; + app._focusMobileTerminalInput = originalFocusInput; + return { beforeFinalSettle, afterFinalSettle }; + }); + + expect(result.beforeFinalSettle).toEqual({ + fits: 0, + resizes: 0, + bottomRestores: 0, + inputFocusRestores: 0, + }); + expect(result.afterFinalSettle).toEqual({ + fits: 1, + resizes: 1, + bottomRestores: 1, + inputFocusRestores: 1, + scrollLocked: false, + followsBottom: true, + }); + }); + + it('does not steal focus or resize the PTY when the keyboard belongs to a form field', async () => { + const state = await page.evaluate(async () => { + const originalFocusInput = app._focusMobileTerminalInput.bind(app); + const originalScrollToBottom = app.terminal.scrollToBottom.bind(app.terminal); + const originalSendResize = KeyboardHandler._sendTerminalResize.bind(KeyboardHandler); + let inputFocusRestores = 0; + let bottomRestores = 0; + let resizes = 0; + app._focusMobileTerminalInput = () => { + inputFocusRestores++; + }; + app.terminal.scrollToBottom = () => { + bottomRestores++; + }; + KeyboardHandler._sendTerminalResize = () => { + resizes++; + }; + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = false; + document.body.classList.add('keyboard-visible'); + + KeyboardHandler.onKeyboardShow(); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.VIEWPORT_SETTLE_MS + 20)); + + app._focusMobileTerminalInput = originalFocusInput; + app.terminal.scrollToBottom = originalScrollToBottom; + KeyboardHandler._sendTerminalResize = originalSendResize; + return { inputFocusRestores, bottomRestores, resizes }; + }); + + expect(state).toEqual({ + inputFocusRestores: 0, + bottomRestores: 0, + resizes: 0, + }); + }); + + it('accessory bar has the unified terminal-control action set', async () => { const actions = await page.evaluate(() => { return Array.from(document.querySelectorAll('.keyboard-accessory-bar [data-action]')).map( (button) => (button as HTMLElement).dataset.action ); }); - expect(actions).toEqual(['scroll-up', 'scroll-down', 'init', 'clear', 'paste', 'dismiss']); + expect(actions).toEqual([ + 'esc', + 'arrow-left', + 'scroll-up', + 'opt-enter', + 'scroll-down', + 'arrow-right', + 'tab', + 'shift-tab', + 'paste', + 'pick-path', + 'clear-input', + 'effort-max', + 'ctrl-o', + 'init', + 'clear', + 'compact', + 'dismiss', + ]); }); it('double-tap confirm on /clear button', async () => { @@ -418,14 +855,16 @@ describe('Virtual Keyboard', () => { expect(activeTag).toBe('BODY'); }); - it('terminal fit called on keyboard toggle', async () => { + it('keeps keyboard fits inside the coalesced keyboard settle', async () => { // Inject spy on fitAddon.fit await page.evaluate(` if (typeof app !== 'undefined' && app.fitAddon) { window.__fitCallCount = 0; + window.__fitCallStacks = []; var orig = app.fitAddon.fit; app.fitAddon.fit = function () { window.__fitCallCount++; + window.__fitCallStacks.push(String(new Error().stack || '')); try { orig.call(this); } catch(e) {} }; } @@ -435,9 +874,14 @@ describe('Virtual Keyboard', () => { // Wait for the setTimeout(150) in onKeyboardShow await page.waitForTimeout(300); - const callCount = await page.evaluate(() => (window as any).__fitCallCount ?? 0); - // Soft assertion — fitAddon may not be initialized without real terminal - expect(callCount).toBeGreaterThanOrEqual(0); + const calls = await page.evaluate(() => ({ + count: (window as any).__fitCallCount ?? 0, + stacks: (window as any).__fitCallStacks ?? [], + })); + // One settled fit plus, when needed, one synchronous row-gap correction. + expect(calls.count).toBeGreaterThanOrEqual(1); + expect(calls.count).toBeLessThanOrEqual(2); + expect(calls.stacks.every((stack: string) => stack.includes('mobile-handlers.js'))).toBe(true); }); it('keeps xterm helper textarea focusable near the terminal cursor on touch devices', async () => { @@ -475,7 +919,7 @@ describe('Virtual Keyboard', () => { expect(Number(styles?.zIndex)).toBeGreaterThanOrEqual(0); }); - it('routes CJK textarea typing through local echo on Enter', async () => { + it('routes CJK textarea typing directly to session input', async () => { await page.evaluate(() => { window.__sentInputs = []; const sessionId = 'mobile-cjk-local-echo-test'; @@ -514,18 +958,18 @@ describe('Virtual Keyboard', () => { pendingText: app._localEchoOverlay.pendingText, sentInputs: window.__sentInputs, })); - expect(beforeEnter.visibleText).toBe('hello'); + expect(beforeEnter.visibleText).toBe(''); expect(beforeEnter.pendingText).toBe(''); - expect(beforeEnter.sentInputs).toEqual([]); + expect(beforeEnter.sentInputs.join('')).toBe('hello'); await page.keyboard.press('Enter'); - await page.waitForFunction(() => window.__sentInputs?.length === 2); + await page.waitForFunction(() => window.__sentInputs?.join('') === 'hello\r'); const afterEnter = await page.evaluate(() => ({ pendingText: app._localEchoOverlay.pendingText, sentInputs: window.__sentInputs, })); expect(afterEnter.pendingText).toBe(''); - expect(afterEnter.sentInputs).toEqual(['hello', '\r']); + expect(afterEnter.sentInputs.join('')).toBe('hello\r'); }); it('shows the CJK textarea on mobile for server override only inside an active session', async () => { @@ -605,25 +1049,226 @@ describe('Virtual Keyboard', () => { expect(state?.bodyClass).toBe(false); }); - it('focuses the terminal helper textarea when the terminal is tapped', async () => { - await page.evaluate(() => { + it('collapses a terminal readback without focusing the hidden textarea', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-readback-tap-test'; + app.sessions.set('mobile-readback-tap-test', { + id: 'mobile-readback-tap-test', + mode: 'codex', + status: 'running', + }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app.terminal.reset(); + await new Promise((resolve) => + app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve) + ); + app.terminal.focus(); + + const screen = app.terminal.element?.querySelector('.xterm-screen'); + const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; + const rect = screen?.getBoundingClientRect(); + if (!rect || !cell?.width || !cell?.height) return null; + return { + x: rect.left + cell.width * 2, + y: rect.top + cell.height / 2, + }; + }); + expect(point).not.toBeNull(); + + await page.touchscreen.tap(point!.x, point!.y); + + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(state.activeClass).not.toContain('xterm-helper-textarea'); + expect(state.sentInputs).toHaveLength(1); + expect(state.sentInputs[0]).toMatch(/^\x1b\[<0;\d+;1M\x1b\[<0;\d+;1m$/); + }); + + it('prevents Claude subagent status taps from opening the hidden keyboard input', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-claude-subagent-tap-test'; + app.sessions.set('mobile-claude-subagent-tap-test', { + id: 'mobile-claude-subagent-tap-test', + mode: 'claude', + cliVersion: '2.1.220', + status: 'working', + }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.hideWelcome(); + app.terminal.reset(); + const statusRow = Math.max(0, app.terminal.rows - 2); + await new Promise((resolve) => + app.terminal.write( + `${'\r\n'.repeat(statusRow)}• Working (1m 50s • esc to interrupt) · 1 background teammate`, + resolve + ) + ); + app.terminal.focus(); + + const screen = app.terminal.element?.querySelector('.xterm-screen'); + const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; + const rect = screen?.getBoundingClientRect(); + if (!screen || !rect || !cell?.width || !cell?.height) return null; + const cursorRow = app.terminal.buffer.active.cursorY; + const x = rect.left + cell.width * 2; + const y = rect.top + cell.height * (cursorRow + 0.5); + return { + x, + y, + intent: app._classifyMobileTerminalTap(x, y), + cursorRow, + screenBottom: rect.bottom, + }; + }); + expect(point).toEqual( + expect.objectContaining({ + intent: 'content', + }) + ); + + const dispatch = await page.evaluate(({ x, y }) => { + const target = document.querySelector('#terminalContainer .xterm-screen'); + if (!(target instanceof Element)) { + return { prevented: false, insideTerminal: false, targetClass: null }; + } + const touch = new Touch({ + identifier: 3, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + const allowed = target.dispatchEvent( + new TouchEvent('touchstart', { + touches: [touch], + changedTouches: [touch], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchend', { + touches: [], + changedTouches: [touch], + bubbles: true, + cancelable: true, + }) + ); + return { + prevented: !allowed, + insideTerminal: Boolean(target.closest('#terminalContainer')), + targetClass: target.className, + }; + }, point!); + + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(dispatch).toEqual( + expect.objectContaining({ + prevented: true, + insideTerminal: true, + }) + ); + expect(state.activeClass).not.toContain('xterm-helper-textarea'); + expect(state.sentInputs).toHaveLength(1); + }); + + it('focuses the terminal helper textarea when the visible prompt is tapped', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; app.activeSessionId = 'mobile-focus-visible-input-test'; app.sessions.set('mobile-focus-visible-input-test', { id: 'mobile-focus-visible-input-test', mode: 'codex', status: 'running', }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; app.hideWelcome(); const settings = app.loadAppSettingsFromStorage(); settings.cjkInputEnabled = false; app.saveAppSettingsToStorage(settings); app._updateCjkInputState(); + app.terminal.reset(); + await new Promise((resolve) => + app.terminal.write('Agent readback\r\n tap to collapse\r\n\r\n› ask', resolve) + ); + (document.activeElement as HTMLElement | null)?.blur?.(); + + const screen = app.terminal.element?.querySelector('.xterm-screen'); + const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; + const rect = screen?.getBoundingClientRect(); + if (!rect || !cell?.width || !cell?.height) return null; + return { + x: rect.left + cell.width * 2, + y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5), + }; }); + expect(point).not.toBeNull(); - await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); + await page.touchscreen.tap(point!.x, point!.y); + + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(state.activeClass).toContain('xterm-helper-textarea'); + expect(state.sentInputs).toEqual([]); + }); + + it('focuses the live Claude cursor when a redraw omits the prompt glyph', async () => { + const point = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-focus-promptless-claude-test'; + app.sessions.set('mobile-focus-promptless-claude-test', { + id: 'mobile-focus-promptless-claude-test', + mode: 'claude', + status: 'running', + }); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('Claude response\r\nready for input', resolve)); + (document.activeElement as HTMLElement | null)?.blur?.(); + + const screen = app.terminal.element?.querySelector('.xterm-screen'); + const cell = app.terminal._core?._renderService?.dimensions?.css?.cell; + const rect = screen?.getBoundingClientRect(); + if (!rect || !cell?.width || !cell?.height) return null; + return { + x: rect.left + cell.width * 2, + y: rect.top + cell.height * (app.terminal.buffer.active.cursorY + 0.5), + }; + }); + expect(point).not.toBeNull(); + + await page.touchscreen.tap(point!.x, point!.y); - const activeClass = await page.evaluate(() => document.activeElement?.className); - expect(activeClass).toContain('xterm-helper-textarea'); + const state = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + sentInputs: window.__sentInputs, + })); + expect(state.activeClass).toContain('xterm-helper-textarea'); + expect(state.sentInputs).toEqual([]); }); it('keeps terminal touch drag available for scrollback with the visible textarea enabled', async () => { @@ -695,52 +1340,2673 @@ describe('Virtual Keyboard', () => { expect(calls.some((lines) => lines !== 0)).toBe(true); }); - it('keeps typed phone text in the terminal local echo path', async () => { - await page.evaluate(() => { - window.__sentInputs = []; - app.activeSessionId = 'mobile-visible-input-test'; - app.sessions.set('mobile-visible-input-test', { - id: 'mobile-visible-input-test', + it('keeps a focused draft visible while a keyboard-open transcript drag reads history', async () => { + const result = await page.evaluate(async () => { + app.activeSessionId = 'mobile-focused-draft-scroll-test'; + app.sessions.set('mobile-focused-draft-scroll-test', { + id: 'mobile-focused-draft-scroll-test', mode: 'codex', status: 'running', }); app.hideWelcome(); - app._sendInputAsync = (_sessionId: string, input: string) => { - window.__sentInputs.push(input); - }; const settings = app.loadAppSettingsFromStorage(); settings.cjkInputEnabled = false; settings.localEchoEnabled = true; app.saveAppSettingsToStorage(settings); app._updateCjkInputState(); app._updateLocalEchoState(); + app.terminal.reset(); + const history = Array.from( + { length: app.terminal.rows + 20 }, + (_, index) => `conversation line ${index + 1}` + ).join('\r\n'); + await new Promise((resolve) => app.terminal.write(`${history}\r\n› `, resolve)); app.terminal.focus(); - }); - - await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); - await page.keyboard.type('find bug'); - - const beforeEnter = await page.evaluate(() => ({ - activeClass: document.activeElement?.className, - cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display, - pendingText: app._localEchoOverlay?.pendingText, - sentInputs: window.__sentInputs, - })); - expect(beforeEnter.activeClass).toContain('xterm-helper-textarea'); - expect(beforeEnter.cjkDisplay).toBe('none'); - expect(beforeEnter.pendingText).toBe('find bug'); - expect(beforeEnter.sentInputs).toEqual([]); + app._localEchoOverlay.appendText('draft remains visible'); + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); - await page.keyboard.press('Enter'); - await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r'); + const target = + document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer'); + if (!target) return null; + const rect = target.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const startY = rect.top + Math.min(80, rect.height / 3); + const endY = Math.min(rect.bottom - 10, startY + 120); - const afterEnter = await page.evaluate(() => ({ - pendingText: app._localEchoOverlay?.pendingText, - sentInputs: window.__sentInputs, - })); - expect(afterEnter.pendingText).toBe(''); - expect(afterEnter.sentInputs.join('')).toBe('find bug\r'); - }); + const createTouch = (y: number) => + new Touch({ + identifier: 8, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + target.dispatchEvent( + new TouchEvent('touchstart', { + touches: [createTouch(startY)], + changedTouches: [createTouch(startY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchmove', { + touches: [createTouch(endY)], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchend', { + touches: [], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + + return { + keyboardVisible: KeyboardHandler.keyboardVisible, + terminalFocused: document.activeElement === app.terminal.textarea, + viewportY: app.terminal.buffer.active.viewportY, + baseY: app.terminal.buffer.active.baseY, + pendingText: app._localEchoOverlay.pendingText, + draftVisible: app._localEchoOverlay.state.visible, + }; + }); + + expect(result).toEqual( + expect.objectContaining({ + keyboardVisible: true, + terminalFocused: true, + pendingText: 'draft remains visible', + draftVisible: true, + }) + ); + expect(result!.viewportY).toBeLessThan(result!.baseY); + }); + + it('keeps the previous terminal frame until nonblank Codex output settles', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-keyboard-frame-cover-test'; + app.sessions.set('mobile-keyboard-frame-cover-test', { + id: 'mobile-keyboard-frame-cover-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('frame before keyboard\r\n› ', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler._beginTerminalFrameCover(); + KeyboardHandler._armTerminalFrameCover(); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + const before = { + exists: Boolean(cover), + text: cover?.textContent || '', + }; + + await new Promise((resolve) => app.terminal.write('\x1b[2J\x1b[H', resolve)); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_MIN_MS + 30)); + const survivedBlank = Boolean(app.terminal.element?.querySelector('.terminal-resize-frame-cover')); + + app.batchTerminalWrite('frame after keyboard\r\n› '); + await new Promise((resolve) => setTimeout(resolve, 100)); + const survivedCodexQuietWindow = Boolean(app.terminal.element?.querySelector('.terminal-resize-frame-cover')); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_CODEX_QUIET_MS + 80)); + return { + before, + survivedBlank, + survivedCodexQuietWindow, + removedAfterFrame: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + }; + }); + + expect(state.before.exists).toBe(true); + expect(state.before.text).toContain('frame before keyboard'); + expect(state.survivedBlank).toBe(true); + expect(state.survivedCodexQuietWindow).toBe(true); + expect(state.removedAfterFrame).toBe(true); + }); + + it('replaces keyboard resize redraws with the authoritative pane without losing scrollback', async () => { + const state = await page.evaluate(async () => { + const sessionId = 'mobile-keyboard-authoritative-pane-test'; + const originalFetch = window.fetch; + const originalSendResize = app.sendResize; + const snapshot = `\x1b[1;1HAUTHORITATIVE CURRENT PANE\x1b[${app.terminal.rows};1H› `; + const snapshotCursor = { + stream: 'keyboard-frame-stream', + generation: 1, + start: 0, + end: 500, + }; + const headers = { + 'content-type': 'text/plain; charset=utf-8', + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': snapshotCursor.stream, + 'x-codeman-terminal-generation': String(snapshotCursor.generation), + 'x-codeman-terminal-start': String(snapshotCursor.start), + 'x-codeman-terminal-end': String(snapshotCursor.end), + 'x-codeman-terminal-status': 'idle', + 'x-codeman-terminal-full-size': String(snapshot.length), + 'x-codeman-terminal-truncated': '0', + 'x-codeman-terminal-source': 'mux-visible', + }; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + const history = Array.from( + { length: app.terminal.rows + 20 }, + (_, index) => `KEEP_HISTORY_${String(index).padStart(3, '0')}` + ).join('\r\n'); + await new Promise((resolve) => { + app.terminal.write(`${history}\r\nstable frame before resize\r\n› `, resolve); + }); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + try { + app.sendResize = async () => true; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes(`/api/sessions/${sessionId}/terminal?latest=1`)) { + return new Response(snapshot, { status: 200, headers }); + } + return originalFetch.call(window, input, init); + }) as typeof window.fetch; + + KeyboardHandler._beginTerminalFrameCover({ restart: true, arm: true }); + const startedAt = performance.now(); + const reconcile = KeyboardHandler._sendTerminalResize(); + const gatedImmediately = app._isLoadingBuffer; + app.batchTerminalWrite('\x1b[2J\x1b[HSTALE HISTORY REDRAW', { + stream: snapshotCursor.stream, + generation: snapshotCursor.generation, + start: 100, + end: 130, + }); + await reconcile; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const buffer = app.terminal.buffer.active; + const allText = Array.from( + { length: buffer.length }, + (_, index) => buffer.getLine(index)?.translateToString(true) || '' + ).join('\n'); + const visibleText = Array.from( + { length: app.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '' + ).join('\n'); + return { + allText, + elapsedMs: performance.now() - startedAt, + gatedImmediately, + removed: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + visibleText, + }; + } finally { + window.fetch = originalFetch; + app.sendResize = originalSendResize; + KeyboardHandler._discardTerminalFrameCover(); + } + }); + + expect(state.gatedImmediately).toBe(true); + expect(state.visibleText).toContain('AUTHORITATIVE CURRENT PANE'); + expect(state.visibleText).not.toContain('STALE HISTORY REDRAW'); + expect(state.allText).toContain('KEEP_HISTORY_000'); + expect(state.removed).toBe(true); + expect(state.elapsedMs).toBeLessThan(1000); + }); + + it('reconciles the pane after a touch decision is submitted without relying on hooks', async () => { + const state = await page.evaluate(async () => { + const sessionId = 'mobile-dialogue-authoritative-pane-test'; + const originalFetch = window.fetch; + const originalReliableSend = app._reliableSend; + const sent: string[] = []; + const snapshot = '\x1b[1;1HDIALOGUE RESOLVED\x1b[3;1H› '; + const snapshotCursor = { + stream: 'dialogue-frame-stream', + generation: 2, + start: 0, + end: 300, + }; + const headers = { + 'content-type': 'text/plain; charset=utf-8', + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': snapshotCursor.stream, + 'x-codeman-terminal-generation': String(snapshotCursor.generation), + 'x-codeman-terminal-start': String(snapshotCursor.start), + 'x-codeman-terminal-end': String(snapshotCursor.end), + 'x-codeman-terminal-status': 'idle', + 'x-codeman-terminal-full-size': String(snapshot.length), + 'x-codeman-terminal-truncated': '0', + 'x-codeman-terminal-source': 'mux-visible', + }; + + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('Approve this action?\r\n› 1. Approve\r\n 2. Reject\r\nPress enter to confirm', resolve); + }); + app.terminal.scrollToBottom(); + + try { + app._reliableSend = (_target: string, input: string) => sent.push(input); + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes(`/api/sessions/${sessionId}/terminal?latest=1`)) { + return new Response(snapshot, { status: 200, headers }); + } + return originalFetch.call(window, input, init); + }) as typeof window.fetch; + + const decisionDetected = app._hasForegroundTerminalDecision(sessionId); + app._sendInputAsync(sessionId, '\r'); + const reconcile = app._terminalFrameReconcilePromise; + const gatedImmediately = app._isLoadingBuffer; + app.batchTerminalWrite('\x1b[2J\x1b[HOLD DIALOGUE HISTORY', { + stream: snapshotCursor.stream, + generation: snapshotCursor.generation, + start: 100, + end: 125, + }); + await reconcile; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const buffer = app.terminal.buffer.active; + const visibleText = Array.from( + { length: app.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '' + ).join('\n'); + return { + decisionDetected, + gatedImmediately, + removed: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + sent, + visibleText, + }; + } finally { + window.fetch = originalFetch; + app._reliableSend = originalReliableSend; + KeyboardHandler._discardTerminalFrameCover(); + } + }); + + expect(state.decisionDetected).toBe(true); + expect(state.gatedImmediately).toBe(true); + expect(state.sent).toEqual(['\r']); + expect(state.visibleText).toContain('DIALOGUE RESOLVED'); + expect(state.visibleText).not.toContain('OLD DIALOGUE HISTORY'); + expect(state.removed).toBe(true); + }); + + it('restarts an opening frame cover when keyboard close begins', async () => { + const state = await page.evaluate(async () => { + const sessionId = 'mobile-keyboard-close-cover-race-test'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('stable frame before keyboard close\r\n› ', resolve); + }); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); + const baseline = KeyboardHandler.initialViewportHeight || window.innerHeight; + Object.defineProperty(window.visualViewport, 'height', { + get: () => baseline, + configurable: true, + }); + + KeyboardHandler._beginTerminalFrameCover(); + KeyboardHandler._armTerminalFrameCover(); + KeyboardHandler._terminalFrameCoverReady = true; + KeyboardHandler._terminalFrameCoverReadyVersion += 1; + KeyboardHandler._scheduleTerminalFrameCoverSwap(); + const releaseVersion = KeyboardHandler._terminalFrameCoverReadyVersion; + + KeyboardHandler.handleViewportResize(); + const restartedVersion = KeyboardHandler._terminalFrameCoverReadyVersion; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + }); + + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + return { + keyboardVisible: KeyboardHandler.keyboardVisible, + coverExists: Boolean(cover), + coverText: cover?.textContent || '', + releaseInvalidated: restartedVersion > releaseVersion, + }; + }); + + expect(state.keyboardVisible).toBe(false); + expect(state.coverExists).toBe(true); + expect(state.coverText).toContain('stable frame before keyboard close'); + expect(state.releaseInvalidated).toBe(true); + }); + + it('covers the first keyboard-closing growth before the hidden threshold', async () => { + const state = await page.evaluate(async () => { + const sessionId = 'mobile-keyboard-close-growth-cover-test'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('stable frame before viewport growth\r\n› ', resolve); + }); + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + + const baseline = KeyboardHandler.initialViewportHeight || window.innerHeight; + const openHeight = baseline - 300; + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + KeyboardHandler._keyboardOpenMinHeight = openHeight; + KeyboardHandler._keyboardClosing = false; + KeyboardHandler.lastViewportHeight = openHeight; + document.body.classList.add('keyboard-visible'); + + Object.defineProperty(window.visualViewport, 'height', { + get: () => openHeight + 24, + configurable: true, + }); + KeyboardHandler.handleViewportResize(); + const ignoredAddressBarDrift = !app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + + Object.defineProperty(window.visualViewport, 'height', { + get: () => openHeight + 60, + configurable: true, + }); + KeyboardHandler.handleViewportResize(); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + const result = { + coverExists: Boolean(cover), + coverText: cover?.textContent || '', + ignoredAddressBarDrift, + keyboardClosing: KeyboardHandler._keyboardClosing, + keyboardVisible: KeyboardHandler.keyboardVisible, + }; + + if (KeyboardHandler._viewportSettleTimer) { + clearTimeout(KeyboardHandler._viewportSettleTimer); + KeyboardHandler._viewportSettleTimer = null; + } + KeyboardHandler._settleScrollToBottom = false; + KeyboardHandler._settleFocusInput = false; + KeyboardHandler._discardTerminalFrameCover(); + return result; + }); + + expect(state.ignoredAddressBarDrift).toBe(true); + expect(state.keyboardVisible).toBe(true); + expect(state.keyboardClosing).toBe(true); + expect(state.coverExists).toBe(true); + expect(state.coverText).toContain('stable frame before viewport growth'); + }); + + it('keeps the frame opaque through local resize renders and swaps only after terminal output paints', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-keyboard-atomic-frame-test'; + app.sessions.set('mobile-keyboard-atomic-frame-test', { + id: 'mobile-keyboard-atomic-frame-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('old stable frame\r\n› ', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler._beginTerminalFrameCover(); + KeyboardHandler._armTerminalFrameCover(); + const coverTexts: string[] = []; + const readCoverText = () => { + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + if (cover) coverTexts.push(cover.textContent || ''); + return cover; + }; + readCoverText(); + app.terminal.refresh(0, app.terminal.rows - 1); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_MIN_MS + 80)); + const survivedLocalRender = Boolean(app.terminal.element?.querySelector('.terminal-resize-frame-cover')); + + const opacitySamples: Array = []; + app.batchTerminalWrite('\x1b[2J\x1b[Hnew stable frame\r\n› '); + for (let frame = 0; frame < 5; frame++) { + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + const cover = readCoverText() as HTMLElement | null; + opacitySamples.push(cover ? Number(getComputedStyle(cover).opacity) : null); + } + const survivedInitialCodexFrame = Boolean(app.terminal.element?.querySelector('.terminal-resize-frame-cover')); + app.batchTerminalWrite('\x1b[2J\x1b[Hfinal stable frame\r\n› '); + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_CODEX_QUIET_MS - 40)); + readCoverText(); + await new Promise((resolve) => setTimeout(resolve, 120)); + + return { + survivedLocalRender, + survivedInitialCodexFrame, + coverTexts, + opacitySamples, + removedAfterOutput: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + }; + }); + + expect(state.survivedLocalRender).toBe(true); + expect(state.survivedInitialCodexFrame).toBe(true); + expect(state.coverTexts.length).toBeGreaterThan(2); + expect(state.coverTexts.every((text) => text.includes('old stable frame'))).toBe(true); + expect(state.coverTexts.every((text) => !text.includes('new stable frame'))).toBe(true); + expect(state.coverTexts.every((text) => !text.includes('final stable frame'))).toBe(true); + expect(state.opacitySamples.every((opacity) => opacity === null || opacity === 1)).toBe(true); + expect(state.removedAfterOutput).toBe(true); + }); + + it('crops the captured frame in place instead of panning history during keyboard shrink', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-keyboard-frame-motion-test'; + app.sessions.set('mobile-keyboard-frame-motion-test', { + id: 'mobile-keyboard-frame-motion-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('stable frame before keyboard\r\n› ', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler._beginTerminalFrameCover(); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover') as HTMLElement | null; + const frame = cover?.querySelector('.terminal-resize-frame') as HTMLElement | null; + if (!cover || !frame) return null; + const initialHeight = cover.getBoundingClientRect().height; + const initialTop = frame.getBoundingClientRect().top; + const nextHeight = Math.max(180, initialHeight - 180); + + KeyboardHandler.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); + document.documentElement.style.setProperty('--app-height', `${nextHeight + 42}px`); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + + const style = getComputedStyle(frame); + return { + topDelta: frame.getBoundingClientRect().top - initialTop, + top: style.top, + bottom: style.bottom, + transform: style.transform, + }; + }); + + expect(state).not.toBeNull(); + expect(Math.abs(state!.topDelta)).toBeLessThan(1); + expect(state!.top).toBe('0px'); + expect(state!.bottom).not.toBe('0px'); + expect(state!.transform).toBe('none'); + }); + + it('uses an atomic final-frame swap when the keyboard cover reaches its safety timeout', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-keyboard-frame-timeout-test'; + app.sessions.set('mobile-keyboard-frame-timeout-test', { + id: 'mobile-keyboard-frame-timeout-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('timeout source frame\r\n› ', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + const originalMax = KeyboardHandler.FRAME_COVER_MAX_MS; + KeyboardHandler.FRAME_COVER_MAX_MS = 40; + try { + KeyboardHandler._beginTerminalFrameCover(); + KeyboardHandler._armTerminalFrameCover(); + await new Promise((resolve) => app.terminal.write('\x1b[2J\x1b[Htimeout final frame\r\n› ', resolve)); + await new Promise((resolve) => setTimeout(resolve, 20)); + const coverText = app.terminal.element?.querySelector('.terminal-resize-frame-cover')?.textContent || ''; + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + coverText, + removed: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + }; + } finally { + KeyboardHandler.FRAME_COVER_MAX_MS = originalMax; + KeyboardHandler._discardTerminalFrameCover(); + } + }); + + expect(state.coverText).toContain('timeout source frame'); + expect(state.coverText).not.toContain('timeout final frame'); + expect(state.removed).toBe(true); + }); + + it('holds the outgoing frame and avoids a second keyboard fit during a tab switch', async () => { + await page.route('**/api/sessions/*/terminal*', async (route) => { + const sessionId = new URL(route.request().url()).pathname.split('/')[3]; + if (sessionId === 'smooth-tab-b') { + await new Promise((resolve) => setTimeout(resolve, 140)); + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + terminalBuffer: `${sessionId} stable frame\r\n${sessionId} prompt`, + truncated: false, + }, + }), + }); + }); + + const state = await page.evaluate(async () => { + app._initialFullBufferLoad = false; + app.sessions.set('smooth-tab-a', { + id: 'smooth-tab-a', + name: 'Smooth A', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessions.set('smooth-tab-b', { + id: 'smooth-tab-b', + name: 'Smooth B', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = ['smooth-tab-a', 'smooth-tab-b']; + app.renderSessionTabs(); + await app.selectSession('smooth-tab-a'); + await new Promise((resolve) => app.terminal.write('\r\nsmooth-tab-a outgoing frame\r\n', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); + const originalFit = app.fitAddon.fit.bind(app.fitAddon); + const originalFrameCoverMax = KeyboardHandler.FRAME_COVER_MAX_MS; + KeyboardHandler.FRAME_COVER_MAX_MS = 60; + const fitStacks: string[] = []; + app.fitAddon.fit = () => { + fitStacks.push(String(new Error().stack || '')); + originalFit(); + }; + + const switching = app.selectSession('smooth-tab-b'); + await new Promise((resolve) => setTimeout(resolve, 90)); + const cover = app.terminal.element?.querySelector('.terminal-resize-frame-cover'); + const duringSwitch = { + coverVisible: Boolean(cover), + coverText: cover?.textContent || '', + }; + await switching; + await new Promise((resolve) => setTimeout(resolve, KeyboardHandler.FRAME_COVER_MIN_MS + 30)); + app.fitAddon.fit = originalFit; + KeyboardHandler.FRAME_COVER_MAX_MS = originalFrameCoverMax; + + return { + duringSwitch, + removedAfterTargetFrame: !app.terminal.element?.querySelector('.terminal-resize-frame-cover'), + keyboardFitCalls: fitStacks.filter((stack) => stack.includes('mobile-handlers.js')).length, + }; + }); + + expect(state.duringSwitch.coverVisible).toBe(true); + expect(state.duringSwitch.coverText).toContain('smooth-tab-a'); + expect(state.removedAfterTargetFrame).toBe(true); + expect(state.keyboardFitCalls).toBe(0); + }); + + it('keeps the outgoing frame immutable while bounded target history loads off-screen', async () => { + const state = await page.evaluate(async () => { + const sourceId = 'history-replay-source'; + const targetId = 'history-replay-target'; + const originalFetch = window.fetch; + const originalSendResize = app.sendResize; + const originalConnectWs = app._connectWs; + const originalCaptureCover = app._captureTerminalHistoryReplayCover; + let coverCaptureCount = 0; + const streamHeaders = (end: number, extra: Record = {}) => ({ + 'content-type': 'text/plain; charset=utf-8', + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'history-replay-stream', + 'x-codeman-terminal-generation': '1', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': String(end), + 'x-codeman-terminal-status': 'idle', + 'x-codeman-terminal-full-size': String(end), + 'x-codeman-terminal-truncated': '0', + 'x-codeman-terminal-source': 'mux-visible', + ...extra, + }); + const latestFrame = 'LATEST TARGET FRAME\r\n› current prompt'; + const historicalChunks = Array.from( + { length: 4 }, + (_, chunkIndex) => + Array.from( + { length: 90 }, + (_, lineIndex) => `HISTORY_${chunkIndex}_${String(lineIndex).padStart(3, '0')} background scrollback` + ).join('\r\n') + '\r\n' + ); + let historyChunksSent = 0; + + try { + app.sessions.set(sourceId, { + id: sourceId, + name: 'Replay source', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessions.set(targetId, { + id: targetId, + name: 'Replay target', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = [sourceId, targetId]; + app.activeSessionId = sourceId; + app._initialFullBufferLoad = false; + app.terminalBufferCache.delete(targetId); + app._xtermSnapshots.delete(targetId); + app._warmTerminalCache.remove(targetId); + app.renderSessionTabs(); + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('OUTGOING SOURCE FRAME\r\n› source prompt', resolve)); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); + + app.sendResize = async () => false; + app._connectWs = () => {}; + app._captureTerminalHistoryReplayCover = function () { + const captured = originalCaptureCover.call(this); + if (captured) coverCaptureCount += 1; + return captured; + }; + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!url.includes(`/api/sessions/${targetId}/terminal`)) { + return originalFetch.call(window, input, init); + } + if (url.includes('latest=1')) { + return new Response(latestFrame, { + status: 200, + headers: streamHeaders(latestFrame.length), + }); + } + + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + void (async () => { + for (const chunk of historicalChunks) { + await new Promise((resolve) => setTimeout(resolve, 70)); + controller.enqueue(encoder.encode(chunk)); + historyChunksSent += 1; + } + controller.close(); + })(); + }, + }); + const totalLength = historicalChunks.reduce((sum, chunk) => sum + chunk.length, 0); + return new Response(body, { + status: 200, + headers: streamHeaders(totalLength, { + 'x-codeman-terminal-source': 'mux-history-page', + 'x-codeman-history-start': '0', + 'x-codeman-history-end': '360', + 'x-codeman-history-total': '360', + 'x-codeman-history-more-before': '0', + 'x-codeman-history-more-after': '0', + 'x-codeman-history-origin': 'mobile-history-origin', + }), + }); + }) as typeof window.fetch; + + const switching = app.selectSession(targetId); + const samples: Array<{ + coverText: string; + baseY: number; + viewportY: number; + coverRight: number; + screenRight: number; + }> = []; + for (let attempt = 0; attempt < 40; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + const cover = app.terminal.element?.querySelector('.terminal-history-replay-cover') as HTMLElement | null; + if (historyChunksSent < 1 || !cover || !cover.textContent?.includes('OUTGOING SOURCE FRAME')) { + continue; + } + const screen = app.terminal.element?.querySelector('.xterm-screen') as HTMLElement | null; + const buffer = app.terminal.buffer.active; + const coverRect = cover.getBoundingClientRect(); + const screenRect = screen?.getBoundingClientRect(); + samples.push({ + coverText: cover.textContent || '', + baseY: buffer.baseY, + viewportY: buffer.viewportY, + coverRight: coverRect.right, + screenRight: screenRect?.right || 0, + }); + if (historyChunksSent >= historicalChunks.length - 1) break; + } + + await switching; + await new Promise((resolve) => setTimeout(resolve, 180)); + const buffer = app.terminal.buffer.active; + const visibleRows = Array.from( + { length: app.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '' + ).join('\n'); + const paging = app._terminalHistoryPaging.get(targetId); + + return { + samples, + historyChunksSent, + finalBaseY: buffer.baseY, + finalViewportY: buffer.viewportY, + finalVisibleRows: visibleRows, + coverCaptureCount, + paging: paging + ? { + start: paging.start, + end: paging.end, + total: paging.total, + pages: paging.pages.length, + } + : null, + coverRemoved: !app.terminal.element?.querySelector('.terminal-history-replay-cover'), + }; + } finally { + window.fetch = originalFetch; + app.sendResize = originalSendResize; + app._connectWs = originalConnectWs; + app._captureTerminalHistoryReplayCover = originalCaptureCover; + } + }); + + expect(state.historyChunksSent).toBeGreaterThan(2); + expect(state.samples.length).toBeGreaterThan(2); + expect(state.samples.every((sample) => sample.coverText.includes('OUTGOING SOURCE FRAME'))).toBe(true); + expect(state.samples.every((sample) => !sample.coverText.includes('LATEST TARGET FRAME'))).toBe(true); + expect(state.samples.every((sample) => !sample.coverText.includes('HISTORY_'))).toBe(true); + expect(state.samples.every((sample) => sample.viewportY === sample.baseY)).toBe(true); + expect(Math.max(...state.samples.map((sample) => sample.baseY))).toBe( + Math.min(...state.samples.map((sample) => sample.baseY)) + ); + expect(state.samples.every((sample) => Math.abs(sample.coverRight - sample.screenRight) < 1)).toBe(true); + expect(state.finalBaseY).toBeGreaterThan(state.samples[0].baseY); + expect(state.finalViewportY).toBe(state.finalBaseY); + expect(state.finalVisibleRows).toContain('LATEST TARGET FRAME'); + expect(state.coverCaptureCount).toBe(1); + expect(state.paging).toEqual({ start: 0, end: 360, total: 360, pages: 1 }); + expect(state.coverRemoved).toBe(true); + }); + + it('does not reset the shared terminal while the previous session is still parsing', async () => { + await page.route('**/api/sessions/parser-fence-target/terminal*', async (route) => { + const frame = '\x1b[2J\x1b[HDESTINATION FRAME\r\n› target prompt'; + const end = String(frame.length); + await route.fulfill({ + status: 200, + contentType: 'text/plain; charset=utf-8', + headers: { + 'x-codeman-terminal-format': 'stream-v1', + 'x-codeman-terminal-stream': 'parser-fence-stream', + 'x-codeman-terminal-generation': '1', + 'x-codeman-terminal-start': '0', + 'x-codeman-terminal-end': end, + 'x-codeman-terminal-status': 'idle', + 'x-codeman-terminal-full-size': end, + 'x-codeman-terminal-truncated': '0', + 'x-codeman-terminal-source': route.request().url().includes('historyPage=1') + ? 'mux-history-page' + : 'mux-visible', + ...(route.request().url().includes('historyPage=1') + ? { + 'x-codeman-history-start': '0', + 'x-codeman-history-end': '1', + 'x-codeman-history-total': '1', + 'x-codeman-history-more-before': '0', + 'x-codeman-history-more-after': '0', + 'x-codeman-history-origin': 'parser-fence-origin', + } + : {}), + }, + body: frame, + }); + }); + + const state = await page.evaluate(async () => { + const sourceId = 'parser-fence-source'; + const targetId = 'parser-fence-target'; + app.sessions.set(sourceId, { + id: sourceId, + name: 'Parser source', + mode: 'codex', + status: 'busy', + pid: 1, + workingDir: '/tmp', + }); + app.sessions.set(targetId, { + id: targetId, + name: 'Parser target', + mode: 'codex', + status: 'idle', + pid: 1, + workingDir: '/tmp', + }); + app.sessionOrder = [sourceId, targetId]; + app.activeSessionId = sourceId; + app._initialFullBufferLoad = false; + app._warmTerminalCache.remove(targetId); + app._xtermSnapshots.delete(targetId); + app.terminalBufferCache.delete(targetId); + app.sendResize = async () => false; + app._connectWs = () => {}; + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('SOURCE FRAME\r\n', resolve)); + + let releaseParser: ((value: boolean) => void) | null = null; + let parserEnteredResolve: (() => void) | null = null; + const parserEntered = new Promise((resolve) => { + parserEnteredResolve = resolve; + }); + const parserBlock = new Promise((resolve) => { + releaseParser = resolve; + }); + const blocker = app.terminal.parser.registerOscHandler(777, () => { + parserEnteredResolve?.(); + return parserBlock; + }); + + const originalReset = app.terminal.reset.bind(app.terminal); + let resetCount = 0; + app.terminal.reset = () => { + resetCount += 1; + originalReset(); + }; + + try { + app.batchTerminalWrite('\x1b]777;hold\x07OLD SESSION DATA AFTER BLOCK'); + await parserEntered; + + const switching = app.selectSession(targetId, { takeControl: false }); + await new Promise((resolve) => setTimeout(resolve, 80)); + const resetBeforeRelease = resetCount; + + releaseParser?.(true); + await switching; + await new Promise((resolve) => app.terminal.write('', resolve)); + + const buffer = app.terminal.buffer.active; + const visibleRows = Array.from( + { length: app.terminal.rows }, + (_, row) => buffer.getLine(buffer.viewportY + row)?.translateToString(true) || '' + ).join('\n'); + return { + resetBeforeRelease, + visibleRows, + }; + } finally { + releaseParser?.(true); + blocker.dispose(); + app.terminal.reset = originalReset; + } + }); + + expect(state.resetBeforeRelease).toBe(0); + expect(state.visibleRows).toContain('DESTINATION FRAME'); + expect(state.visibleRows).not.toContain('OLD SESSION DATA AFTER BLOCK'); + }); + + it('routes Claude touch drags to its transcript without moving local xterm history', async () => { + const result = await page.evaluate(async () => { + app.activeSessionId = 'mobile-claude-scroll-test'; + app.sessions.set('mobile-claude-scroll-test', { + id: 'mobile-claude-scroll-test', + mode: 'claude', + cliVersion: '2.1.220', + status: 'running', + }); + app.hideWelcome(); + + const sgrCalls: number[] = []; + const localCalls: number[] = []; + app._sendSyntheticSgrWheel = (_x: number, _y: number, lines: number) => { + sgrCalls.push(lines); + }; + app.terminal.scrollLines = (lines: number) => { + localCalls.push(lines); + }; + + const target = + document.querySelector('#terminalContainer .xterm-screen') ?? document.getElementById('terminalContainer'); + if (!target) return { sgrCalls, localCalls }; + const rect = target.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const startY = rect.top + Math.min(100, rect.height - 20); + const endY = startY + 100; + + function createTouch(y: number) { + return new Touch({ + identifier: 2, + target, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }); + } + + target.dispatchEvent( + new TouchEvent('touchstart', { + touches: [createTouch(startY)], + changedTouches: [createTouch(startY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchmove', { + touches: [createTouch(endY)], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + target.dispatchEvent( + new TouchEvent('touchend', { + touches: [], + changedTouches: [createTouch(endY)], + bubbles: true, + cancelable: true, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + return { sgrCalls, localCalls }; + }); + + expect(result.sgrCalls.some((lines) => lines < 0)).toBe(true); + expect(result.localCalls).toEqual([]); + }); + + it('keeps typed phone text in the terminal local echo path', async () => { + await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-visible-input-test'; + app.sessions.set('mobile-visible-input-test', { + id: 'mobile-visible-input-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.focus(); + }); + + await page.locator('#terminalContainer').tap({ position: { x: 40, y: 40 } }); + // The tap itself emits a valid SGR mouse report. This assertion is about + // typed text, so discard pointer setup traffic before entering text. + await page.evaluate(() => { + window.__sentInputs = []; + }); + await page.keyboard.type('find bug'); + + const beforeEnter = await page.evaluate(() => ({ + activeClass: document.activeElement?.className, + cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display, + pendingText: app._localEchoOverlay?.pendingText, + sentInputs: window.__sentInputs, + })); + expect(beforeEnter.activeClass).toContain('xterm-helper-textarea'); + expect(beforeEnter.cjkDisplay).toBe('none'); + expect(beforeEnter.pendingText).toBe('find bug'); + expect(beforeEnter.sentInputs).toEqual([]); + + await page.keyboard.press('Enter'); + await page.waitForFunction(() => window.__sentInputs?.join('') === 'find bug\r'); + + const afterEnter = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + sentInputs: window.__sentInputs, + })); + expect(afterEnter.pendingText).toBe(''); + expect(afterEnter.sentInputs.join('')).toBe('find bug\r'); + }); + + it('rehydrates an unsent session draft after the page is backgrounded', async () => { + const state = await page.evaluate(() => { + const sessionId = 'mobile-durable-draft-test'; + localStorage.removeItem('codeman:sessionDrafts'); + app._inputState.clearAll({ persist: false }); + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('first paragraph\n\nsecond paragraph'); + + window.dispatchEvent(new PageTransitionEvent('pagehide')); + const persisted = JSON.parse(localStorage.getItem('codeman:sessionDrafts') || '{}'); + + // Simulate the in-memory state loss caused by a discarded mobile tab. + app._localEchoOverlay.clear(); + app._inputState.clearAll({ persist: false }); + app._inputState.load(); + app._restoreSessionDraft(sessionId, false); + + return { + persisted: persisted.drafts?.[sessionId], + restored: app._localEchoOverlay.state, + }; + }); + + expect(state.persisted).toMatchObject({ + pendingText: 'first paragraph\n\nsecond paragraph', + flushedText: '', + cjkText: '', + }); + expect(state.restored).toMatchObject({ + pendingText: 'first paragraph\n\nsecond paragraph', + flushedLength: 0, + flushedText: '', + }); + }); + + it('preserves a live composition when session replay finishes late', async () => { + const state = await page.evaluate(() => { + const sessionId = 'mobile-replay-composition-race-test'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('cd hom'); + app._localEchoOverlay.setCompositionText('e cd hom'); + + // Crash recovery deliberately folds the visible composition into its + // persisted snapshot. A late session replay must not feed that folded + // snapshot back into the still-live overlay. + app._captureActiveSessionDraft(); + const persisted = app._inputState.get(sessionId); + const restored = app._restoreSessionDraft(sessionId, false, { + preserveLiveComposition: true, + }); + + return { + persisted, + restored, + overlay: app._localEchoOverlay.state, + }; + }); + + expect(state).toMatchObject({ + persisted: { + pendingText: 'cd home cd hom', + }, + restored: true, + overlay: { + pendingText: 'cd hom', + compositionText: 'e cd hom', + }, + }); + }); + + it('does not commit Android DEL as finalized composition text', async () => { + const state = await page.evaluate(() => { + const sessionId = 'mobile-composition-delete-test'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('cd'); + app._localEchoOverlay.setCompositionText('candidate'); + app._terminalInputController.clearCompositionDelivery(); + app._terminalInputController.setCompositionPending(true, 'candidate'); + + app.terminal._core.coreService.triggerDataEvent('\x7f', true); + + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + compositionPending: app._terminalInputController.state.compositionPending, + fallbackCommit: app._terminalInputController.state.fallbackCommit, + }; + }); + + expect(state).toEqual({ + pendingText: 'c', + compositionText: '', + compositionPending: false, + fallbackCommit: null, + }); + }); + + it('does not append interim xterm data during an active composition', async () => { + const state = await page.evaluate(async () => { + const sessionId = 'mobile-active-composition-data-test'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._terminalInputController.clearCompositionDelivery(); + app._terminalInputController.setCompositionPending(false); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'home'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'home', + }) + ); + app.terminal._core.coreService.triggerDataEvent('home', true); + const duringComposition = app._localEchoOverlay.pendingText; + + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'home', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + + return { + duringComposition, + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + }; + }); + + expect(state).toEqual({ + duringComposition: '', + pendingText: 'home', + compositionText: '', + }); + }); + + it('strips a stale helper prefix from finalized Android composition data', async () => { + const state = await page.evaluate(async () => { + const sessionId = 'mobile-stale-composition-prefix-test'; + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { + id: sessionId, + mode: 'codex', + status: 'running', + }); + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('cd'); + app._terminalInputController.clearCompositionDelivery(); + app._terminalInputController.setCompositionPending(false); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'hcd home'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: ' home', + }) + ); + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: ' home', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + helperValue: textarea.value, + }; + }); + + expect(state).toEqual({ + pendingText: 'cd home', + compositionText: '', + helperValue: '', + }); + }); + + it('keeps a switched-away draft editable after browser state is reloaded', async () => { + const state = await page.evaluate(() => { + const firstId = 'mobile-draft-session-a'; + const secondId = 'mobile-draft-session-b'; + localStorage.removeItem('codeman:sessionDrafts'); + app._inputState.clearAll({ persist: false }); + app.sessions.set(firstId, { id: firstId, mode: 'codex', status: 'running' }); + app.sessions.set(secondId, { id: secondId, mode: 'codex', status: 'running' }); + app.activeSessionId = firstId; + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('switch-safe draft'); + const sent: string[] = []; + app._sendInputAsync = (_sessionId: string, input: string) => sent.push(input); + + app._cleanupPreviousSession(secondId); + app._inputState.persistNow(); + + // Rehydrate the input store exactly as a fresh browser page does. + app._localEchoOverlay.clear(); + app._inputState.clearAll({ persist: false }); + app._inputState.load(); + app.activeSessionId = firstId; + app._restoreSessionDraft(firstId, false); + const restoredDraft = app._inputState.get(firstId); + + return { + sent, + restored: app._localEchoOverlay.state, + flushedOffset: restoredDraft?.flushedText.length, + flushedText: restoredDraft?.flushedText, + }; + }); + + expect(state).toMatchObject({ + sent: ['switch-safe draft'], + restored: { + pendingText: '', + flushedLength: 'switch-safe draft'.length, + flushedText: 'switch-safe draft', + }, + flushedOffset: 'switch-safe draft'.length, + flushedText: 'switch-safe draft', + }); + }); + + it('removes a persisted draft once Enter submits it', async () => { + const state = await page.evaluate(() => { + const sessionId = 'mobile-cleared-draft-test'; + localStorage.removeItem('codeman:sessionDrafts'); + app._inputState.clearAll({ persist: false }); + app.activeSessionId = sessionId; + app.sessions.set(sessionId, { id: sessionId, mode: 'codex', status: 'running' }); + app._localEchoEnabled = true; + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('submit me'); + app._captureActiveSessionDraft(); + app._inputState.persistNow(); + const before = JSON.parse(localStorage.getItem('codeman:sessionDrafts') || '{}'); + + app._sendInputAsync = () => {}; + app.terminal.input('\r'); + app._inputState.persistNow(); + const after = JSON.parse(localStorage.getItem('codeman:sessionDrafts') || '{}'); + + return { + before: before.drafts?.[sessionId]?.pendingText, + after: after.drafts?.[sessionId] ?? null, + }; + }); + + expect(state).toEqual({ + before: 'submit me', + after: null, + }); + }); + + it('shows and commits each live Android composition after the first word', async () => { + const preview = await page.evaluate(async () => { + app.activeSessionId = 'mobile-composition-test'; + app.sessions.set('mobile-composition-test', { + id: 'mobile-composition-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('first '); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'sec'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'sec', + }) + ); + + const overlay = Array.from(app.terminal.element.querySelector('.xterm-screen')?.children || []).find( + (element) => (element as HTMLElement).style.zIndex === '7' + ); + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + visibleText: overlay?.textContent, + }; + }); + + expect(preview).toEqual({ + pendingText: 'first ', + compositionText: 'sec', + visibleText: expect.stringContaining('first sec'), + }); + + await page.evaluate(() => { + const textarea = app.terminal.textarea; + textarea.value = 'second'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'second', + }) + ); + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'second', + }) + ); + }); + + await expect + .poll(() => + page.evaluate(() => ({ + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + })) + ) + .toEqual({ + pendingText: 'first second', + compositionText: '', + }); + + const nextPreview = await page.evaluate(async () => { + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'abandoned', + }) + ); + textarea.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '' })); + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'third', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + const nativeComposition = app.terminal.element.querySelector('.composition-view'); + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + nativeCompositionActive: nativeComposition?.classList.contains('active'), + nativeCompositionDisplay: nativeComposition ? getComputedStyle(nativeComposition).display : null, + nativeCompositionOpacity: nativeComposition ? getComputedStyle(nativeComposition).opacity : null, + nativeCompositionMeasurable: (nativeComposition?.getBoundingClientRect().height || 0) > 0, + helperLineHeightPositive: parseFloat(app.terminal.textarea.style.lineHeight || '0') > 0, + localEchoClass: app.terminal.element.classList.contains('codeman-local-echo'), + }; + }); + + expect(nextPreview).toEqual({ + pendingText: 'first second', + compositionText: 'third', + nativeCompositionActive: true, + nativeCompositionDisplay: 'block', + nativeCompositionOpacity: '0', + nativeCompositionMeasurable: true, + helperLineHeightPositive: true, + localEchoClass: true, + }); + + const fallback = await page.evaluate(async () => { + const textarea = app.terminal.textarea; + const coreService = app.terminal._core.coreService; + const triggerDataEvent = coreService.triggerDataEvent; + coreService.triggerDataEvent = () => {}; + try { + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'third', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + } finally { + coreService.triggerDataEvent = triggerDataEvent; + } + triggerDataEvent.call(coreService, ' ', true); + triggerDataEvent.call(coreService, 'third', true); + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + fallbackCommit: app._terminalInputController.state.fallbackCommit, + }; + }); + + expect(fallback).toEqual({ + pendingText: 'first secondthird ', + compositionText: '', + fallbackCommit: null, + }); + }); + + it('commits an Android word once when a delayed insertText follows composition finalization', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-composition-late-input-test'; + app.sessions.set('mobile-composition-late-input-test', { + id: 'mobile-composition-late-input-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve)); + app._localEchoOverlay.clear(); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'its'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'its', + }) + ); + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'its', + }) + ); + + // xterm finalizes composition in a zero-delay task. Some Android + // keyboards then emit the same committed word as a late insertText. + await new Promise((resolve) => setTimeout(resolve, 20)); + textarea.value = 'its'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'its', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + return { + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + }; + }); + + expect(state).toEqual({ + pendingText: 'its', + compositionText: '', + }); + }); + + it('dedupes Android suggestion acceptance after the user pauses before Space', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-composition-paused-space-test'; + app.sessions.set('mobile-composition-paused-space-test', { + id: 'mobile-composition-paused-space-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'cd'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'cd', + }) + ); + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + const afterComposition = app._localEchoOverlay.pendingText; + + // The user can pause on a completed Gboard suggestion for any length of + // time. Pressing Space later may replay that finalized word as insertText. + await new Promise((resolve) => setTimeout(resolve, 1100)); + app.terminal.input(' '); + textarea.value = 'cd'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + return { + afterComposition, + pendingText: app._localEchoOverlay.pendingText, + }; + }); + + expect(state).toEqual({ + afterComposition: 'cd', + pendingText: 'cd ', + }); + }); + + it('dedupes an Android replay delayed after the accepting Space', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-composition-delayed-replay-test'; + app.sessions.set('mobile-composition-delayed-replay-test', { + id: 'mobile-composition-delayed-replay-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'cd'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'cd', + }) + ); + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + app.terminal.input(' '); + + // Browser/IME scheduling can defer the alternate callback even after + // Space. Elapsed time must not turn the same finalized word into input. + await new Promise((resolve) => setTimeout(resolve, 1100)); + textarea.value = 'cd'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + return { + pendingText: app._localEchoOverlay.pendingText, + }; + }); + + expect(state).toEqual({ + pendingText: 'cd ', + }); + }); + + it('does not recommit Android final input when compositionend arrives afterward', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-input-before-compositionend-test'; + app.sessions.set('mobile-input-before-compositionend-test', { + id: 'mobile-input-before-compositionend-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'cd'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'cd', + }) + ); + + // Some Android keyboards expose the finalized word as a non-composing + // input mutation before they dispatch compositionend. + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'cd', + }) + ); + const afterInput = app._localEchoOverlay.pendingText; + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + + return { + afterInput, + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + }; + }); + + expect(state).toEqual({ + afterInput: 'cd', + pendingText: 'cd', + compositionText: '', + }); + }); + + it('delivers an Android composition once to an immediate-echo shell', async () => { + const state = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-shell-composition-test'; + app.sessions.set('mobile-shell-composition-test', { + id: 'mobile-shell-composition-test', + mode: 'shell', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + + const textarea = app.terminal.textarea; + textarea.value = ''; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.value = 'cd'; + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + textarea.dispatchEvent( + new CompositionEvent('compositionend', { + bubbles: true, + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Gboard can repeat the finalized word as a non-composing input event + // when the following space accepts its suggestion. + textarea.value = 'cd'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'cd', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + app.terminal.input(' '); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // xterm's helper textarea is an IME scratch buffer and may retain the + // earlier command prefix. InputEvent.data still identifies the newly + // inserted word; routing the whole textarea would resend "cd ". + textarea.value = 'cd home'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'home', + }) + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + return { + sentInputs: window.__sentInputs, + localEchoEnabled: app._localEchoEnabled, + localEchoClass: app.terminal.element?.classList.contains('codeman-local-echo'), + }; + }); + + expect(state).toEqual({ + sentInputs: ['cd', ' ', 'home'], + localEchoEnabled: false, + localEchoClass: false, + }); + }); + + it('routes retained Android helper text once for a local-echo agent', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-agent-retained-helper-test'; + app.sessions.set('mobile-agent-retained-helper-test', { + id: 'mobile-agent-retained-helper-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('cd '); + + const textarea = app.terminal.textarea; + textarea.value = 'cd home'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'home', + }) + ); + await Promise.resolve(); + + return { + pendingText: app._localEchoOverlay.pendingText, + helperValue: textarea.value, + }; + }); + + expect(state).toEqual({ + pendingText: 'cd home', + helperValue: '', + }); + }); + + it('keeps routing Android textarea text after an interrupted composition', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-null-input-data-test'; + app.sessions.set('mobile-null-input-data-test', { + id: 'mobile-null-input-data-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); + app.terminal._core.coreService.triggerDataEvent('a', true); + + const textarea = app.terminal.textarea; + for (const data of ['b', 'c']) { + textarea.value = data; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + inputType: 'insertText', + data: null, + }) + ); + await Promise.resolve(); + } + + const firstSequence = app._localEchoOverlay.pendingText; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'interrupted', + }) + ); + app._localEchoOverlay.clear(); + textarea.value = 'd'; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + inputType: 'insertText', + data: null, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + + return { + firstSequence, + pendingText: app._localEchoOverlay.pendingText, + helperValue: textarea.value, + }; + }); + + expect(state).toEqual({ + firstSequence: 'abc', + pendingText: 'd', + helperValue: '', + }); + }); + + it('applies only the new Android helper-textarea mutation from cumulative values', async () => { + const state = await page.evaluate(async () => { + app.activeSessionId = 'mobile-cumulative-input-test'; + app.sessions.set('mobile-cumulative-input-test', { + id: 'mobile-cumulative-input-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('seed'); + + const textarea = app.terminal.textarea; + const beginMutation = (value: string) => { + textarea.value = value; + textarea.setSelectionRange(value.length, value.length); + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Process', + keyCode: 229, + }) + ); + textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: null, + }) + ); + }; + + beginMutation('seed'); + textarea.value = 'seed next'; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: true, + inputType: 'insertText', + data: null, + }) + ); + await Promise.resolve(); + const afterNullData = app._localEchoOverlay.pendingText; + + beginMutation('seed next'); + textarea.value = 'seed next more'; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + composed: false, + inputType: 'insertText', + data: 'seed next more', + }) + ); + const immediateAfterCumulative = app._localEchoOverlay.pendingText; + await Promise.resolve(); + + return { + afterNullData, + immediateAfterCumulative, + pendingText: app._localEchoOverlay.pendingText, + helperValue: textarea.value, + }; + }); + + expect(state).toEqual({ + afterNullData: 'seed next', + immediateAfterCumulative: 'seed next more', + pendingText: 'seed next more', + helperValue: '', + }); + }); + + it('filters xterm status replies without swallowing user navigation keys', async () => { + const state = await page.evaluate(() => { + const suppress = window.CodemanTerminalInput.shouldSuppressTerminalQueryResponse; + return { + deviceAttributes: suppress('\x1b[>0;276;0c'), + modeReport: suppress('\x1b[?2026;1$y'), + windowReport: suppress('\x1b[8;24;80t'), + statusString: suppress('\x1bP1$r0m\x1b\\'), + oscColor: suppress('\x1b]10;rgb:ffff/ffff/ffff\x1b\\'), + arrowUp: suppress('\x1b[A'), + escape: suppress('\x1b'), + }; + }); + + expect(state).toEqual({ + deviceAttributes: true, + modeReport: true, + windowReport: true, + statusString: true, + oscColor: true, + arrowUp: false, + escape: false, + }); + }); + + it('routes Backspace past a stale autocomplete textarea buffer', async () => { + const state = await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-autocomplete-delete-test'; + app.sessions.set('mobile-autocomplete-delete-test', { + id: 'mobile-autocomplete-delete-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + app._localEchoOverlay.appendText('draft'); + + const textarea = app.terminal.textarea; + const dispatchDelete = (inputType: string) => { + textarea.value = 'invisible autocomplete'; + return textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType, + }) + ); + }; + + const firstAccepted = dispatchDelete('deleteContentBackward'); + const secondAccepted = dispatchDelete('deleteWordBackward'); + const pendingText = app._localEchoOverlay.pendingText; + app._localEchoOverlay.clear(); + const remoteAccepted = dispatchDelete('deleteContentBackward'); + return { + firstAccepted, + secondAccepted, + remoteAccepted, + pendingText, + helperValue: textarea.value, + sentInputs: window.__sentInputs, + }; + }); + + expect(state).toEqual({ + firstAccepted: false, + secondAccepted: false, + remoteAccepted: false, + pendingText: 'dra', + helperValue: '', + sentInputs: ['\x7f'], + }); + }); + + it('keeps a pending phone draft anchored to the prompt after agent output', async () => { + await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-draft-anchor-test'; + app.sessions.set('mobile-draft-anchor-test', { + id: 'mobile-draft-anchor-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + window.__draftPromptRow = Math.max(2, app.terminal.rows - 5); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__draftPromptRow + 1};1H\u276f `, resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('keep this draft'); + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__draftPromptRow)); + + await page.evaluate(() => { + window.__updatedDraftPromptRow = Math.max(2, app.terminal.rows - 4); + app.batchTerminalWrite( + `\x1b[2J\x1b[Hagent output\r\ncontinues here\x1b[${window.__updatedDraftPromptRow + 1};1H\u276f ` + ); + }); + + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__updatedDraftPromptRow)); + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + sentInputs: window.__sentInputs, + })); + expect(state.pendingText).toBe('keep this draft'); + expect(state.sentInputs).toEqual([]); + }); + + it('submits multiline local echo as one bracketed paste frame', async () => { + await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-multiline-paste-test'; + app.sessions.set('mobile-multiline-paste-test', { + id: 'mobile-multiline-paste-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + const pasteEvent = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(pasteEvent, 'clipboardData', { + value: { + getData: (type: string) => (type === 'text/plain' ? 'first paragraph\n\nsecond paragraph' : ''), + }, + }); + app.terminal.textarea.dispatchEvent(pasteEvent); + app.terminal.input('\r'); + }); + + await expect + .poll(() => page.evaluate(() => window.__sentInputs)) + .toEqual(['\x1b[200~first paragraph\r\rsecond paragraph\x1b[201~', '\r']); + }); + + it('captures Android xterm input-only paste before newline segmentation', async () => { + const pastedText = 'References\nfirst line after references\n\nfinal paragraph'; + const captured = await page.evaluate((text) => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-xterm-input-paste-test'; + app.sessions.set('mobile-xterm-input-paste-test', { + id: 'mobile-xterm-input-paste-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + + const textarea = app.terminal.textarea; + textarea.value = text; + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + inputType: 'insertFromPaste', + // Some Android builds expose only the first segment here. The + // helper textarea still contains the full inserted value. + data: 'References', + }) + ); + const pendingText = app._localEchoOverlay.pendingText; + const helperValue = textarea.value; + app.terminal.input('\r'); + return { pendingText, helperValue }; + }, pastedText); + + expect(captured).toEqual({ + pendingText: pastedText, + helperValue: '', + }); + await expect + .poll(() => page.evaluate(() => window.__sentInputs)) + .toEqual(['\x1b[200~References\rfirst line after references\r\rfinal paragraph\x1b[201~', '\r']); + }); + + it('confirms Android paste on a line break without duplicating word-space input', async () => { + const captured = await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-xterm-segmented-paste-test'; + app.sessions.set('mobile-xterm-segmented-paste-test', { + id: 'mobile-xterm-segmented-paste-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + + const textarea = app.terminal.textarea; + const dispatchMutation = (inputType: string, data: string | null = null) => { + const accepted = textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType, + data, + }) + ); + if (accepted && inputType === 'insertText' && data) { + app.terminal.input(data); + } + return accepted; + }; + + const wordInputAccepted = dispatchMutation('insertText', 'writing'); + const spaceInputAccepted = dispatchMutation('insertText', ' '); + const typedText = app._localEchoOverlay.pendingText; + app._localEchoOverlay.clear(); + + dispatchMutation('insertText', 'References'); + dispatchMutation('insertLineBreak'); + dispatchMutation('insertText', 'first line after references'); + dispatchMutation('insertLineBreak'); + dispatchMutation('insertLineBreak'); + dispatchMutation('insertText', 'final paragraph'); + + const pendingText = app._localEchoOverlay.pendingText; + const helperValue = textarea.value; + app.terminal.input('\r'); + return { + wordInputAccepted, + spaceInputAccepted, + typedText, + pendingText, + helperValue, + }; + }); + + expect(captured).toEqual({ + wordInputAccepted: true, + spaceInputAccepted: true, + typedText: 'writing ', + pendingText: 'References\nfirst line after references\n\nfinal paragraph', + helperValue: '', + }); + await expect + .poll(() => page.evaluate(() => window.__sentInputs)) + .toEqual(['\x1b[200~References\rfirst line after references\r\rfinal paragraph\x1b[201~', '\r']); + }); + + it('routes the mobile paste dialog through the multiline paste boundary', async () => { + await page.evaluate(`(() => { + window.__pasteCalls = []; + app.activeSessionId = 'mobile-paste-dialog-test'; + app.sendPastedText = (text, options) => { + window.__pasteCalls.push({ text, options }); + }; + KeyboardAccessoryBar.pasteFromClipboard(); + })()`); + + await page.locator('.paste-textarea').fill('first paragraph\n\nsecond paragraph'); + await page.locator('.paste-send').click(); + + await expect + .poll(() => page.evaluate(() => window.__pasteCalls)) + .toEqual([ + { + text: 'first paragraph\n\nsecond paragraph', + options: { submit: true }, + }, + ]); + expect(await page.locator('.paste-overlay').count()).toBe(0); + }); + + it('keeps multiline paste on the newline-preserving fallback transport', async () => { + const calls = await page.evaluate(() => { + const sent = []; + app.activeSessionId = 'mobile-paste-fallback-test'; + app.sessions.set('mobile-paste-fallback-test', { + id: 'mobile-paste-fallback-test', + mode: 'claude', + status: 'running', + }); + app._sendInputAsync = (sessionId: string, input: string, options?: { useMux?: boolean }) => { + sent.push({ sessionId, input, options }); + }; + app.sendPastedText('first\n\nReferences\nmore after references', { submit: true }); + return sent; + }); + + expect(calls).toEqual([ + { + sessionId: 'mobile-paste-fallback-test', + input: '\x1b[200~first\r\rReferences\rmore after references\x1b[201~', + options: { useMux: false }, + }, + { + sessionId: 'mobile-paste-fallback-test', + input: '\r', + options: { useMux: false }, + }, + ]); + }); + + it('keeps back-to-back local echo submissions in text-then-Enter order', async () => { + await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-rapid-submit-test'; + app.sessions.set('mobile-rapid-submit-test', { + id: 'mobile-rapid-submit-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.focus(); + }); + + await page.keyboard.type('first'); + await page.keyboard.press('Enter'); + await page.keyboard.type('second'); + await page.keyboard.press('Enter'); + + await expect.poll(() => page.evaluate(() => window.__sentInputs)).toEqual(['first', '\r', 'second', '\r']); + }); + + it('inserts Android keyCode 229 line-break input into the current draft', async () => { + const state = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-android-enter-test'; + app.sessions.set('mobile-android-enter-test', { + id: 'mobile-android-enter-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); + + app.terminal._core.coreService.triggerDataEvent('stringA stringB', true); + const textarea = app.terminal.textarea; + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Enter', + code: 'Enter', + keyCode: 229, + }) + ); + textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertLineBreak', + }) + ); + textarea.dispatchEvent( + new InputEvent('input', { + bubbles: true, + inputType: 'insertLineBreak', + }) + ); + app.terminal._core.coreService.triggerDataEvent('stringC', true); + await Promise.resolve(); + + return { + sentInputs: window.__sentInputs, + pendingText: app._localEchoOverlay.pendingText, + }; + }); + + expect(state).toEqual({ + sentInputs: [], + pendingText: 'stringA stringB\nstringC', + }); + }); + + it('inserts an Android line break when compositionend is omitted', async () => { + const state = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-android-composing-enter-test'; + app.sessions.set('mobile-android-composing-enter-test', { + id: 'mobile-android-composing-enter-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); + + app.terminal._core.coreService.triggerDataEvent('stringA ', true); + const textarea = app.terminal.textarea; + textarea.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true })); + textarea.dispatchEvent( + new CompositionEvent('compositionupdate', { + bubbles: true, + data: 'stringB', + }) + ); + textarea.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: 'Enter', + code: 'Enter', + keyCode: 229, + isComposing: true, + }) + ); + textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertLineBreak', + isComposing: true, + }) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + app.terminal._core.coreService.triggerDataEvent('stringC', true); + + return { + sentInputs: window.__sentInputs, + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + }; + }); + + expect(state).toEqual({ + sentInputs: [], + pendingText: 'stringA stringB\nstringC', + compositionText: '', + }); + }); + + it('flushes local echo before mobile-control Enter and accepts the next draft', async () => { + const state = await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-control-enter-test'; + app.sessions.set('mobile-control-enter-test', { + id: 'mobile-control-enter-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + app.sendResize = async () => false; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f ', resolve); + }); + app._localEchoOverlay.clear(); + + app.terminal._core.coreService.triggerDataEvent('stringA ', true); + app._localEchoOverlay.setCompositionText('stringB'); + app.terminal.blur(); + app.sendTerminalKey('\r'); + app.terminal._core.coreService.triggerDataEvent('stringC', true); + + return { + sentInputs: window.__sentInputs, + pendingText: app._localEchoOverlay.pendingText, + compositionText: app._localEchoOverlay.compositionText, + visible: app._localEchoOverlay.state.visible, + terminalFocused: document.activeElement === app.terminal.textarea, + }; + }); + + expect(state).toEqual({ + sentInputs: ['stringA stringB', '\r'], + pendingText: 'stringC', + compositionText: '', + visible: true, + terminalFocused: false, + }); + }); + + it('holds the first phone draft until the initial prompt frame is loaded', async () => { + await page.evaluate(() => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-initial-draft-test'; + app.sessions.set('mobile-initial-draft-test', { + id: 'mobile-initial-draft-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._beginBufferLoad('mobile-initial-draft-load'); + app.terminal.focus(); + }); + + await page.keyboard.type('first draft'); + + const loadingState = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + overlayState: app._localEchoOverlay?.state, + sentInputs: window.__sentInputs, + })); + expect(loadingState.pendingText).toBe('first draft'); + expect(loadingState.overlayState?.visible).toBe(false); + expect(loadingState.overlayState?.promptPosition).toBeNull(); + expect(loadingState.sentInputs).toEqual([]); + + await page.evaluate(async () => { + window.__loadedPromptRow = Math.max(2, app.terminal.rows - 5); + await new Promise((resolve) => { + app.terminal.write( + `\x1b[2J\x1b[Hagent output\r\nready\x1b[${window.__loadedPromptRow + 1};1H\u203a `, + resolve + ); + }); + app._finishBufferLoad('mobile-initial-draft-load'); + }); + + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay?.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__loadedPromptRow)); + const readyState = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + visible: app._localEchoOverlay?.state.visible, + sentInputs: window.__sentInputs, + })); + expect(readyState.pendingText).toBe('first draft'); + expect(readyState.visible).toBe(true); + expect(readyState.sentInputs).toEqual([]); + }); it('shows terminal local echo at the cursor when no prompt marker is visible', async () => { await page.evaluate(async () => { @@ -758,7 +4024,13 @@ describe('Virtual Keyboard', () => { app._updateCjkInputState(); app._updateLocalEchoState(); app.terminal.reset(); - await new Promise((resolve) => app.terminal.write('working without prompt marker', resolve)); + window.__cursorFallbackRow = Math.max(0, app.terminal.rows - 2); + await new Promise((resolve) => { + app.terminal.write( + `\x1b[2J\x1b[Hworking without prompt marker\x1b[${window.__cursorFallbackRow + 1};1H`, + resolve + ); + }); app.terminal.focus(); }); @@ -768,12 +4040,320 @@ describe('Virtual Keyboard', () => { cjkDisplay: getComputedStyle(document.getElementById('cjkInput') as HTMLElement).display, pendingText: app._localEchoOverlay?.pendingText, overlayState: app._localEchoOverlay?.state, + expectedRow: window.__cursorFallbackRow, })); expect(state.cjkDisplay).toBe('none'); expect(state.pendingText).toBe('abc'); expect(state.overlayState?.visible).toBe(true); - expect(state.overlayState?.promptPosition).not.toBeNull(); + expect(state.overlayState?.promptPosition?.row).toBe(state.expectedRow); + }); + + it('ignores a stale historical prompt above the live input cursor', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-stale-prompt-test'; + app.sessions.set('mobile-stale-prompt-test', { + id: 'mobile-stale-prompt-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__liveInputRow = Math.max(2, app.terminal.rows - 2); + await new Promise((resolve) => { + app.terminal.write( + `\x1b[2J\x1b[H\u276f old submitted input\r\nagent output\r\nworking without prompt marker\x1b[${window.__liveInputRow + 1};1H`, + resolve + ); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('abc'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + expectedRow: window.__liveInputRow, + })); + + expect(state.pendingText).toBe('abc'); + expect(state.promptRow).toBe(state.expectedRow); + }); + + it('prefers the live cursor when a short keyboard viewport makes a stale prompt look current', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-short-viewport-prompt-test'; + app.sessions.set('mobile-short-viewport-prompt-test', { + id: 'mobile-short-viewport-prompt-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.resize(app.terminal.cols, 6); + app.terminal.reset(); + app._localEchoOverlay.clear(); + app._localEchoPromptAnchor = null; + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f old submitted input\x1b[6;3H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('abc'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + cursorRow: app.terminal.buffer.active.cursorY, + })); + + expect(state.pendingText).toBe('abc'); + expect(state.promptRow).toBe(state.cursorRow); + expect(state.cursorRow).toBe(5); + }); + + it('discards a remembered prompt anchor that no longer fits after keyboard shrink', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-invalid-prompt-anchor-test'; + app.sessions.set('mobile-invalid-prompt-anchor-test', { + id: 'mobile-invalid-prompt-anchor-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.resize(app.terminal.cols, 6); + app.terminal.reset(); + app._localEchoOverlay.clear(); + app._localEchoPromptAnchor = { + sessionId: 'mobile-invalid-prompt-anchor-test', + rowsFromBottom: 7, + col: 2, + }; + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\x1b[3;1Hagent output\x1b[6;3H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('abc'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + cursorRow: app.terminal.buffer.active.cursorY, + })); + + expect(state.pendingText).toBe('abc'); + expect(state.promptRow).toBe(state.cursorRow); + expect(state.cursorRow).toBe(5); + }); + + it('uses a bottom fallback for the first mobile draft when the replay prompt remains at row zero', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-row-zero-prompt-test'; + app.sessions.set('mobile-row-zero-prompt-test', { + id: 'mobile-row-zero-prompt-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + app._localEchoPromptAnchor = null; + window.__fallbackPromptRow = Math.max(0, app.terminal.rows - 5); + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f replayed prompt\x1b[H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('first draft'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + expectedRow: window.__fallbackPromptRow, + })); + + expect(state.pendingText).toBe('first draft'); + expect(state.promptRow).toBe(state.expectedRow); + }); + + it('keeps the first post-Enter draft at the remembered prompt while a resized frame is stale', async () => { + await page.evaluate(async () => { + window.__sentInputs = []; + app.activeSessionId = 'mobile-stale-resize-prompt-test'; + app.sessions.set('mobile-stale-resize-prompt-test', { + id: 'mobile-stale-resize-prompt-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + app._sendInputAsync = (_sessionId: string, input: string) => { + window.__sentInputs.push(input); + }; + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__rememberedPromptRow = Math.max(2, app.terminal.rows - 5); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__rememberedPromptRow + 1};1H\u276f `, resolve); + }); + window.__promptAnchorCaptured = app._captureLocalEchoPromptAnchor?.(); + app.terminal._core.coreService.triggerDataEvent('stringA', true); + app.terminal._core.coreService.triggerDataEvent('\r', true); + + await new Promise((resolve) => { + app.terminal.write('\x1b[2J\x1b[H\u276f old submitted input\x1b[H', resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('first draft'); + + const state = await page.evaluate(() => ({ + pendingText: app._localEchoOverlay?.pendingText, + promptRow: app._localEchoOverlay?.state.promptPosition?.row, + expectedRow: window.__rememberedPromptRow, + captured: window.__promptAnchorCaptured, + sentInputs: window.__sentInputs, + })); + + expect(state).toMatchObject({ + pendingText: 'first draft', + captured: true, + promptRow: state.expectedRow, + sentInputs: ['stringA', '\r'], + }); + }); + + it('does not paint a mobile draft over output while the resized prompt frame is pending', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-occupied-anchor-test'; + app.sessions.set('mobile-occupied-anchor-test', { + id: 'mobile-occupied-anchor-test', + mode: 'codex', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__occupiedAnchorRow = Math.max(2, app.terminal.rows - 3); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__occupiedAnchorRow + 1};1H\u276f `, resolve); + }); + window.__occupiedAnchorCaptured = app._captureLocalEchoPromptAnchor?.(); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[Hagent output\x1b[${window.__occupiedAnchorRow + 1};1Hstill output`, resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('held draft'); + + const pendingFrame = await page.evaluate(() => ({ + captured: window.__occupiedAnchorCaptured, + pendingText: app._localEchoOverlay.pendingText, + visible: app._localEchoOverlay.state.visible, + promptRow: app._localEchoOverlay.state.promptPosition?.row ?? null, + })); + expect(pendingFrame).toEqual({ + captured: true, + pendingText: 'held draft', + visible: false, + promptRow: null, + }); + + await page.evaluate(async () => { + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__occupiedAnchorRow + 1};1H\u276f `, resolve); + }); + app._localEchoOverlay.rerender(); + }); + + await expect + .poll(() => page.evaluate(() => app._localEchoOverlay.state.promptPosition?.row)) + .toBe(await page.evaluate(() => window.__occupiedAnchorRow)); + await expect.poll(() => page.evaluate(() => app._localEchoOverlay.state.visible)).toBe(true); + }); + + it('follows the live mobile cursor when it diverges from a remembered anchor', async () => { + await page.evaluate(async () => { + app.activeSessionId = 'mobile-remembered-anchor-drift-test'; + app.sessions.set('mobile-remembered-anchor-drift-test', { + id: 'mobile-remembered-anchor-drift-test', + mode: 'claude', + status: 'running', + }); + app.hideWelcome(); + const settings = app.loadAppSettingsFromStorage(); + settings.cjkInputEnabled = false; + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateCjkInputState(); + app._updateLocalEchoState(); + app.terminal.reset(); + app._localEchoOverlay.clear(); + window.__stableAnchorRow = Math.max(2, app.terminal.rows - 3); + window.__driftCursorRow = Math.max(2, app.terminal.rows - 8); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[H\x1b[${window.__stableAnchorRow + 1};1H\u276f `, resolve); + }); + window.__stableAnchorCaptured = app._captureLocalEchoPromptAnchor?.(); + await new Promise((resolve) => { + app.terminal.write(`\x1b[2J\x1b[Hagent output\x1b[${window.__driftCursorRow + 1};1H`, resolve); + }); + app.terminal.focus(); + }); + + await page.keyboard.type('stable draft'); + + const state = await page.evaluate(() => ({ + captured: window.__stableAnchorCaptured, + promptRow: app._localEchoOverlay.state.promptPosition?.row, + expectedRow: window.__driftCursorRow, + })); + expect(state).toMatchObject({ + captured: true, + promptRow: state.expectedRow, + }); }); }); @@ -796,6 +4376,73 @@ describe('Virtual Keyboard', () => { } }); + it('scaled Android phone keeps unified controls flush with the keyboard edge', async () => { + const device = DEVICE_REGISTRY.find((entry) => entry.name === 'Galaxy S23 Ultra')!; + const { context, page } = await createDevicePage(device, BASE_URL, 'chromium'); + try { + expect(await page.evaluate(() => window.innerWidth)).toBeGreaterThan(430); + expect(await page.evaluate(`MobileDetection.isHandheldDevice()`)).toBe(true); + + await page.evaluate(` + app.activeSessionId = 'scaled-android-controls-test'; + app.hideWelcome(); + MobileTerminalControls.setEnabled(true); + MobileTerminalControls.syncVisibility(); + `); + + const viewport = page.viewportSize()!; + const keyboardViewportHeight = viewport.height - KEYBOARD.TYPICAL_ANDROID_HEIGHT; + const cdp = await getCDP(page); + await setVisualViewportHeight(cdp, viewport.width, keyboardViewportHeight, device.deviceScaleFactor); + await page.waitForTimeout(100); + await page.evaluate(`KeyboardHandler.handleViewportResize()`); + await page.waitForTimeout(WAIT.KEYBOARD_ANIMATION); + expect(await getKeyboardVisible(page)).toBe(true); + + const layout = await page.evaluate(() => { + const appEl = document.querySelector('.app') as HTMLElement | null; + const main = document.querySelector('.main') as HTMLElement | null; + const terminal = document.getElementById('terminalContainer'); + const toolbar = document.querySelector('.toolbar') as HTMLElement | null; + const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement | null; + const accessoryRect = accessory?.getBoundingClientRect(); + const buttonRects = [...(accessory?.querySelectorAll('button') ?? [])].map((button) => + button.getBoundingClientRect() + ); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content ?? '', + hasHandheldClass: document.body.classList.contains('handheld-device'), + toolbarDisplay: toolbar ? getComputedStyle(toolbar).display : '', + accessoryDisplay: accessory ? getComputedStyle(accessory).display : '', + mainPadding: main ? parseFloat(getComputedStyle(main).paddingBottom) : -1, + terminalBottom: terminal?.getBoundingClientRect().bottom ?? 0, + accessoryTop: accessoryRect?.top ?? 0, + accessoryBottom: accessoryRect?.bottom ?? 0, + accessoryButtonsContained: buttonRects.every( + (rect) => rect.top >= (accessoryRect?.top ?? 0) - 1 && rect.bottom <= (accessoryRect?.bottom ?? 0) + 1 + ), + appBottom: appEl?.getBoundingClientRect().bottom ?? 0, + layoutViewportHeight: window.innerHeight, + visualViewportHeight: window.visualViewport?.height ?? 0, + }; + }); + + expect(layout.viewportMeta).toContain('interactive-widget=resizes-content'); + expect(layout.hasHandheldClass).toBe(true); + expect(layout.toolbarDisplay).toBe('none'); + expect(layout.accessoryDisplay).toBe('flex'); + expect(layout.mainPadding).toBeGreaterThan(0); + expect(layout.accessoryButtonsContained).toBe(true); + expect(layout.layoutViewportHeight).toBe(keyboardViewportHeight); + expect(layout.visualViewportHeight).toBe(keyboardViewportHeight); + expect(Math.abs(layout.appBottom - keyboardViewportHeight)).toBeLessThanOrEqual(1); + expect(Math.abs(layout.terminalBottom - layout.accessoryTop)).toBeLessThanOrEqual(1); + expect(Math.abs(layout.accessoryBottom - layout.appBottom)).toBeLessThanOrEqual(1); + } finally { + await context.close(); + } + }); + it('tablet: keyboard handling active', async () => { const device = REPRESENTATIVE_DEVICES['standard-tablet']; // iPad Mini const { context, page } = await createDevicePage(device, BASE_URL, 'chromium'); @@ -827,6 +4474,36 @@ describe('Virtual Keyboard', () => { expect(state.exists).toBe(true); expect(state.keyboardVisible).toBe(false); expect(state.hasViewportHandler).toBe(false); + + const inputState = await page.evaluate(() => { + app.activeSessionId = 'desktop-input-event-test'; + app.sessions.set('desktop-input-event-test', { + id: 'desktop-input-event-test', + mode: 'claude', + status: 'running', + }); + const settings = app.loadAppSettingsFromStorage(); + settings.localEchoEnabled = true; + app.saveAppSettingsToStorage(settings); + app._updateLocalEchoState(); + app._localEchoOverlay.clear(); + const accepted = app.terminal.textarea.dispatchEvent( + new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'insertText', + data: 'autocomplete', + }) + ); + return { + accepted, + pendingText: app._localEchoOverlay.pendingText, + }; + }); + expect(inputState).toEqual({ + accepted: true, + pendingText: '', + }); } finally { await context.close(); } diff --git a/test/mobile/navigation-pad.test.ts b/test/mobile/navigation-pad.test.ts new file mode 100644 index 00000000..452b2ea2 --- /dev/null +++ b/test/mobile/navigation-pad.test.ts @@ -0,0 +1,621 @@ +// Port 3209 - Keyboard-hidden terminal navigation tests +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BrowserContext, Page } from 'playwright'; +import type { WebServer } from '../../src/web/server.js'; +import { TmuxManager } from '../../src/tmux-manager.js'; +import { DEVICE_REGISTRY } from './devices.js'; +import { createDevicePage, closeAllBrowsers } from './helpers/browser.js'; +import { KEYBOARD, PORTS, SELECTORS } from './helpers/constants.js'; +import { getCDP, dispatchTouchEvent } from './helpers/cdp.js'; +import { createTestServer, stopTestServer } from './helpers/server.js'; + +const PORT = PORTS.NAVIGATION_PAD; +const BASE_URL = `http://localhost:${PORT}`; +const SESSION_ID = 'mobile-navigation-test'; +const PIXEL_8 = DEVICE_REGISTRY.find((device) => device.name === 'Pixel 8')!; +const tmuxAvailableSpy = vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true); + +describe('Mobile Navigation Pad', () => { + let server: WebServer; + let context: BrowserContext; + let page: Page; + let inputLog: string[]; + let resizeLog: Array>; + let dataDir: string; + let previousDataDir: string | undefined; + + beforeAll(async () => { + previousDataDir = process.env.CODEMAN_DATA_DIR; + dataDir = await mkdtemp(join(tmpdir(), 'codeman-mobile-navigation-')); + process.env.CODEMAN_DATA_DIR = dataDir; + server = await createTestServer(PORT); + ({ context, page } = await createDevicePage(PIXEL_8, BASE_URL, 'chromium')); + inputLog = []; + resizeLog = []; + await page.exposeFunction('__recordMobileNavigationInput', (input: string) => { + inputLog.push(input); + }); + await page.exposeFunction('__recordMobileNavigationResize', (options: Record) => { + resizeLog.push(options); + }); + }); + + beforeEach(async () => { + inputLog.length = 0; + resizeLog.length = 0; + await page.evaluate(` + document.getElementById('appSettingsModal')?.classList.remove('active'); + document.body.classList.remove('keyboard-visible', 'keyboard-opening'); + KeyboardHandler.keyboardVisible = false; + KeyboardHandler._terminalInputRequested = false; + clearTimeout(KeyboardHandler._keyboardOpeningTimer); + KeyboardHandler._keyboardOpeningTimer = null; + KeyboardHandler._discardTerminalFrameCover?.(); + app.activeWebviewId = null; + app.sendResize = function(_sessionId, options = {}) { + window.__recordMobileNavigationResize(options); + return Promise.resolve(true); + }; + app._sendInputAsync = function(_sessionId, input) { + window.__recordMobileNavigationInput(input); + }; + app.activeSessionId = ${JSON.stringify(SESSION_ID)}; + app.hideWelcome(); + MobileTerminalControls.setEnabled(true); + `); + }); + + afterAll(async () => { + await context?.close(); + await closeAllBrowsers(); + if (server) await stopTestServer(server); + if (previousDataDir === undefined) delete process.env.CODEMAN_DATA_DIR; + else process.env.CODEMAN_DATA_DIR = previousDataDir; + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + tmuxAvailableSpy.mockRestore(); + }); + + it('fits above the toolbar with accessible touch targets', async () => { + const navigation = page.locator(SELECTORS.MOBILE_NAVIGATION); + expect(await navigation.isVisible()).toBe(true); + expect(await navigation.getAttribute('aria-hidden')).toBe('false'); + + const navBox = await navigation.boundingBox(); + const toolbarBox = await page.locator(SELECTORS.TOOLBAR).boundingBox(); + const terminalBox = await page.locator(SELECTORS.TERMINAL_CONTAINER).boundingBox(); + expect(navBox).not.toBeNull(); + expect(toolbarBox).not.toBeNull(); + expect(terminalBox).not.toBeNull(); + expect((terminalBox?.y ?? 0) + (terminalBox?.height ?? 0)).toBeLessThanOrEqual((navBox?.y ?? 0) + 1); + expect((navBox?.y ?? 0) + (navBox?.height ?? 0)).toBeLessThanOrEqual((toolbarBox?.y ?? 0) + 1); + expect(navBox?.height).toBe(48); + + expect(await navigation.locator('button').count()).toBe(6); + expect(await navigation.locator('[data-nav-key="jump-bottom"]').isHidden()).toBe(true); + const buttons = navigation.locator('button:not([data-nav-key="jump-bottom"])'); + const buttonBoxes = []; + for (let index = 0; index < 5; index++) { + const box = await buttons.nth(index).boundingBox(); + buttonBoxes.push(box); + expect(box?.width ?? 0).toBeGreaterThanOrEqual(48); + expect(box?.height).toBe(48); + expect(Math.abs((box?.y ?? 0) - (navBox?.y ?? 0))).toBeLessThanOrEqual(1); + } + + const [escapeBox, upBox, , downBox, tabBox] = buttonBoxes; + expect((escapeBox?.x ?? 0) - (navBox?.x ?? 0)).toBeLessThanOrEqual(12); + expect((navBox?.x ?? 0) + (navBox?.width ?? 0) - ((tabBox?.x ?? 0) + (tabBox?.width ?? 0))).toBeLessThanOrEqual(12); + expect((upBox?.x ?? 0) - ((escapeBox?.x ?? 0) + (escapeBox?.width ?? 0))).toBeGreaterThanOrEqual(24); + expect((tabBox?.x ?? 0) - ((downBox?.x ?? 0) + (downBox?.width ?? 0))).toBeGreaterThanOrEqual(24); + const navigationCenter = (navBox?.x ?? 0) + (navBox?.width ?? 0) / 2; + const directionalCenter = (upBox?.x ?? 0) + ((downBox?.x ?? 0) + (downBox?.width ?? 0) - (upBox?.x ?? 0)) / 2; + expect(Math.abs(navigationCenter - directionalCenter)).toBeLessThanOrEqual(1); + + const layerStyles = await page.evaluate(() => { + const main = document.querySelector('.main') as HTMLElement; + const terminal = document.getElementById('terminalContainer') as HTMLElement; + const navigation = document.querySelector('.mobile-terminal-nav') as HTMLElement; + return { + mainPadding: parseFloat(getComputedStyle(main).paddingBottom), + mainBackground: getComputedStyle(main).backgroundColor, + terminalBackground: getComputedStyle(terminal).backgroundColor, + navigationBackground: getComputedStyle(navigation).backgroundColor, + }; + }); + expect(layerStyles.mainPadding).toBe(88); + expect(layerStyles.mainBackground).toBe(layerStyles.terminalBackground); + expect(layerStyles.navigationBackground).toBe(layerStyles.terminalBackground); + + if (process.env.CODEMAN_NAV_SCREENSHOT) { + await page.evaluate(` + app.terminal?.write( + '\\x1b[2J\\x1b[H' + + 'Select a dispatched agent\\r\\n\\r\\n' + + ' Agent 1 - research\\r\\n' + + ' Agent 2 - implementation\\r\\n' + + '> Agent 3 - review\\r\\n\\r\\n' + + 'Use arrow keys and Enter' + ); + `); + await page.waitForTimeout(100); + await page.screenshot({ path: process.env.CODEMAN_NAV_SCREENSHOT }); + } + }); + + it('hides terminal controls over web tabs and restores the already-active terminal', async () => { + await page.evaluate(() => { + app.activeWebviewId = 'mobile-webview-test'; + document.querySelector('.main')?.classList.add('webview-active'); + MobileTerminalControls.syncVisibility(); + }); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(false); + + await page.evaluate(async (sessionId) => { + MobileTerminalControls.sendKey('up'); + await app.selectSession(sessionId); + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + }, SESSION_ID); + + expect(inputLog).toEqual([]); + expect( + await page.evaluate(() => ({ + activeWebviewId: app.activeWebviewId, + webviewActive: document.querySelector('.main')?.classList.contains('webview-active'), + })) + ).toEqual({ + activeWebviewId: null, + webviewActive: false, + }); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(true); + expect(resizeLog).toEqual([ + expect.objectContaining({ + force: true, + takeControl: true, + }), + ]); + }); + + it('shows jump-to-latest above the three central controls only while reading history', async () => { + const navigation = page.locator(SELECTORS.MOBILE_NAVIGATION); + const jump = navigation.locator('[data-nav-key="jump-bottom"]'); + + await page.evaluate(async () => { + app.terminal.reset(); + const lines = Array.from({ length: app.terminal.rows + 24 }, (_, index) => `history line ${index + 1}`).join( + '\r\n' + ); + await new Promise((resolve) => app.terminal.write(lines, resolve)); + }); + expect(await jump.isHidden()).toBe(true); + + await page.evaluate(() => app._scrollTerminalLines(-8)); + await page.waitForFunction( + () => !(document.querySelector('[data-nav-key="jump-bottom"]') as HTMLButtonElement)?.hidden + ); + + const navBox = await navigation.boundingBox(); + const jumpBox = await jump.boundingBox(); + const upBox = await navigation.locator('[data-nav-key="up"]').boundingBox(); + const downBox = await navigation.locator('[data-nav-key="down"]').boundingBox(); + expect(navBox).not.toBeNull(); + expect(jumpBox).not.toBeNull(); + expect(jumpBox?.height).toBeGreaterThanOrEqual(44); + expect((jumpBox?.y ?? 0) + (jumpBox?.height ?? 0)).toBeLessThanOrEqual((navBox?.y ?? 0) - 4); + + const navigationCenter = (navBox?.x ?? 0) + (navBox?.width ?? 0) / 2; + const centralControlsCenter = (upBox?.x ?? 0) + ((downBox?.x ?? 0) + (downBox?.width ?? 0) - (upBox?.x ?? 0)) / 2; + const jumpCenter = (jumpBox?.x ?? 0) + (jumpBox?.width ?? 0) / 2; + expect(Math.abs(navigationCenter - centralControlsCenter)).toBeLessThanOrEqual(1); + expect(Math.abs(jumpCenter - centralControlsCenter)).toBeLessThanOrEqual(1); + + await jump.click(); + await page.waitForFunction(() => app.isTerminalAtBottom()); + expect(await jump.isHidden()).toBe(true); + }); + + it('sends raw Up without focusing xterm or showing the keyboard', async () => { + await page.evaluate(` + app.terminal.focus(); + KeyboardHandler.keyboardVisible = false; + document.body.classList.remove('keyboard-visible'); + MobileNavigationPad.syncVisibility(); + `); + const focusedBefore = await page.evaluate( + () => document.activeElement?.classList.contains('xterm-helper-textarea') ?? false + ); + expect(focusedBefore).toBe(true); + + const viewportHeight = await page.evaluate(() => window.visualViewport?.height); + const up = page.locator('[data-nav-key="up"]'); + const box = await up.boundingBox(); + expect(box).not.toBeNull(); + + const cdp = await getCDP(page); + await dispatchTouchEvent(cdp, 'touchStart', [ + { x: (box?.x ?? 0) + (box?.width ?? 0) / 2, y: (box?.y ?? 0) + (box?.height ?? 0) / 2 }, + ]); + await dispatchTouchEvent(cdp, 'touchEnd', []); + await vi.waitFor(() => { + expect(inputLog).toEqual(['\x1b[A']); + }); + expect(resizeLog).toEqual([ + expect.objectContaining({ + takeControl: true, + refit: true, + }), + ]); + const state = await page.evaluate(` + ({ + keyboardVisible: KeyboardHandler.keyboardVisible, + viewportHeight: window.visualViewport?.height, + activeTag: document.activeElement?.tagName, + activeClass: document.activeElement?.className || '' + }) + `); + expect(state.keyboardVisible).toBe(false); + expect(state.viewportHeight).toBe(viewportHeight); + expect(state.activeTag).not.toBe('TEXTAREA'); + expect(state.activeClass).not.toContain('xterm-helper-textarea'); + }); + + it('sends raw Escape and Tab without opening the keyboard', async () => { + const viewportHeight = await page.evaluate(() => window.visualViewport?.height); + + await page.locator('[data-nav-key="esc"]').click(); + await page.locator('[data-nav-key="tab"]').click(); + await vi.waitFor(() => { + expect(inputLog).toEqual(['\x1b', '\t']); + }); + + const state = await page.evaluate(`({ + keyboardVisible: KeyboardHandler.keyboardVisible, + viewportHeight: window.visualViewport?.height, + terminalFocused: document.activeElement?.classList.contains('xterm-helper-textarea') || false + })`); + expect(state.keyboardVisible).toBe(false); + expect(state.viewportHeight).toBe(viewportHeight); + expect(state.terminalFocused).toBe(false); + }); + + it('maps Up+Down to one Enter and supports swipes on the surrounding band', async () => { + await page.evaluate(` + (function() { + const nav = document.querySelector(${JSON.stringify(SELECTORS.MOBILE_NAVIGATION)}); + const up = nav.querySelector('[data-nav-key="up"]'); + const down = nav.querySelector('[data-nav-key="down"]'); + const emit = (target, type, pointerId, clientY = 0) => { + target.dispatchEvent(new PointerEvent(type, { + bubbles: true, + cancelable: true, + pointerId, + pointerType: 'touch', + isPrimary: pointerId === 1, + clientY + })); + }; + + emit(up, 'pointerdown', 1); + emit(down, 'pointerdown', 2); + emit(up, 'pointerup', 1); + emit(down, 'pointerup', 2); + })() + `); + await page.waitForTimeout(50); + expect(inputLog).toEqual(['\r']); + + inputLog.length = 0; + await page.evaluate(` + (function() { + const nav = document.querySelector(${JSON.stringify(SELECTORS.MOBILE_NAVIGATION)}); + const rect = nav.getBoundingClientRect(); + const x = rect.left + 12; + nav.dispatchEvent(new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + pointerId: 3, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: rect.bottom - 4 + })); + nav.dispatchEvent(new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + pointerId: 3, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: rect.top + 4 + })); + })() + `); + await page.waitForTimeout(50); + expect(inputLog).toEqual(['\x1b[A']); + }); + + it('uses volume-key DOM events when the browser exposes them', async () => { + await page.evaluate(` + document.body.dispatchEvent(new KeyboardEvent('keydown', { + key: 'AudioVolumeDown', + code: 'AudioVolumeDown', + bubbles: true, + cancelable: true + })); + document.body.dispatchEvent(new KeyboardEvent('keyup', { + key: 'AudioVolumeDown', + code: 'AudioVolumeDown', + bubbles: true, + cancelable: true + })); + `); + await page.waitForTimeout(50); + expect(inputLog).toEqual(['\x1b[B']); + + inputLog.length = 0; + await page.evaluate(` + for (const [type, key] of [ + ['keydown', 'AudioVolumeUp'], + ['keydown', 'AudioVolumeDown'], + ['keyup', 'AudioVolumeUp'], + ['keyup', 'AudioVolumeDown'] + ]) { + document.body.dispatchEvent(new KeyboardEvent(type, { + key, + code: key, + bubbles: true, + cancelable: true + })); + } + `); + await page.waitForTimeout(50); + expect(inputLog).toEqual(['\r']); + }); + + it('is controlled from the mobile App Settings Input options', async () => { + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(true); + expect(await page.locator('.btn-toolbar.btn-enter').isVisible()).toBe(false); + + await page.evaluate(`app.openAppSettings()`); + const settingRow = page.locator('#appSettingsMobileTerminalControlsItem'); + const setting = page.locator('#appSettingsMobileTerminalControls'); + const settingSlider = settingRow.locator('.slider'); + const hapticsRow = page.locator('#appSettingsMobileControlHapticsItem'); + const haptics = hapticsRow.locator('#appSettingsMobileControlHaptics'); + const soundRow = page.locator('#appSettingsMobileControlSoundItem'); + const sound = soundRow.locator('#appSettingsMobileControlSound'); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(false); + expect(await settingRow.isVisible()).toBe(true); + expect(await settingSlider.isVisible()).toBe(true); + expect(await setting.isChecked()).toBe(false); + expect(await hapticsRow.isVisible()).toBe(true); + expect(await hapticsRow.locator('.slider').isVisible()).toBe(true); + expect(await haptics.isChecked()).toBe(false); + expect(await soundRow.isVisible()).toBe(true); + expect(await soundRow.locator('.slider').isVisible()).toBe(true); + expect(await sound.isChecked()).toBe(false); + expect(await settingRow.locator('.settings-item-label').textContent()).toBe('Mobile Terminal Controls'); + expect(await page.locator('#appSettingsExtendedKeyboardBar').count()).toBe(0); + if (process.env.CODEMAN_NAV_MODAL_SCREENSHOT) { + await page.screenshot({ path: process.env.CODEMAN_NAV_MODAL_SCREENSHOT }); + } + + await settingSlider.click(); + expect(await setting.isChecked()).toBe(true); + await page.evaluate(`app.saveAppSettings()`); + await page.waitForFunction(() => { + const saved = JSON.parse(localStorage.getItem('codeman-app-settings-mobile') || '{}'); + return saved.mobileTerminalControlsEnabled === true; + }); + expect( + await page.evaluate( + `MobileTerminalControls.enabled === true && + MobileNavigationPad.enabled === true && + KeyboardAccessoryBar.enabled === true` + ) + ).toBe(true); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(true); + expect(await page.locator('.btn-toolbar.btn-enter').isVisible()).toBe(false); + + await page.evaluate(`app.openAppSettings()`); + expect(await setting.isChecked()).toBe(true); + await settingSlider.click(); + expect(await setting.isChecked()).toBe(false); + await page.evaluate(`app.saveAppSettings()`); + await page.waitForFunction(() => { + const saved = JSON.parse(localStorage.getItem('codeman-app-settings-mobile') || '{}'); + return saved.mobileTerminalControlsEnabled === false; + }); + expect( + await page.evaluate( + `MobileTerminalControls.enabled === false && + MobileNavigationPad.enabled === false && + KeyboardAccessoryBar.enabled === false` + ) + ).toBe(true); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(false); + expect(await page.locator('.btn-toolbar.btn-enter').isVisible()).toBe(true); + }); + + it('switches to the extended accessory bar while the phone keyboard is visible', async () => { + const viewportHeight = await page.evaluate(() => window.visualViewport?.height); + await page.evaluate(` + KeyboardHandler.keyboardVisible = true; + KeyboardHandler._terminalInputRequested = true; + document.body.classList.add('keyboard-visible'); + KeyboardHandler.onKeyboardShow(); + `); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(false); + expect(await page.locator(SELECTORS.KEYBOARD_ACCESSORY).isVisible()).toBe(true); + expect(await page.locator('.keyboard-accessory-bar [data-action="arrow-left"]').isVisible()).toBe(true); + expect(await page.locator('.toolbar').isVisible()).toBe(false); + const keyboardLayout = await page.evaluate(() => { + const appEl = document.querySelector('.app') as HTMLElement; + const main = document.querySelector('.main') as HTMLElement; + const terminal = document.getElementById('terminalContainer') as HTMLElement; + const accessory = document.querySelector('.keyboard-accessory-bar') as HTMLElement; + const appBox = appEl.getBoundingClientRect(); + const terminalBox = terminal.getBoundingClientRect(); + const accessoryBox = accessory.getBoundingClientRect(); + return { + mainPadding: parseFloat(getComputedStyle(main).paddingBottom), + appBottom: appBox.bottom, + terminalBottom: terminalBox.bottom, + accessoryTop: accessoryBox.top, + accessoryBottom: accessoryBox.bottom, + accessoryScrollLeft: accessory.scrollLeft, + primaryButtonsContained: [ + 'esc', + 'arrow-left', + 'scroll-up', + 'opt-enter', + 'scroll-down', + 'arrow-right', + 'tab', + ].every((action) => { + const button = accessory.querySelector(`[data-action="${action}"]`); + if (!(button instanceof HTMLElement)) return false; + const box = button.getBoundingClientRect(); + return box.left >= accessoryBox.left - 1 && box.right <= accessoryBox.right + 1; + }), + }; + }); + expect(keyboardLayout.mainPadding).toBeGreaterThan(0); + expect(keyboardLayout.terminalBottom).toBeLessThanOrEqual(keyboardLayout.accessoryTop + 1); + expect(Math.abs(keyboardLayout.accessoryBottom - keyboardLayout.appBottom)).toBeLessThanOrEqual(1); + expect(keyboardLayout.accessoryScrollLeft).toBe(0); + expect(keyboardLayout.primaryButtonsContained).toBe(true); + + await vi.waitFor(() => { + expect(resizeLog).toContainEqual( + expect.objectContaining({ + takeControl: true, + refit: false, + }) + ); + }); + resizeLog.length = 0; + inputLog.length = 0; + await page.locator('.keyboard-accessory-bar [data-action="arrow-left"]').click(); + await vi.waitFor(() => { + expect(inputLog).toEqual(['\x1b[D']); + }); + expect(resizeLog).toEqual([ + expect.objectContaining({ + takeControl: true, + refit: false, + }), + ]); + const afterKey = await page.evaluate(`({ + keyboardVisible: KeyboardHandler.keyboardVisible, + viewportHeight: window.visualViewport?.height, + terminalFocused: document.activeElement?.classList.contains('xterm-helper-textarea') || false + })`); + expect(afterKey.keyboardVisible).toBe(true); + expect(afterKey.viewportHeight).toBe(viewportHeight); + expect(afterKey.terminalFocused).toBe(true); + + await page.evaluate(` + KeyboardHandler.keyboardVisible = false; + document.body.classList.remove('keyboard-visible'); + KeyboardHandler.onKeyboardHide(); + `); + await page.waitForTimeout(KEYBOARD.ANIMATION_DELAY); + expect(await page.locator(SELECTORS.KEYBOARD_ACCESSORY).isVisible()).toBe(false); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(true); + expect(await page.locator('.toolbar').isVisible()).toBe(true); + }); + + it('removes the keyboard-open reservation when mobile controls are disabled', async () => { + await page.evaluate(` + MobileTerminalControls.setEnabled(false); + KeyboardHandler.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); + KeyboardHandler.onKeyboardShow(); + `); + + expect(await page.locator(SELECTORS.KEYBOARD_ACCESSORY).isVisible()).toBe(false); + const layout = await page.evaluate(() => { + const appBox = document.querySelector('.app')!.getBoundingClientRect(); + const terminalBox = document.getElementById('terminalContainer')!.getBoundingClientRect(); + return { + hasReservation: document.body.classList.contains('keyboard-accessory-visible'), + mainPadding: parseFloat(getComputedStyle(document.querySelector('.main')!).paddingBottom), + appBottom: appBox.bottom, + terminalBottom: terminalBox.bottom, + }; + }); + expect(layout.hasReservation).toBe(false); + expect(layout.mainPadding).toBe(0); + expect(Math.abs(layout.terminalBottom - layout.appBottom)).toBeLessThanOrEqual(1); + }); + + it('removes both keyboard control surfaces while a dialog is open', async () => { + await page.evaluate(` + KeyboardHandler.keyboardVisible = true; + document.body.classList.add('keyboard-visible'); + KeyboardHandler.onKeyboardShow(); + `); + expect(await page.locator(SELECTORS.KEYBOARD_ACCESSORY).isVisible()).toBe(true); + + await page.evaluate(`app.openAppSettings()`); + await page.waitForFunction(() => !document.body.classList.contains('keyboard-accessory-visible')); + expect(await page.locator(SELECTORS.KEYBOARD_ACCESSORY).isVisible()).toBe(false); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(false); + expect( + await page.evaluate(() => parseFloat(getComputedStyle(document.querySelector('.main')!).paddingBottom)) + ).toBe(0); + + await page.evaluate(`app.closeAppSettings()`); + await page.waitForFunction(() => document.body.classList.contains('keyboard-accessory-visible')); + expect(await page.locator(SELECTORS.KEYBOARD_ACCESSORY).isVisible()).toBe(true); + }); + + it('removes the navigation-band reservation during an incremental keyboard animation', async () => { + const state = await page.evaluate(`(function() { + var vv = window.visualViewport; + var baseline = KeyboardHandler.initialViewportHeight; + Object.defineProperty(window, 'innerHeight', { + get: function() { return baseline; }, + configurable: true, + }); + + for (const shrink of [70, 140, 210]) { + Object.defineProperty(vv, 'height', { + get: function() { return baseline - shrink; }, + configurable: true, + }); + KeyboardHandler.handleViewportResize(); + } + + return { + keyboardVisible: KeyboardHandler.keyboardVisible, + keyboardClass: document.body.classList.contains('keyboard-visible'), + navigationClass: document.body.classList.contains('mobile-nav-visible'), + mainPadding: parseFloat(getComputedStyle(document.querySelector('.main')).paddingBottom), + toolbarDisplay: getComputedStyle(document.querySelector('.toolbar')).display, + accessoryDisplay: getComputedStyle(document.querySelector('.keyboard-accessory-bar')).display, + toolbarTransform: document.querySelector('.toolbar').style.transform, + accessoryTransform: document.querySelector('.keyboard-accessory-bar').style.transform, + appBottom: document.querySelector('.app').getBoundingClientRect().bottom, + terminalBottom: document.getElementById('terminalContainer').getBoundingClientRect().bottom, + accessoryTop: document.querySelector('.keyboard-accessory-bar').getBoundingClientRect().top, + accessoryBottom: document.querySelector('.keyboard-accessory-bar').getBoundingClientRect().bottom, + }; + })()`); + + expect(state.keyboardVisible).toBe(true); + expect(state.keyboardClass).toBe(true); + expect(state.navigationClass).toBe(false); + expect(state.mainPadding).toBeGreaterThan(0); + expect(state.toolbarDisplay).toBe('none'); + expect(state.accessoryDisplay).toBe('flex'); + expect(state.toolbarTransform).toBe(''); + expect(state.accessoryTransform).toBe(''); + expect(state.terminalBottom).toBeLessThanOrEqual(state.accessoryTop + 1); + expect(Math.abs(state.accessoryBottom - state.appBottom)).toBeLessThanOrEqual(1); + expect(await page.locator(SELECTORS.MOBILE_NAVIGATION).isVisible()).toBe(false); + }); +}); diff --git a/test/mobile/settings.test.ts b/test/mobile/settings.test.ts index 5d6282b2..856933b6 100644 --- a/test/mobile/settings.test.ts +++ b/test/mobile/settings.test.ts @@ -60,7 +60,7 @@ describe('Settings Modal', () => { } }); - it('mobile defaults: all panels hidden, subagent tracking OFF, ralph OFF', async () => { + it('mobile defaults: panels hidden, subagent tracking ON, ralph OFF', async () => { const defaults = await page.evaluate(() => { const fn = (window as any).app?.getDefaultSettings; if (fn) return fn.call((window as any).app); @@ -76,8 +76,11 @@ describe('Settings Modal', () => { expect(defaults.showProjectInsights).toBe(false); expect(defaults.showFileBrowser).toBe(false); expect(defaults.showSubagents).toBe(false); - expect(defaults.subagentTrackingEnabled).toBe(false); + expect(defaults.subagentTrackingEnabled).toBe(true); expect(defaults.ralphTrackerEnabled).toBe(false); + expect(defaults.mobileTerminalControlsEnabled).toBe(false); + expect(defaults.mobileControlHaptics).toBe(false); + expect(defaults.mobileControlSound).toBe(false); } }); @@ -182,6 +185,50 @@ describe('Settings Modal', () => { } }); + it('opens the full option description without toggling the setting', async () => { + await openSettingsModal(); + + const item = page.locator('#appSettingsMobileTerminalControlsItem'); + const trigger = item.locator('.settings-item-text'); + const checkbox = page.locator('#appSettingsMobileTerminalControls'); + const checkedBefore = await checkbox.isChecked(); + + await trigger.click(); + await assertVisible(page, '#settingsDescriptionLayer'); + + const description = await page.locator('#settingsDescriptionText').textContent(); + const accessibility = await trigger.evaluate((element) => ({ + role: element.getAttribute('role'), + tabIndex: element.getAttribute('tabindex'), + hasPopup: element.getAttribute('aria-haspopup'), + controls: element.getAttribute('aria-controls'), + })); + + expect(await page.locator('#settingsDescriptionTitle').textContent()).toBe('Mobile Terminal Controls'); + expect(description).toContain('Hardware volume keys are used when the browser exposes them'); + expect(await checkbox.isChecked()).toBe(checkedBefore); + expect(accessibility).toEqual({ + role: 'button', + tabIndex: '0', + hasPopup: 'dialog', + controls: 'settingsDescriptionLayer', + }); + + await page.locator('.settings-description-backdrop').click({ position: { x: 8, y: 8 } }); + await assertHidden(page, '#settingsDescriptionLayer'); + + await item.locator('.switch').click(); + expect(await checkbox.isChecked()).toBe(!checkedBefore); + await assertHidden(page, '#settingsDescriptionLayer'); + + await page.locator('.modal-tab-btn[data-tab="settings-notifications"]').click(); + await page.locator('#settings-notifications .settings-grid-3col .settings-item-label').first().click(); + await assertVisible(page, '#settingsDescriptionLayer'); + expect(await page.locator('#settingsDescriptionTitle').textContent()).toBe('Critical'); + expect(await page.locator('#settingsDescriptionText').textContent()).toBe('Errors, crashes, agent failures'); + await page.locator('#settingsDescriptionClose').click(); + }); + it('modal body adjusts height when keyboard visible', async () => { await openSettingsModal(); const modalBody = page.locator(SELECTORS.SETTINGS_MODAL_BODY).first(); @@ -305,7 +352,6 @@ describe('Settings Modal', () => { JSON.stringify({ showFontControls: true, showMonitor: true, - subagentTrackingEnabled: true, }) ); }, STORAGE_KEYS.SETTINGS_MOBILE); @@ -323,7 +369,6 @@ describe('Settings Modal', () => { expect(settings).not.toBeNull(); expect(settings?.showFontControls).toBe(true); expect(settings?.showMonitor).toBe(true); - expect(settings?.subagentTrackingEnabled).toBe(true); } finally { await context.close(); } @@ -414,7 +459,7 @@ describe('Settings Modal', () => { key, JSON.stringify({ showResponseViewer: true, - extendedKeyboardBar: true, + mobileTerminalControlsEnabled: true, }) ); }, STORAGE_KEYS.SETTINGS_MOBILE); @@ -434,16 +479,60 @@ describe('Settings Modal', () => { responseViewerVisible: !document .querySelector('.btn-response-viewer-header') ?.classList.contains('btn-response-viewer-header--hidden'), - keyboardExtended: Boolean(document.querySelector('.keyboard-accessory-bar [data-action="arrow-left"]')), })); expect(state.deviceType).toBe('desktop'); expect(state.handheld).toBe(true); expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE); expect(state.responseViewerVisible).toBe(true); - expect(state.keyboardExtended).toBe(true); + expect(await page.evaluate('MobileTerminalControls.enabled')).toBe(true); + + await page.evaluate(() => (window as any).app.openAppSettings()); + await assertVisible(page, '#appSettingsMobileTerminalControlsItem'); + expect(await page.locator('#appSettingsMobileTerminalControls').isChecked()).toBe(true); + await page.evaluate(() => (window as any).app.closeAppSettings()); - await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT); + await page.evaluate(` + app.activeSessionId = 'foldable-controls-test'; + app.hideWelcome(); + MobileTerminalControls.syncVisibility(); + `); + + await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT, { preferredLayer: 'dom' }); + await assertVisible(page, '.keyboard-accessory-bar'); + const alignment = await page.evaluate(() => { + const appBox = document.querySelector('.app')!.getBoundingClientRect(); + const accessoryBox = document.querySelector('.keyboard-accessory-bar')!.getBoundingClientRect(); + return Math.abs(accessoryBox.bottom - appBox.bottom); + }); + expect(alignment).toBeLessThanOrEqual(1); + } finally { + await context.close(); + } + }); + + it('keeps unified controls opt-in on touch tablets', async () => { + const device = REPRESENTATIVE_DEVICES['standard-tablet']; + const { page, context } = await createDevicePage(device, BASE_URL, 'chromium'); + + try { + expect(await page.evaluate('MobileTerminalControls.enabled')).toBe(false); + + await page.evaluate(() => (window as any).app.openAppSettings()); + await assertVisible(page, '#appSettingsMobileTerminalControlsItem'); + expect(await page.locator('#appSettingsMobileTerminalControls').isChecked()).toBe(false); + await page.evaluate(() => (window as any).app.closeAppSettings()); + + await page.evaluate(` + const settings = app.loadAppSettingsFromStorage(); + settings.mobileTerminalControlsEnabled = true; + app.saveAppSettingsToStorage(settings); + MobileTerminalControls.setEnabled(true); + app.activeSessionId = 'tablet-controls-test'; + app.hideWelcome(); + MobileTerminalControls.syncVisibility(); + `); + await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT, { preferredLayer: 'dom' }); await assertVisible(page, '.keyboard-accessory-bar'); } finally { await context.close(); diff --git a/test/mobile/setup.ts b/test/mobile/setup.ts new file mode 100644 index 00000000..47173624 --- /dev/null +++ b/test/mobile/setup.ts @@ -0,0 +1,17 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll } from 'vitest'; + +const previousDataDir = process.env.CODEMAN_DATA_DIR; +const dataDir = mkdtempSync(join(tmpdir(), 'codeman-mobile-test-')); + +// Setup files run before each test module is imported, so module-level dataPath() +// constants resolve inside this disposable directory instead of ~/.codeman. +process.env.CODEMAN_DATA_DIR = dataDir; + +afterAll(() => { + if (previousDataDir === undefined) delete process.env.CODEMAN_DATA_DIR; + else process.env.CODEMAN_DATA_DIR = previousDataDir; + rmSync(dataDir, { recursive: true, force: true }); +}); diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts index 82aba528..5de434ca 100644 --- a/test/mobile/tabs.test.ts +++ b/test/mobile/tabs.test.ts @@ -556,7 +556,7 @@ describe('Tab Navigation', () => { if (typeof KeyboardHandler !== 'undefined') KeyboardHandler.keyboardVisible = false; let selected: string | null = null; - let selectedOptions: { preserveKeyboard?: boolean } | null = null; + let selectedOptions: { forceReload?: boolean; preserveKeyboard?: boolean } | null = null; const originalSelect = app.selectSession; app.selectSession = function (id, options) { selected = id; @@ -573,6 +573,7 @@ describe('Tab Navigation', () => { return { hasHandler: typeof app.handleSessionTabClick === 'function', selected: selected, + forceReload: selectedOptions ? selectedOptions.forceReload : undefined, preserveKeyboard: selectedOptions ? selectedOptions.preserveKeyboard : undefined, activeIsTextarea: activeIsTextarea, }; @@ -580,7 +581,8 @@ describe('Tab Navigation', () => { expect(result.hasHandler).toBe(true); expect(result.selected).toBe('mock-session-2'); - expect(result.preserveKeyboard).toBe(false); + expect(result.forceReload).toBe(true); + expect(result.preserveKeyboard).toBeUndefined(); expect(result.activeIsTextarea).toBe(false); } finally { await context.close(); diff --git a/test/mobile/vitest.config.ts b/test/mobile/vitest.config.ts index 169bd633..a5ed8e9e 100644 --- a/test/mobile/vitest.config.ts +++ b/test/mobile/vitest.config.ts @@ -9,9 +9,10 @@ export default defineConfig({ globals: true, environment: 'node', include: ['test/mobile/**/*.test.ts'], - setupFiles: ['./test/setup.ts'], + setupFiles: ['./test/setup.ts', './test/mobile/setup.ts'], fileParallelism: false, testTimeout: 60_000, + hookTimeout: 60_000, teardownTimeout: 60_000, }, }); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 14aa1ea1..9ee0940f 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -106,6 +106,7 @@ export function createMockRouteContext(options?: { sessionId?: string }) { listSessions: vi.fn(() => []), getStats: vi.fn(() => ({})), updateSessionName: vi.fn(() => true), + updateSessionWorkingDir: vi.fn(() => true), getSession: vi.fn(() => null), clearRespawnConfig: vi.fn(), updateRespawnConfig: vi.fn(), @@ -129,6 +130,13 @@ export function createMockRouteContext(options?: { sessionId?: string }) { orchestratorLoop: null, initOrchestratorLoop: vi.fn(), + // -- InstanceControlPort -- + requestInstanceShutdown: vi.fn(async () => ({ + accepted: true as const, + strategy: 'manual' as const, + alreadyScheduled: false, + })), + // Convenience accessors (not part of any port interface) _session: session, _sessionId: sessionId, diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index bda2077b..189fcd24 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -4,6 +4,7 @@ */ import { EventEmitter } from 'node:events'; import { vi } from 'vitest'; +import type { TerminalCursor } from '../../src/session.js'; /** * Enhanced mock session for testing RespawnController. @@ -19,6 +20,7 @@ export class MockSession extends EventEmitter { ralphTracker: null = null; writeBuffer: string[] = []; terminalBuffer: string = ''; + private _terminalGeneration = 0; private _muxName: string | null = null; @@ -68,8 +70,13 @@ export class MockSession extends EventEmitter { /** Simulate raw terminal output */ simulateTerminalOutput(data: string): void { + const start = this.terminalBuffer.length; this.terminalBuffer += data; - this.emit('terminal', data); + this.emit('terminal', data, { + ...this.terminalCursor, + start, + end: this.terminalBuffer.length, + }); } /** Simulate prompt appearing (legacy fallback signal) */ @@ -152,6 +159,7 @@ export class MockSession extends EventEmitter { /** Clear terminal buffer */ clearTerminalBuffer(): void { this.terminalBuffer = ''; + this._terminalGeneration++; } // ========== Session Lifecycle ========== @@ -188,6 +196,10 @@ export class MockSession extends EventEmitter { /** CLI mode */ mode: string = 'claude'; + /** External execution metadata used by route guards. */ + remote: Record | undefined; + docker: Record | undefined; + /** Text output buffer (stripped of ANSI) */ textOutput: string = ''; @@ -219,6 +231,15 @@ export class MockSession extends EventEmitter { return this.terminalBuffer.length; } + get terminalCursor(): TerminalCursor { + return { + stream: `mock-${this.id}`, + generation: this._terminalGeneration, + start: 0, + end: this.terminalBuffer.length, + }; + } + /** Return a state-like object for route handlers */ toState(): Record { return { @@ -228,6 +249,8 @@ export class MockSession extends EventEmitter { name: this.name, color: this.color, mode: this.mode, + remote: this.remote, + docker: this.docker, muxName: this._muxName, pinned: this.pinned || undefined, pinnedAt: this.pinned ? (this.pinnedAt ?? undefined) : undefined, @@ -259,6 +282,11 @@ export class MockSession extends EventEmitter { this.color = c; }); + /** Synchronize the session's authoritative working directory. */ + setWorkingDir = vi.fn((workingDir: string) => { + this.workingDir = workingDir; + }); + /** Stub for sendInput */ sendInput = vi.fn(); @@ -268,7 +296,6 @@ export class MockSession extends EventEmitter { /** Stubs for the desktop sizing claims used by resize arbitration */ claimDesktopSizing = vi.fn(); releaseDesktopSizing = vi.fn(); - noteDesktopActivity = vi.fn(); /** Stub for runPrompt */ runPrompt = vi.fn(async () => {}); diff --git a/test/notification-manager-noise.test.ts b/test/notification-manager-noise.test.ts new file mode 100644 index 00000000..d5fbf896 --- /dev/null +++ b/test/notification-manager-noise.test.ts @@ -0,0 +1,150 @@ +import { readFileSync } from 'node:fs'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SOURCE = readFileSync(new URL('../src/web/public/notification-manager.js', import.meta.url), 'utf8'); + +type EventPreference = { + enabled: boolean; + browser: boolean; + audio: boolean; + push: boolean; +}; + +type NotificationPreferences = { + enabled: boolean; + eventTypes: Record; + _version: number; +}; + +type Manager = { + preferences: NotificationPreferences; + notifications: unknown[]; + getStorageKey: () => string; + normalizePreferences: (preferences: Record) => NotificationPreferences; + notify: (notification: Record) => void; +}; + +const openWindows: JSDOM[] = []; + +function loadManager( + saved?: Record, + device: { deviceType?: string; handheld?: boolean } = {} +): { dom: JSDOM; manager: Manager } { + const dom = new JSDOM( + '
', + { + url: 'http://localhost/', + runScripts: 'outside-only', + } + ); + openWindows.push(dom); + const win = dom.window as unknown as Window & + typeof globalThis & { + MobileDetection: { + getDeviceType: () => string; + isHandheldDevice?: () => boolean; + }; + STUCK_THRESHOLD_DEFAULT_MS: number; + GROUPING_TIMEOUT_MS: number; + NOTIFICATION_LIST_CAP: number; + }; + win.MobileDetection = { + getDeviceType: () => device.deviceType ?? 'desktop', + ...(typeof device.handheld === 'boolean' ? { isHandheldDevice: () => device.handheld === true } : {}), + }; + win.STUCK_THRESHOLD_DEFAULT_MS = 600_000; + win.GROUPING_TIMEOUT_MS = 5_000; + win.NOTIFICATION_LIST_CAP = 100; + win.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof requestAnimationFrame; + + if (saved) { + win.localStorage.setItem('codeman-notification-prefs', JSON.stringify(saved)); + } + + win.eval(` + ${SOURCE} + window.__testNotificationManager = NotificationManager; + `); + const NotificationManager = ( + win as unknown as { + __testNotificationManager: new (app: { sessions: Map }) => Manager; + } + ).__testNotificationManager; + const manager = new NotificationManager({ sessions: new Map() }) as Manager; + return { dom, manager }; +} + +afterEach(() => { + for (const dom of openWindows.splice(0)) dom.window.close(); +}); + +describe('notification noise defaults', () => { + it('keeps response-complete and team lifecycle drawer entries opt-in', () => { + const { manager } = loadManager(); + expect(manager.preferences.eventTypes.stop.enabled).toBe(false); + + for (const category of ['hook-stop', 'hook-teammate-idle', 'hook-task-completed']) { + manager.notify({ + urgency: 'info', + category, + sessionId: 'session-1', + sessionName: 'session', + title: category, + message: category, + }); + } + + expect(manager.notifications).toHaveLength(0); + }); + + it('migrates the old drawer-only Stop default but preserves explicit delivery', () => { + const quietV4 = { + enabled: true, + eventTypes: { + stop: { enabled: true, browser: false, audio: false, push: false }, + }, + _version: 4, + }; + const { manager: quietManager } = loadManager(quietV4); + expect(quietManager.preferences.eventTypes.stop.enabled).toBe(false); + expect(quietManager.preferences._version).toBe(5); + + const browserV4 = { + enabled: true, + eventTypes: { + stop: { enabled: true, browser: true, audio: false, push: false }, + }, + _version: 4, + }; + const { manager: browserManager } = loadManager(browserV4); + expect(browserManager.preferences.eventTypes.stop.enabled).toBe(true); + }); + + it('normalizes server-hydrated v4 preferences through the same quiet migration', () => { + const { manager } = loadManager(); + manager.preferences = manager.normalizePreferences({ + enabled: true, + eventTypes: { + stop: { enabled: true, browser: false, audio: false, push: false }, + }, + _version: 4, + }); + + expect(manager.preferences.eventTypes.stop.enabled).toBe(false); + expect(manager.preferences._version).toBe(5); + }); + + it('keeps mobile notification defaults and storage on an unfolded handheld', () => { + const { manager } = loadManager(undefined, { + deviceType: 'desktop', + handheld: true, + }); + + expect(manager.preferences.enabled).toBe(false); + expect(manager.getStorageKey()).toBe('codeman-notification-prefs-mobile'); + }); +}); diff --git a/test/opencode-resize.test.ts b/test/opencode-resize.test.ts deleted file mode 100644 index af330777..00000000 --- a/test/opencode-resize.test.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * OpenCode session UI tests - * - * Tests OpenCode-specific UI behavior: - * - Initial terminal resize (not stuck at 120x40) - * - Close modal shows "Kill Tmux & OpenCode" (not "Claude Code") - * - needsRefresh handler sends resize - * - * Port: 3211 (opencode UI tests) - * - * Run: npx vitest run test/opencode-resize.test.ts - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; -import { WebServer } from '../src/web/server.js'; - -const PORT = 3211; -const BASE_URL = `http://localhost:${PORT}`; - -let server: WebServer; -let browser: Browser; - -async function freshPage(): Promise<{ context: BrowserContext; page: Page }> { - const context = await browser.newContext({ - viewport: { width: 1280, height: 800 }, - }); - const page = await context.newPage(); - return { context, page }; -} - -async function navigateAndWait(page: Page): Promise { - await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); - await page.waitForFunction(() => document.body.classList.contains('app-loaded'), { - timeout: 5000, - }); -} - -beforeAll(async () => { - server = new WebServer(PORT, false, true); // testMode - await server.start(); - browser = await chromium.launch({ headless: true }); -}, 30_000); - -afterAll(async () => { - await browser?.close(); - await server?.stop(); -}, 30_000); - -describe('OpenCode session initial resize', () => { - let context: BrowserContext; - let page: Page; - - afterAll(async () => { - await context?.close(); - }); - - it('selectSession is not bypassed when runOpenCode sets activeSessionId', async () => { - // This test verifies at the code level that runOpenCode does NOT - // pre-set activeSessionId before calling selectSession. - // If it did, selectSession would early-return and skip sendResize. - ({ context, page } = await freshPage()); - await navigateAndWait(page); - - // Read the runOpenCode source from the live app and verify - // it doesn't assign activeSessionId before selectSession - const hasPreAssignment = await page.evaluate(() => { - const app = (window as unknown as { app: { runOpenCode: { toString: () => string } } }).app; - const source = app.runOpenCode.toString(); - - // Check: the source should NOT have activeSessionId = ... before selectSession - // Find positions of both patterns - const assignIdx = source.indexOf('this.activeSessionId = data.sessionId'); - const selectIdx = source.indexOf('this.selectSession(data.sessionId)'); - - // If assign doesn't exist at all, that's the correct fix - if (assignIdx === -1) return false; - - // If assign comes before select, that's the bug - return assignIdx < selectIdx; - }); - - expect(hasPreAssignment).toBe(false); - }); - - it('sends resize to server after creating a session via quick-start', async () => { - ({ context, page } = await freshPage()); - await navigateAndWait(page); - - // Intercept resize API calls to track when they happen - const resizeCalls: Array<{ url: string; cols: number; rows: number }> = []; - await page.route('**/api/sessions/*/resize', async (route) => { - const request = route.request(); - const body = request.postDataJSON(); - resizeCalls.push({ - url: request.url(), - cols: body.cols, - rows: body.rows, - }); - // Let the request through to the server - await route.continue(); - }); - - // Create a session via API (simulating what quick-start does) - const sessionId = await page.evaluate(async () => { - const res = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp', name: 'oc-resize-test' }), - }); - const data = await res.json(); - return data.id ?? data.session?.id; - }); - - expect(sessionId).toBeTruthy(); - - // Call selectSession (which is what runOpenCode does after fix) - await page.evaluate(async (sid: string) => { - const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app; - await app.selectSession(sid); - }, sessionId); - - // Wait for the resize to be sent (it's fire-and-forget in selectSession) - await page.waitForTimeout(500); - - // Verify resize was called with reasonable dimensions (not 120x40 default) - expect(resizeCalls.length).toBeGreaterThanOrEqual(1); - const lastResize = resizeCalls[resizeCalls.length - 1]; - expect(lastResize.url).toContain(sessionId); - // Browser viewport is 1280x800 — terminal cols/rows should be substantially - // different from the hardcoded 120x40 default. xterm.js calculates these - // from container dimensions and cell size, but in headless mode with a - // 1280x800 viewport, we should get something reasonable (>= 40 cols). - expect(lastResize.cols).toBeGreaterThanOrEqual(40); - expect(lastResize.rows).toBeGreaterThanOrEqual(10); - - console.log(`[opencode-resize] resize sent: ${lastResize.cols}x${lastResize.rows}`); - - // Cleanup - await page.evaluate(async (sid: string) => { - await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); - }, sessionId); - }); - - it('selectSession does NOT early-return for a new session', async () => { - ({ context, page } = await freshPage()); - await navigateAndWait(page); - - // Create a session - const sessionId = await page.evaluate(async () => { - const res = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp', name: 'oc-earlyret-test' }), - }); - const data = await res.json(); - return data.id ?? data.session?.id; - }); - - expect(sessionId).toBeTruthy(); - - // Verify activeSessionId is NOT the new session before selectSession - const activeBeforeSelect = await page.evaluate(() => { - const app = (window as unknown as { app: { activeSessionId: string | null } }).app; - return app.activeSessionId; - }); - - // activeSessionId should be null or empty (welcome screen) — not our session - expect(activeBeforeSelect).not.toBe(sessionId); - - // Now call selectSession and verify it actually runs (sets activeSessionId) - await page.evaluate(async (sid: string) => { - const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app; - await app.selectSession(sid); - }, sessionId); - - const activeAfterSelect = await page.evaluate(() => { - const app = (window as unknown as { app: { activeSessionId: string | null } }).app; - return app.activeSessionId; - }); - - expect(activeAfterSelect).toBe(sessionId); - - // Cleanup - await page.evaluate(async (sid: string) => { - await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); - }, sessionId); - }); - - it('needsRefresh handler includes sendResize call', async () => { - // The needsRefresh handler is registered inside a closure (connectSSE), - // so we can't directly invoke it from tests. Instead, verify that the - // handler source code dispatched to the EventSource includes sendResize. - // This is a structural test — if the handler code changes, this test - // ensures the resize call is preserved. - ({ context, page } = await freshPage()); - await navigateAndWait(page); - - // Dispatch a needsRefresh event on the EventSource and intercept - // the resulting resize API call - const sessionId = await page.evaluate(async () => { - const res = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp', name: 'oc-refresh-test' }), - }); - const data = await res.json(); - return data.id ?? data.session?.id; - }); - - expect(sessionId).toBeTruthy(); - - // Select the session first so activeSessionId is set - await page.evaluate(async (sid: string) => { - const app = (window as unknown as { app: { selectSession: (id: string) => Promise } }).app; - await app.selectSession(sid); - }, sessionId); - - await page.waitForTimeout(300); - - // Intercept resize calls - const resizeCalls: Array<{ url: string }> = []; - await page.route('**/api/sessions/*/resize', async (route) => { - resizeCalls.push({ url: route.request().url() }); - await route.continue(); - }); - - // Dispatch the needsRefresh event directly on the EventSource - // (this is how the server sends SSE events — as named events) - await page.evaluate((sid: string) => { - const app = (window as unknown as { app: { eventSource: EventSource } }).app; - if (app.eventSource) { - const event = new MessageEvent('session:needsRefresh', { - data: JSON.stringify({ id: sid }), - }); - app.eventSource.dispatchEvent(event); - } - }, sessionId); - - // Wait for the async handler (fetches /terminal buffer + sends resize) - await page.waitForTimeout(1500); - - // Verify resize was called - expect(resizeCalls.length).toBeGreaterThanOrEqual(1); - console.log(`[opencode-resize] needsRefresh triggered ${resizeCalls.length} resize call(s)`); - - // Cleanup - await page.route('**/api/sessions/*/resize', (route) => route.continue()); - await page.evaluate(async (sid: string) => { - await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); - }, sessionId); - }); -}); - -describe('OpenCode close modal text', () => { - let context: BrowserContext; - let page: Page; - - afterAll(async () => { - await context?.close(); - }); - - it('shows "Kill Tmux & OpenCode" for opencode sessions', async () => { - ({ context, page } = await freshPage()); - await navigateAndWait(page); - - // Create a session and mark it as opencode mode - const sessionId = await page.evaluate(async () => { - const res = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp', name: 'oc-close-test', mode: 'opencode' }), - }); - const data = await res.json(); - return data.id ?? data.session?.id; - }); - - expect(sessionId).toBeTruthy(); - - // Wait for SSE to propagate the session - await page.waitForTimeout(500); - - // Open the close confirmation modal - await page.evaluate((sid: string) => { - const app = (window as unknown as { app: { requestCloseSession: (id: string) => void } }).app; - app.requestCloseSession(sid); - }, sessionId); - - // Check the kill button text - const killTitle = await page.locator('#closeConfirmKillTitle').textContent(); - expect(killTitle).toBe('Kill Tmux & OpenCode'); - - // Close the modal - await page.evaluate(() => { - const app = (window as unknown as { app: { cancelCloseSession: () => void } }).app; - app.cancelCloseSession(); - }); - - // Cleanup - await page.evaluate(async (sid: string) => { - await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); - }, sessionId); - }); - - it('shows "Kill Tmux & Claude Code" for claude sessions', async () => { - ({ context, page } = await freshPage()); - await navigateAndWait(page); - - // Create a standard claude session - const sessionId = await page.evaluate(async () => { - const res = await fetch('/api/sessions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ workingDir: '/tmp', name: 'cc-close-test' }), - }); - const data = await res.json(); - return data.id ?? data.session?.id; - }); - - expect(sessionId).toBeTruthy(); - - await page.waitForTimeout(500); - - // Open the close confirmation modal - await page.evaluate((sid: string) => { - const app = (window as unknown as { app: { requestCloseSession: (id: string) => void } }).app; - app.requestCloseSession(sid); - }, sessionId); - - // Check the kill button text - const killTitle = await page.locator('#closeConfirmKillTitle').textContent(); - expect(killTitle).toBe('Kill Tmux & Claude Code'); - - // Close the modal - await page.evaluate(() => { - const app = (window as unknown as { app: { cancelCloseSession: () => void } }).app; - app.cancelCloseSession(); - }); - - // Cleanup - await page.evaluate(async (sid: string) => { - await fetch(`/api/sessions/${sid}`, { method: 'DELETE' }); - }, sessionId); - }); -}); diff --git a/test/path-picker-ui.test.ts b/test/path-picker-ui.test.ts index b64633e6..fc3b083d 100644 --- a/test/path-picker-ui.test.ts +++ b/test/path-picker-ui.test.ts @@ -114,54 +114,36 @@ describe('mobile filesystem picker actions', () => { }); it('inserts a selected path into the editable local-echo prompt without sending it', () => { - const appendText = vi.fn(); - const sendInput = vi.fn(); + const insertText = vi.fn(); const focus = vi.fn(); const app = { activeSessionId: 'session-1', - _localEchoEnabled: true, - _localEchoOverlay: { appendText }, + _terminalInputController: { insertText }, terminal: { focus }, - sendInput, }; terminalHarness.mixin.insertTerminalText.call(app, '/mnt/d/AI/project'); - expect(appendText).toHaveBeenCalledWith('/mnt/d/AI/project'); - expect(sendInput).not.toHaveBeenCalled(); + expect(insertText).toHaveBeenCalledWith('/mnt/d/AI/project'); expect(focus).toHaveBeenCalledOnce(); }); it('clears pending and already-flushed prompt text without invoking /clear', () => { - const clear = vi.fn(); - const suppressBufferDetection = vi.fn(); - const sendInput = vi.fn(() => Promise.resolve()); + const clearInput = vi.fn(); const showToast = vi.fn(); const focus = vi.fn(); const app = { activeSessionId: 'session-1', - _inputFlushTimeout: null, - _pendingInput: 'pending text', - _localEchoEnabled: true, - _localEchoOverlay: { - getFlushed: () => ({ count: 4, text: 'sent' }), - clear, - suppressBufferDetection, - }, + _terminalInputController: { clearInput }, _flushedOffsets: new Map([['session-1', 4]]), _flushedTexts: new Map([['session-1', 'sent']]), - sendInput, showToast, terminal: { focus }, }; terminalHarness.mixin.clearTerminalInput.call(app); - expect(app._pendingInput).toBe(''); - expect(clear).toHaveBeenCalledOnce(); - expect(suppressBufferDetection).toHaveBeenCalledOnce(); - expect(sendInput).toHaveBeenCalledWith('\x7f'.repeat(4)); - expect(sendInput).not.toHaveBeenCalledWith('/clear'); + expect(clearInput).toHaveBeenCalledOnce(); expect(app._flushedOffsets.size).toBe(0); expect(app._flushedTexts.size).toBe(0); expect(showToast).toHaveBeenCalledWith('Input cleared', 'success'); @@ -169,21 +151,19 @@ describe('mobile filesystem picker actions', () => { expect(terminalHarness.cjkClear).toHaveBeenCalled(); }); - it('uses Ctrl+U to clear the TUI-owned prompt when local echo is disabled', () => { - const sendInput = vi.fn(() => Promise.resolve()); + it('delegates non-local prompt clearing to the controller', () => { + const clearInput = vi.fn(); const app = { activeSessionId: 'session-1', - _inputFlushTimeout: null, - _pendingInput: '', - _localEchoEnabled: false, - _localEchoOverlay: null, - sendInput, + _terminalInputController: { clearInput }, + _flushedOffsets: new Map(), + _flushedTexts: new Map(), showToast: vi.fn(), terminal: { focus: vi.fn() }, }; terminalHarness.mixin.clearTerminalInput.call(app); - expect(sendInput).toHaveBeenCalledWith('\x15'); + expect(clearInput).toHaveBeenCalledOnce(); }); }); diff --git a/test/quick-start.test.ts b/test/quick-start.test.ts index 330fe5d9..9611a401 100644 --- a/test/quick-start.test.ts +++ b/test/quick-start.test.ts @@ -1,11 +1,28 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import { WebServer } from '../src/web/server.js'; -import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import type { WebServer } from '../src/web/server.js'; +import { existsSync, rmSync, mkdirSync, mkdtempSync } from 'node:fs'; import { join } from 'node:path'; -import { homedir } from 'node:os'; +import { tmpdir } from 'node:os'; const TEST_PORT = 3099; -const CASES_DIR = join(homedir(), 'codeman-cases'); +const ORIGINAL_HOME = process.env.HOME; +const TEST_HOME = mkdtempSync(join(tmpdir(), 'codeman-quick-start-')); +const CASES_DIR = join(TEST_HOME, 'codeman-cases'); +let webServerModule: Promise | undefined; + +process.env.HOME = TEST_HOME; + +async function createTestServer(port: number): Promise { + webServerModule ??= import('../src/web/server.js'); + const { WebServer: TestWebServer } = await webServerModule; + return new TestWebServer(port, false, true); +} + +afterAll(() => { + if (ORIGINAL_HOME === undefined) delete process.env.HOME; + else process.env.HOME = ORIGINAL_HOME; + rmSync(TEST_HOME, { recursive: true, force: true }); +}); describe('Quick Start API', () => { let server: WebServer; @@ -13,7 +30,7 @@ describe('Quick Start API', () => { const createdCases: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT, false, true); + server = await createTestServer(TEST_PORT); await server.start(); baseUrl = `http://localhost:${TEST_PORT}`; }); @@ -147,7 +164,7 @@ describe('Session Management', () => { let baseUrl: string; beforeAll(async () => { - server = new WebServer(TEST_PORT + 1, false, true); + server = await createTestServer(TEST_PORT + 1); await server.start(); baseUrl = `http://localhost:${TEST_PORT + 1}`; }); @@ -206,7 +223,7 @@ describe('Case Management', () => { const createdCases: string[] = []; beforeAll(async () => { - server = new WebServer(TEST_PORT + 2, false, true); + server = await createTestServer(TEST_PORT + 2); await server.start(); baseUrl = `http://localhost:${TEST_PORT + 2}`; }); diff --git a/test/response-viewer.test.ts b/test/response-viewer.test.ts new file mode 100644 index 00000000..5cfb49b1 --- /dev/null +++ b/test/response-viewer.test.ts @@ -0,0 +1,84 @@ +/** + * @fileoverview Response viewer source-selection regressions. + * + * The "Last Response" action must never substitute the complete terminal + * scrollback when a structured transcript lookup is temporarily unavailable. + */ +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 fakeClassList() { + const values = new Set(); + return { + add: (...names: string[]) => names.forEach((name) => values.add(name)), + remove: (...names: string[]) => names.forEach((name) => values.delete(name)), + contains: (name: string) => values.has(name), + }; +} + +function loadCodemanAppClass(elements: Record>) { + 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) => global.fetch(...args), + document: { + addEventListener: vi.fn(), + getElementById: (id: string) => elements[id] ?? null, + }, + 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; +} + +describe('Last Response viewer', () => { + it('does not replace an empty transcript response with the full terminal history', async () => { + const elements = { + responseViewer: { classList: fakeClassList() }, + responseViewerBackdrop: { classList: fakeClassList() }, + responseViewerBody: { textContent: '', innerHTML: '', scrollTop: 0 }, + responseViewerTitle: { textContent: '' }, + responseViewerMore: { style: { display: '' }, textContent: '' }, + }; + const CodemanApp = loadCodemanAppClass(elements); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as { + activeSessionId: string; + sessions: Map; + toggleResponseViewer: () => Promise; + }; + app.activeSessionId = 'claude-session'; + app.sessions = new Map([['claude-session', { mode: 'claude' }]]); + + const fetchMock = vi.fn(async () => ({ + json: async () => ({ success: true, data: { text: '', timestamp: '' } }), + })); + global.fetch = fetchMock as unknown as typeof fetch; + + await app.toggleResponseViewer(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith('/api/sessions/claude-session/last-response'); + expect(elements.responseViewerBody.textContent).toContain('No response yet'); + expect(elements.responseViewerTitle.textContent).toBe('Last Response'); + }); +}); diff --git a/test/routes/file-routes-repository.test.ts b/test/routes/file-routes-repository.test.ts new file mode 100644 index 00000000..75283276 --- /dev/null +++ b/test/routes/file-routes-repository.test.ts @@ -0,0 +1,198 @@ +/** + * @fileoverview Repository-aware File Viewer route tests. + * + * Uses app.inject() with a real temporary Git repository; no HTTP port needed. + */ + +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; +import { discoverGitRepository } from '../../src/git-repository-browser.js'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + GIT_CONFIG_NOSYSTEM: '1', + }, + }).trim(); +} + +describe('file-routes repository browsing', () => { + let fixtureRoot: string; + let repositoryRoot: string; + let harness: RouteTestHarness; + let savedMultiUser: string | undefined; + let savedUserSpaces: string | undefined; + + beforeEach(async () => { + savedMultiUser = process.env.CODEMAN_MULTIUSER; + savedUserSpaces = process.env.CODEMAN_USER_SPACES_DIR; + delete process.env.CODEMAN_MULTIUSER; + delete process.env.CODEMAN_USER_SPACES_DIR; + fixtureRoot = mkdtempSync(join(tmpdir(), 'codeman-file-routes-git-')); + repositoryRoot = join(fixtureRoot, 'repository'); + mkdirSync(repositoryRoot); + mkdirSync(join(repositoryRoot, 'src')); + git(repositoryRoot, 'init', '-b', 'main'); + git(repositoryRoot, 'config', 'user.name', 'Codeman Test'); + git(repositoryRoot, 'config', 'user.email', 'codeman@example.invalid'); + writeFileSync(join(repositoryRoot, 'README.md'), 'initial\n'); + writeFileSync(join(repositoryRoot, 'src', 'app.ts'), 'export const initial = true;\n'); + git(repositoryRoot, 'add', '.'); + git(repositoryRoot, 'commit', '-m', 'initial commit'); + writeFileSync(join(repositoryRoot, 'README.md'), 'initial\nchanged\n'); + + harness = await createRouteTestHarness(registerFileRoutes); + harness.ctx._session.workingDir = join(repositoryRoot, 'src'); + }); + + afterEach(async () => { + await harness.app.close(); + rmSync(fixtureRoot, { recursive: true, force: true }); + if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = savedMultiUser; + if (savedUserSpaces === undefined) { + delete process.env.CODEMAN_USER_SPACES_DIR; + } else { + process.env.CODEMAN_USER_SPACES_DIR = savedUserSpaces; + } + }); + + it('returns repository metadata and roots the scoped file tree at the worktree', async () => { + const repositoryResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/repository?scope=current`, + }); + expect(repositoryResponse.statusCode).toBe(200); + const repository = repositoryResponse.json(); + expect(repository).toMatchObject({ + success: true, + data: { + available: true, + repositoryRoot, + }, + }); + expect(repository.data.changes).toEqual( + expect.arrayContaining([expect.objectContaining({ path: 'README.md', status: 'modified' })]) + ); + + const filesResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?scope=current&depth=2`, + }); + const files = filesResponse.json(); + expect(files.success).toBe(true); + expect(files.data.root).toBe(repositoryRoot); + expect(files.data.tree).toEqual( + expect.arrayContaining([expect.objectContaining({ name: 'README.md', type: 'file' })]) + ); + }); + + it('uses the same worktree scope for file content and diff detail', async () => { + const legacyResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=README.md`, + }); + expect(legacyResponse.json().success).toBe(false); + + const scopedResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=README.md&scope=current`, + }); + expect(scopedResponse.json()).toMatchObject({ + success: true, + data: { + content: 'initial\nchanged\n', + }, + }); + + const diffResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/repository/diff?scope=current&path=README.md`, + }); + expect(diffResponse.json()).toMatchObject({ + success: true, + data: { + beforeContent: 'initial\n', + afterContent: 'initial\nchanged\n', + additions: 1, + }, + }); + }); + + it('rejects a forged worktree scope', async () => { + const response = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?scope=forged&depth=2`, + }); + expect(response.json()).toMatchObject({ + success: false, + error: expect.stringContaining('scope not found'), + }); + }); + + it('filters and rejects linked worktrees outside a regular user workspace', async () => { + const userSpaces = join(fixtureRoot, 'user-spaces'); + const allowedRoot = join(userSpaces, 'alice', 'cases', 'allowed-repository'); + const outsideRoot = join(fixtureRoot, 'outside-worktree'); + mkdirSync(allowedRoot, { recursive: true }); + git(allowedRoot, 'init', '-b', 'main'); + git(allowedRoot, 'config', 'user.name', 'Codeman Test'); + git(allowedRoot, 'config', 'user.email', 'codeman@example.invalid'); + writeFileSync(join(allowedRoot, 'README.md'), 'allowed\n'); + git(allowedRoot, 'add', 'README.md'); + git(allowedRoot, 'commit', '-m', 'allowed root'); + git(allowedRoot, 'worktree', 'add', '-b', 'outside', outsideRoot); + writeFileSync(join(outsideRoot, 'secret.txt'), 'outside secret\n'); + + process.env.CODEMAN_MULTIUSER = '1'; + process.env.CODEMAN_USER_SPACES_DIR = userSpaces; + await harness.app.close(); + harness = await createRouteTestHarness(registerFileRoutes, { + authUser: { username: 'alice', role: 'user' }, + }); + harness.ctx._session.workingDir = allowedRoot; + harness.ctx._session.owner = 'alice'; + + const discovery = await discoverGitRepository(allowedRoot); + const outsideScope = discovery?.worktrees.find((worktree) => worktree.path === outsideRoot); + expect(outsideScope).toBeDefined(); + + const overviewResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/repository?scope=current`, + }); + const overview = overviewResponse.json(); + expect(overview.success).toBe(true); + expect(overview.data.worktrees).not.toEqual( + expect.arrayContaining([expect.objectContaining({ path: outsideRoot })]) + ); + + const filesResponse = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/files?depth=1&scope=` + encodeURIComponent(outsideScope!.id), + }); + expect(filesResponse.json()).toMatchObject({ + success: false, + error: expect.stringContaining('outside the allowed workspace'), + }); + + const diffResponse = await harness.app.inject({ + method: 'GET', + url: + `/api/sessions/${harness.ctx._sessionId}/repository/diff?path=secret.txt&scope=` + + encodeURIComponent(outsideScope!.id), + }); + expect(diffResponse.json()).toMatchObject({ + success: false, + error: expect.stringContaining('outside the allowed workspace'), + }); + }); +}); diff --git a/test/routes/session-routes-codex-last-response.test.ts b/test/routes/session-routes-codex-last-response.test.ts index 12bc2ac7..860d1f8b 100644 --- a/test/routes/session-routes-codex-last-response.test.ts +++ b/test/routes/session-routes-codex-last-response.test.ts @@ -15,7 +15,7 @@ * - response envelope shape; Claude-mode behavior unchanged (regression guard) */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync } from 'node:fs'; @@ -380,4 +380,66 @@ describe('GET /api/sessions/:id/last-response (codex)', () => { rmSync(fakeHome, { recursive: true, force: true }); } }); + + it('recovers a restored Claude pane by transcript-id prefix and returns only its latest response', async () => { + const fakeHome = mkdtempSync(join(tmpdir(), 'codeman-restored-claude-home-')); + const prevHome = process.env.HOME; + process.env.HOME = fakeHome; + try { + const restored = createMockSession('restored-deadbeef'); + restored.mode = 'claude'; + restored.workingDir = '/stale/server/cwd'; + const restoredWithTranscript = restored as typeof restored & { + claudeSessionId: string; + adoptClaudeSessionId: (id: string) => void; + }; + restoredWithTranscript.claudeSessionId = restored.id; + restoredWithTranscript.adoptClaudeSessionId = vi.fn((id: string) => { + restoredWithTranscript.claudeSessionId = id; + }); + harness.ctx.sessions.set(restored.id, restored); + + const transcriptId = 'deadbeef-1111-4222-8333-444444444444'; + const projDir = join(fakeHome, '.claude', 'projects', '-actual-project'); + mkdirSync(projDir, { recursive: true }); + writeFileSync( + join(projDir, `${transcriptId}.jsonl`), + [ + { + type: 'user', + timestamp: '2026-07-01T00:00:00Z', + message: { content: 'first prompt' }, + }, + { + type: 'assistant', + timestamp: '2026-07-01T00:01:00Z', + message: { content: [{ type: 'text', text: 'older response' }] }, + }, + { + type: 'user', + timestamp: '2026-07-01T00:02:00Z', + message: { content: 'latest prompt' }, + }, + { + type: 'assistant', + timestamp: '2026-07-01T00:03:00Z', + message: { content: [{ type: 'text', text: 'actual latest response' }] }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join('\n') + '\n' + ); + + const { res, body } = await getLastResponse(restored.id); + expect(res.statusCode).toBe(200); + expect(body.data).toEqual({ + text: 'actual latest response', + timestamp: '2026-07-01T00:03:00Z', + }); + expect(restoredWithTranscript.adoptClaudeSessionId).toHaveBeenCalledWith(transcriptId); + } finally { + process.env.HOME = prevHome; + rmSync(fakeHome, { recursive: true, force: true }); + } + }); }); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 259ed71d..d1fd722c 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -387,6 +387,52 @@ describe('session-routes', () => { }); }); + // ========== PUT /api/sessions/:id/working-directory ========== + + describe('PUT /api/sessions/:id/working-directory', () => { + it('synchronizes a local session path and persists the updated state', async () => { + const workingDir = await mkdtemp(join(tmpdir(), 'codeman-workdir-')); + try { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/working-directory`, + payload: { workingDir }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).data.workingDir).toBe(workingDir); + expect(harness.ctx._session.setWorkingDir).toHaveBeenCalledWith(workingDir); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything()); + } finally { + await rm(workingDir, { recursive: true }); + } + }); + + it('rejects a missing directory without changing the session', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/working-directory`, + payload: { workingDir: '/tmp/codeman-directory-that-does-not-exist' }, + }); + + expect(res.statusCode).toBe(400); + expect(harness.ctx._session.setWorkingDir).not.toHaveBeenCalled(); + }); + + it('rejects remote sessions because their paths cannot be validated locally', async () => { + (harness.ctx._session as typeof harness.ctx._session & { remote: object }).remote = {}; + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/working-directory`, + payload: { workingDir: '/tmp' }, + }); + + expect(res.statusCode).toBe(400); + expect(harness.ctx._session.setWorkingDir).not.toHaveBeenCalled(); + }); + }); + // ========== PUT /api/sessions/:id/color ========== describe('PUT /api/sessions/:id/color', () => { @@ -584,6 +630,20 @@ describe('session-routes', () => { expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40, { viewportType: undefined, force: true }); }); + it('passes explicit viewport takeover through for HTTP fallback', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/resize`, + payload: { cols: 48, rows: 28, viewportType: 'mobile', takeControl: true }, + }); + expect(res.statusCode).toBe(200); + expect(harness.ctx._session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: undefined, + takeControl: true, + }); + }); + it('rejects cols exceeding max (500)', async () => { const res = await harness.app.inject({ method: 'POST', @@ -630,6 +690,117 @@ describe('session-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.data.terminalBuffer).toBeDefined(); + expect(body.data.cursor).toEqual({ + stream: expect.any(String), + generation: expect.any(Number), + start: expect.any(Number), + end: 11, + }); + }); + + it('streams lossless terminal text outside the JSON envelope with cursor headers', async () => { + const terminalText = 'history line\nunicode: \u05e9\u05dc\u05d5\u05dd \ud83d\ude80\ncurrent prompt'; + harness.ctx._session.terminalBuffer = terminalText; + harness.ctx.mux.captureActivePaneBuffer = vi.fn(() => null); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?format=stream`, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toBe(terminalText); + expect(res.body).not.toContain('"success"'); + expect(res.headers['content-type']).toContain('text/plain'); + expect(res.headers['x-codeman-terminal-format']).toBe('stream-v1'); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.headers['x-codeman-terminal-stream']).toEqual(expect.any(String)); + expect(res.headers['x-codeman-terminal-generation']).toBe('0'); + expect(res.headers['x-codeman-terminal-start']).toBe('0'); + expect(res.headers['x-codeman-terminal-end']).toBe(String(terminalText.length)); + expect(res.headers['x-codeman-terminal-truncated']).toBe('0'); + expect(res.headers['x-codeman-terminal-source']).toBe('history'); + }); + + it('streams a bounded tmux history page with absolute row metadata', async () => { + const capturePage = vi.fn(() => ({ + buffer: 'PAGE_ROW_1200\r\nPAGE_ROW_2199', + start: 1200, + end: 2200, + total: 2200, + hasMoreBefore: true, + hasMoreAfter: false, + origin: 'pane-7:stable-origin', + })); + (harness.ctx.mux as { captureActivePaneHistoryPage?: unknown }).captureActivePaneHistoryPage = capturePage; + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?historyPage=1&lines=1000&format=stream`, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toContain('PAGE_ROW_1200'); + expect(res.body).toContain('PAGE_ROW_2199'); + expect(res.headers['x-codeman-terminal-source']).toBe('mux-history-page'); + expect(res.headers['x-codeman-history-start']).toBe('1200'); + expect(res.headers['x-codeman-history-end']).toBe('2200'); + expect(res.headers['x-codeman-history-total']).toBe('2200'); + expect(res.headers['x-codeman-history-more-before']).toBe('1'); + expect(res.headers['x-codeman-history-more-after']).toBe('0'); + expect(res.headers['x-codeman-history-origin']).toBe('pane-7:stable-origin'); + expect(capturePage).toHaveBeenCalledWith(harness.ctx._session.muxName, { + limit: 1000, + before: undefined, + after: undefined, + }); + }); + + it('forwards an older-page boundary without requesting the full tmux history', async () => { + const capturePage = vi.fn(() => ({ + buffer: 'OLDER_PAGE', + start: 1000, + end: 2000, + total: 5000, + hasMoreBefore: true, + hasMoreAfter: true, + origin: 'pane-7:stable-origin', + })); + (harness.ctx.mux as { captureActivePaneHistoryPage?: unknown }).captureActivePaneHistoryPage = capturePage; + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?historyPage=1&before=2000&lines=1000&format=stream`, + }); + + expect(res.statusCode).toBe(200); + expect(capturePage).toHaveBeenCalledWith(harness.ctx._session.muxName, { + limit: 1000, + before: 2000, + after: undefined, + }); + expect(harness.ctx.mux.captureActivePaneBuffer).toBeUndefined(); + }); + + it('falls back to a bounded newest-byte tail when tmux page capture fails', async () => { + const oldest = 'HISTORY_PAGE_FALLBACK_OLDEST'; + const newest = 'HISTORY_PAGE_FALLBACK_NEWEST'; + harness.ctx._session.terminalBuffer = oldest + '\n' + 'x'.repeat(1024 * 1024 + 4096) + '\n' + newest; + (harness.ctx.mux as { captureActivePaneHistoryPage?: unknown }).captureActivePaneHistoryPage = vi.fn(() => null); + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn(() => null); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?historyPage=1`, + }); + + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.data.terminalBuffer.length).toBeLessThanOrEqual(1024 * 1024); + expect(body.data.terminalBuffer).toContain(newest); + expect(body.data.terminalBuffer).not.toContain(oldest); + expect(body.data.truncated).toBe(true); + expect(body.data.historyPage).toBeUndefined(); }); it('does not strip VPA-like shell scrollback as Ink redraw bloat', async () => { @@ -676,6 +847,26 @@ describe('session-routes', () => { expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined); }); + it('returns only the current mux pane for latest-frame requests, even with full=1', async () => { + harness.ctx._session.terminalBuffer = 'old accumulated history\nthat must load in the background'; + harness.ctx._session.mode = 'codex'; + (harness.ctx.mux as { captureActivePaneBuffer?: unknown }).captureActivePaneBuffer = vi.fn( + () => 'current pane now\n› current prompt' + ); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal?latest=1&full=1&tail=131072&format=stream`, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).toContain('current pane now'); + expect(res.body).toContain('› current prompt'); + expect(res.body).not.toContain('old accumulated history'); + expect(res.headers['x-codeman-terminal-source']).toBe('mux-visible'); + expect(harness.ctx.mux.captureActivePaneBuffer).toHaveBeenCalledWith(harness.ctx._session.muxName, undefined); + }); + // ── COD-47: full tmux scrollback replay on full page reload ── it('full reload (?full=1) requests full tmux history and replays boundary markers', async () => { // A realistic scrollback-length capture: ~5000 lines, well past one screen. diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts index 0455586e..deb588e8 100644 --- a/test/routes/system-routes.test.ts +++ b/test/routes/system-routes.test.ts @@ -101,8 +101,14 @@ const mockedResolveGeminiDir = vi.mocked(resolveGeminiDir); describe('system-routes', () => { let harness: RouteTestHarness; + let savedPassword: string | undefined; + let savedMultiUser: string | undefined; beforeEach(async () => { + savedPassword = process.env.CODEMAN_PASSWORD; + savedMultiUser = process.env.CODEMAN_MULTIUSER; + delete process.env.CODEMAN_PASSWORD; + delete process.env.CODEMAN_MULTIUSER; harness = await createRouteTestHarness(registerSystemRoutes); vi.clearAllMocks(); @@ -131,6 +137,10 @@ describe('system-routes', () => { afterEach(async () => { await harness.app.close(); + if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD; + else process.env.CODEMAN_PASSWORD = savedPassword; + if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER; + else process.env.CODEMAN_MULTIUSER = savedMultiUser; }); // ========== GET /api/status ========== @@ -143,6 +153,70 @@ describe('system-routes', () => { }); }); + // ========== POST /api/system/shutdown ========== + + describe('POST /api/system/shutdown', () => { + it('accepts a graceful instance shutdown request', async () => { + process.env.CODEMAN_PASSWORD = 'test-password'; + const res = await harness.app.inject({ method: 'POST', url: '/api/system/shutdown' }); + + expect(res.statusCode).toBe(202); + expect(JSON.parse(res.body)).toEqual({ + accepted: true, + strategy: 'manual', + alreadyScheduled: false, + }); + expect(harness.ctx.requestInstanceShutdown).toHaveBeenCalledOnce(); + }); + + it('reports when the owning supervisor requires administrator access', async () => { + process.env.CODEMAN_PASSWORD = 'test-password'; + harness.ctx.requestInstanceShutdown.mockResolvedValueOnce({ + accepted: false, + reason: 'Stopping this service requires administrator access', + }); + + const res = await harness.app.inject({ method: 'POST', url: '/api/system/shutdown' }); + + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body)).toMatchObject({ + success: false, + errorCode: 'CONFLICT', + error: 'Stopping this service requires administrator access', + }); + }); + + it('rejects remote shutdown when single-user authentication is disabled', async () => { + const res = await harness.app.inject({ method: 'POST', url: '/api/system/shutdown' }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ + success: false, + errorCode: 'FORBIDDEN', + error: expect.stringContaining('CODEMAN_PASSWORD'), + }); + expect(harness.ctx.requestInstanceShutdown).not.toHaveBeenCalled(); + }); + + it('rejects a non-admin in multi-user mode', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const userHarness = await createRouteTestHarness(registerSystemRoutes, { + authUser: { username: 'viewer', role: 'user' }, + }); + try { + const res = await userHarness.app.inject({ + method: 'POST', + url: '/api/system/shutdown', + }); + + expect(res.statusCode).toBe(403); + expect(userHarness.ctx.requestInstanceShutdown).not.toHaveBeenCalled(); + } finally { + await userHarness.app.close(); + } + }); + }); + // ========== GET /api/config ========== describe('GET /api/config', () => { diff --git a/test/routes/ws-routes.test.ts b/test/routes/ws-routes.test.ts index 5b722036..1b6facc2 100644 --- a/test/routes/ws-routes.test.ts +++ b/test/routes/ws-routes.test.ts @@ -19,6 +19,21 @@ import { registerWsRoutes } from '../../src/web/routes/ws-routes.js'; import { MAX_INPUT_LENGTH } from '../../src/config/terminal-limits.js'; const PORT = 3170; +const DEC_2026_START = '\x1b[?2026h'; +const DEC_2026_END = '\x1b[?2026l'; + +function synchronizedPayload(messages: Array<{ d: string }>): string { + const transaction = messages.map((message) => message.d).join(''); + expect(transaction.startsWith(DEC_2026_START)).toBe(true); + expect(transaction.endsWith(DEC_2026_END)).toBe(true); + return transaction.slice(DEC_2026_START.length, -DEC_2026_END.length); +} + +function transportPayload(message: { d: string }, index: number, count: number): string { + const start = index === 0 ? DEC_2026_START.length : 0; + const end = index === count - 1 ? message.d.length - DEC_2026_END.length : message.d.length; + return message.d.slice(start, end); +} /** Helper: open a WebSocket connection and wait for it to reach OPEN state. */ function connectWs(path: string, timeoutMs = 5000): Promise { @@ -78,13 +93,26 @@ function collectMessages(ws: WebSocket, count: number, timeoutMs = 3000): Promis describe('ws-routes', () => { let app: FastifyInstance; let ctx: MockRouteContext; + let streamCoordinator: { + suspendSseTerminal: ReturnType; + resumeSseTerminal: ReturnType; + }; beforeEach(async () => { app = Fastify({ logger: false }); await app.register(fastifyWebsocket); ctx = createMockRouteContext({ sessionId: 'ws-test-session' }); - registerWsRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null })); + streamCoordinator = { + suspendSseTerminal: vi.fn(), + resumeSseTerminal: vi.fn(), + }; + registerWsRoutes( + app, + ctx as never, + () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }), + streamCoordinator + ); await app.listen({ port: PORT, host: '127.0.0.1' }); }); @@ -107,6 +135,26 @@ describe('ws-routes', () => { // ========== Terminal output ========== describe('terminal output', () => { + it('flushes queued output before handing the session stream back to SSE', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal?cid=page-client-a'); + try { + expect(streamCoordinator.suspendSseTerminal).toHaveBeenCalledWith('page-client-a', 'ws-test-session'); + + const messages = collectMessages(ws, 2); + ctx._session.emit('terminal', 'last websocket batch'); + ws.send('{"t":"h"}'); + + await expect(messages).resolves.toEqual([ + expect.objectContaining({ t: 'o', d: expect.stringContaining('last websocket batch') }), + { t: 'ha' }, + ]); + expect(streamCoordinator.resumeSseTerminal).toHaveBeenCalledWith('page-client-a', 'ws-test-session', false); + expect(ctx._session.listenerCount('terminal')).toBe(0); + } finally { + ws.close(); + } + }); + it('receives terminal output via WS with DEC 2026 sync markers', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); try { @@ -127,6 +175,29 @@ describe('ws-routes', () => { } }); + it('carries the terminal stream cursor with WebSocket output', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const cursor = { + stream: 'ws-cursor-stream', + generation: 3, + start: 40, + end: 51, + }; + ctx._session.emit('terminal', 'hello world', cursor); + + const msg = (await nextMessage(ws)) as { + t: string; + d: string; + cursor?: typeof cursor; + }; + expect(msg.t).toBe('o'); + expect(msg.cursor).toEqual(cursor); + } finally { + ws.close(); + } + }); + it('sends clearTerminal event as {"t":"c"}', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); try { @@ -170,19 +241,159 @@ describe('ws-routes', () => { } }); - it('flushes immediately when batch exceeds size threshold', async () => { + it('coalesces adjacent mobile redraws across the desktop batch window', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); try { const session = ctx._session; + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' })); + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: false, + }); + }); - // Emit data larger than WS_BATCH_FLUSH_THRESHOLD (16384) - const largeData = 'X'.repeat(17000); - session.emit('terminal', largeData); + session.emit('terminal', 'mobile-frame-1'); + await new Promise((resolve) => setTimeout(resolve, 15)); + session.emit('terminal', 'mobile-frame-2'); - // Should flush immediately (no 8ms wait) — use a tight timeout - const msg = (await nextMessage(ws, 500)) as { t: string; d: string }; + const msg = (await nextMessage(ws)) as { t: string; d: string }; expect(msg.t).toBe('o'); - expect(msg.d).toContain(largeData); + expect(msg.d).toContain('mobile-frame-1mobile-frame-2'); + } finally { + ws.close(); + } + }); + + it('keeps large adjacent mobile redraw chunks in one synchronized transaction', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' })); + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: false, + }); + }); + + const first = 'A'.repeat(24 * 1024); + const second = 'B'.repeat(24 * 1024); + const messagesPromise = collectMessages(ws, 3, 500); + session.emit('terminal', first); + await new Promise((resolve) => setTimeout(resolve, 15)); + session.emit('terminal', second); + + const messages = (await messagesPromise) as Array<{ t: string; d: string }>; + expect(synchronizedPayload(messages)).toBe(first + second); + expect(messages[0].d.startsWith(DEC_2026_START)).toBe(true); + expect(messages[0].d.endsWith(DEC_2026_END)).toBe(false); + expect(messages[1].d.includes(DEC_2026_START)).toBe(false); + expect(messages[1].d.includes(DEC_2026_END)).toBe(false); + expect(messages[2].d.startsWith(DEC_2026_START)).toBe(false); + expect(messages[2].d.endsWith(DEC_2026_END)).toBe(true); + } finally { + ws.close(); + } + }); + + it('splits mobile bulk output into one lossless synchronized transaction', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' })); + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: false, + }); + }); + + const largeData = 'X'.repeat(24 * 1024); + const cursor = { + stream: 'mobile-bulk-stream', + generation: 4, + start: 100, + end: 100 + largeData.length, + }; + const messagesPromise = collectMessages(ws, 2, 500); + session.emit('terminal', largeData, cursor); + + const messages = (await messagesPromise) as Array<{ + t: string; + d: string; + cursor: typeof cursor; + }>; + const payloads = messages.map((message, index) => transportPayload(message, index, messages.length)); + expect(payloads.every((payload) => Buffer.byteLength(payload, 'utf8') <= 16 * 1024)).toBe(true); + expect(synchronizedPayload(messages)).toBe(largeData); + expect(messages[0].cursor.start).toBe(cursor.start); + expect(messages[0].cursor.end).toBe(messages[1].cursor.start); + expect(messages[1].cursor.end).toBe(cursor.end); + expect( + messages.every((message, index) => message.cursor.end - message.cursor.start === payloads[index].length) + ).toBe(true); + expect(messages[0].d.endsWith(DEC_2026_END)).toBe(false); + expect(messages[1].d.startsWith(DEC_2026_START)).toBe(false); + } finally { + ws.close(); + } + }); + + it('splits bulk UTF-8 output into bounded lossless frames', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + + const largeData = 'X'.repeat(8000) + '😀'.repeat(1000); + const messagesPromise = collectMessages(ws, 2, 500); + session.emit('terminal', largeData); + + const messages = (await messagesPromise) as Array<{ t: string; d: string }>; + const payloads = messages.map((message, index) => transportPayload(message, index, messages.length)); + expect(messages.every((message) => message.t === 'o')).toBe(true); + expect(payloads.every((payload) => Buffer.byteLength(payload, 'utf8') <= 8 * 1024)).toBe(true); + expect(synchronizedPayload(messages)).toBe(largeData); + } finally { + ws.close(); + } + }); + + it('does not split inside ANSI control sequences or synchronized updates', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + const colorSequence = '\x1b[38;5;196m'; + const firstPayload = 'X'.repeat(8190) + colorSequence; + const atomicPayload = '\x1b[?2026h' + 'Y'.repeat(9000) + '\x1b[?2026l'; + const messagesPromise = collectMessages(ws, 2, 500); + + session.emit('terminal', firstPayload + atomicPayload); + + const messages = (await messagesPromise) as Array<{ t: string; d: string }>; + const payloads = messages.map((message, index) => transportPayload(message, index, messages.length)); + expect(payloads[0]).toBe(firstPayload); + expect(payloads[1]).toBe(atomicPayload); + expect(synchronizedPayload(messages)).toBe(firstPayload + atomicPayload); + } finally { + ws.close(); + } + }); + + it('carries an incomplete ANSI suffix into the next PTY event', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + const messagesPromise = collectMessages(ws, 2, 500); + + session.emit('terminal', 'plain text\x1b[38;'); + await new Promise((resolve) => setTimeout(resolve, 20)); + session.emit('terminal', '5;196mRED'); + + const messages = (await messagesPromise) as Array<{ t: string; d: string }>; + const payloads = messages.map((message) => message.d.slice('\x1b[?2026h'.length, -'\x1b[?2026l'.length)); + expect(payloads).toEqual(['plain text', '\x1b[38;5;196mRED']); + expect(payloads.join('')).toBe('plain text\x1b[38;5;196mRED'); } finally { ws.close(); } @@ -208,6 +419,95 @@ describe('ws-routes', () => { } }); + it('reasserts the last mobile viewport before applying ordinary keyboard input', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile' })); + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: false, + }); + }); + session.resize.mockClear(); + + ws.send(JSON.stringify({ t: 'i', d: 'a' })); + await vi.waitFor(() => { + expect(session.writeBuffer).toContain('a'); + }); + + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + takeControl: true, + }); + } finally { + ws.close(); + } + }); + + it('reasserts the sending desktop connection dimensions as sizing activity', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + ws.send(JSON.stringify({ t: 'z', c: 180, r: 50, v: 'desktop' })); + await vi.waitFor(() => { + expect(session.claimDesktopSizing).toHaveBeenCalledOnce(); + }); + session.resize.mockClear(); + + ws.send(JSON.stringify({ t: 'i', d: 'a' })); + await vi.waitFor(() => { + expect(session.writeBuffer).toContain('a'); + }); + + expect(session.resize).toHaveBeenCalledWith(180, 50, { + viewportType: 'desktop', + takeControl: true, + }); + } finally { + ws.close(); + } + }); + + it('uses the active desktop socket dimensions when multiple desktops are connected', async () => { + const desktopA = await connectWs('/ws/sessions/ws-test-session/terminal?cid=desktop-a'); + const desktopB = await connectWs('/ws/sessions/ws-test-session/terminal?cid=desktop-b'); + try { + const session = ctx._session; + desktopA.send(JSON.stringify({ t: 'z', c: 160, r: 44, v: 'desktop' })); + desktopB.send(JSON.stringify({ t: 'z', c: 220, r: 56, v: 'desktop' })); + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(160, 44, { + viewportType: 'desktop', + force: false, + }); + expect(session.resize).toHaveBeenCalledWith(220, 56, { + viewportType: 'desktop', + force: false, + }); + }); + session.resize.mockClear(); + + desktopA.send(JSON.stringify({ t: 'i', d: 'from-a' })); + await vi.waitFor(() => { + expect(session.writeBuffer).toContain('from-a'); + }); + + expect(session.resize).toHaveBeenCalledWith(160, 44, { + viewportType: 'desktop', + takeControl: true, + }); + expect(session.resize).not.toHaveBeenCalledWith(220, 56, { + viewportType: 'desktop', + takeControl: true, + }); + } finally { + desktopA.close(); + desktopB.close(); + } + }); + it('ignores input exceeding MAX_INPUT_LENGTH', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); try { @@ -322,6 +622,25 @@ describe('ws-routes', () => { } }); + it('passes explicit viewport takeover through for active mobile interaction', async () => { + const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); + try { + const session = ctx._session; + + ws.send(JSON.stringify({ t: 'z', c: 48, r: 28, v: 'mobile', a: true })); + + await vi.waitFor(() => { + expect(session.resize).toHaveBeenCalledWith(48, 28, { + viewportType: 'mobile', + force: false, + takeControl: true, + }); + }); + } finally { + ws.close(); + } + }); + it('claims desktop sizing on a desktop resize and releases it on close', async () => { const ws = await connectWs('/ws/sessions/ws-test-session/terminal'); const session = ctx._session; diff --git a/test/run-mode-ui.test.ts b/test/run-mode-ui.test.ts index 0c5b25af..f9129c03 100644 --- a/test/run-mode-ui.test.ts +++ b/test/run-mode-ui.test.ts @@ -6,7 +6,7 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; function loadRunModeHarness() { const elements: Record = {}; @@ -74,6 +74,36 @@ describe('run mode UI', () => { }); describe('Run launch synchronization', () => { + it('keeps launch progress out of an active session terminal', () => { + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { getElementById: () => null }, + console, + }); + const sessionUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + vm.runInContext(sessionUi, context, { filename: 'session-ui.js' }); + + const app = new (CodemanApp as any)(); + app.activeSessionId = 'existing-session'; + app.terminal = { + clear: vi.fn(), + writeln: vi.fn(), + }; + app.showToast = vi.fn(); + + const ownsTerminal = app._beginSessionLaunchStatus('Starting Codex session', '1;32'); + app._appendSessionLaunchStatus(ownsTerminal, 'Creating session'); + app._reportSessionLaunchError(ownsTerminal, 'Launch failed'); + + expect(ownsTerminal).toBe(false); + expect(app.terminal.clear).not.toHaveBeenCalled(); + expect(app.terminal.writeln).not.toHaveBeenCalled(); + expect(app.showToast).toHaveBeenNthCalledWith(1, 'Starting Codex session', 'info'); + expect(app.showToast).toHaveBeenNthCalledWith(2, 'Launch failed', 'error'); + }); + it('coalesces overlapping Run activations and disables the button while the request is active', async () => { const runBtn = { disabled: false, @@ -178,11 +208,13 @@ describe('Codex quick start settings', () => { /