mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7e700834 | ||
|
|
7936a75e28 | ||
|
|
07b9c7fd7b | ||
|
|
c00e054e0e | ||
|
|
68ae9a8c5f | ||
|
|
a49c30d173 | ||
|
|
d871d1913f | ||
|
|
ede3b05c10 | ||
|
|
c049de75db | ||
|
|
aae90599e5 | ||
|
|
2e58da7479 | ||
|
|
ba843bb272 | ||
|
|
756728e553 | ||
|
|
f2d3a7e3c1 | ||
|
|
5cc78669bd | ||
|
|
d4ccff07ca | ||
|
|
108c00e78d | ||
|
|
8a54b331e3 |
@@ -1,5 +1,38 @@
|
|||||||
# aicodeman
|
# aicodeman
|
||||||
|
|
||||||
|
## 1.19.7
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Mobile catches up: links open from a tap, terminal text can be selected and copied, long prompts stay visible while you type. Plus Files panel search, a bundled Nerd Font symbols fallback, and a per-device terminal font setting.
|
||||||
|
- **Terminal and chat links work on phones** (#321): tapping a URL or file path in terminal output now opens it (new tab, file preview, or log viewer), resolved through the same provider desktop hover uses, so tap and click can never disagree about what is a link. Dialog rows and the composer keep their existing meaning. Response-viewer links open in a new tab with `rel="noopener noreferrer"` instead of navigating the dashboard away. Wrapped links open whole: the logical-line reconstruction now stitches hard wraps through the indent their continuation carries, which also fixes desktop hover-click truncating wrapped URLs.
|
||||||
|
- **Terminal text can be copied on touch devices** (#321): long-press selects the token under the finger, drag or tap the other end to extend, and a small bar offers Copy, Line (the whole logical line, wraps included) and dismiss. Copy works on plain-HTTP installs too. Three guards keep the keyboard down and the selection alive through the browser's own long-press handling.
|
||||||
|
- **A long prompt stays visible on phones** (#321): the local-echo overlay grows upward once it would run past the last visible row (a prompt taller than the screen keeps its tail, where the cursor is), and the keyboard-driven padding shrink can no longer reclaim the space the fixed toolbar and accessory bar stand in.
|
||||||
|
- **Files panel search** (#324): `GET /api/sessions/:id/files?q=...` answers a flat match list (name or path substring, `*`/`?` globs), recursing past non-matching directories with its own match cap on top of the existing bounds; without `q` the response is byte-identical to before. Glob queries are matched without regex so a pathological pattern cannot stall the server.
|
||||||
|
- **Nerd Font prompt glyphs out of the box, custom terminal font** (#320): a bundled icons-only Symbols Nerd Font Mono fallback renders powerlevel10k/starship/oh-my-posh glyphs on every device with no font install, and App Settings gains a per-device terminal font family that is prepended to the built-in stack.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
Three contributor PRs in one release: thanks to @rounakdatta (#321), @aakhter (#324) and @comzine (#320).
|
||||||
|
|
||||||
|
- 8a54b33: Clear every production-reachable npm advisory, and fix a service-worker caching regression the upgrade exposed.
|
||||||
|
|
||||||
|
`npm audit` reported 20 advisories, but 16 were devDependencies-only (Remotion, Puppeteer, postcss, the eslint/tsx toolchain) and never reached anyone installing the package. Four reached production and are now resolved:
|
||||||
|
- **`@fastify/static` 9.1.3 to 10.1.3** — GHSA-8pvw-jcv7-9cmj, authorization bypass via non-canonical URL paths. The advisory covers `<=10.1.1`, so the entire 9.x line is affected and the fix only exists on 10.x.
|
||||||
|
- **`find-my-way` 9.6.0 to 9.8.0** — GHSA-c96f-x56v-gq3h (HTTP/2 DDoS). Not exploitable here since Codeman does not enable HTTP/2, fixed anyway.
|
||||||
|
- **`fast-uri` 3.1.2 to 3.1.5** — GHSA-v2hh-gcrm-f6hx, host confusion via a literal backslash authority delimiter.
|
||||||
|
- **`brace-expansion` to 5.0.9 / 1.1.18** — GHSA-3jxr-9vmj-r5cp, exponential-time expansion DoS.
|
||||||
|
|
||||||
|
The last three were transitive and only needed a lockfile re-resolve; no `overrides` were added.
|
||||||
|
|
||||||
|
The `@fastify/static` major changes the `setHeaders` callback's first argument from a Node `ServerResponse` to a `FastifyReply`, which required two fixes:
|
||||||
|
- `res.setHeader()` became `reply.header()`. A v9-style body throws `TypeError: res.setHeader is not a function` from inside the plugin on every static request.
|
||||||
|
- **That change also flips precedence, silently.** The callback used to write to the raw response and be overwritten by the route's staged reply headers; it now writes to the reply and wins instead. That handed `/sw.js` a year of `immutable` in place of the `no-cache, no-store` its route sets, which would pin a service worker on every client with no server-side way to recover. A route that already set `Cache-Control` now keeps it.
|
||||||
|
|
||||||
|
`ws` also appears in `npm audit` but production is already on 8.21.0, outside the vulnerable range; the only affected copy is bundled under `@remotion/renderer` and is dev-only.
|
||||||
|
|
||||||
|
Adds `test/static-cache-headers.test.ts`, which drives a real server and covers the caching contract that had no test at all, and moves the floors in `test/dependency-security.test.ts` up to the patched versions.
|
||||||
|
|
||||||
## 1.19.6
|
## 1.19.6
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ When user says "COM":
|
|||||||
|
|
||||||
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed.
|
||||||
|
|
||||||
**Version**: 1.19.6 (must match `package.json`)
|
**Version**: 1.19.7 (must match `package.json`)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
@@ -130,6 +130,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
- **Model choice flows via `settings.local.json`, NOT `--model` or env** — the App Settings **Claude Model** picker (`claudeModel` in `settings.json`) is read by `session-ui.js` at session create (wins over the legacy 1M-Opus toggles `opusContext1m`/`opusContext1mEnabled`), sent as the `modelOverride` payload field, and `updateCaseModel()` (`hooks-config.ts`) writes/deletes the `model` key in `<case>/.claude/settings.local.json`. This is the intended exception to the envOverrides rule above: model legitimately lives in `settings.local.json` (a soft default — in-session `/model` still works); env vars do not
|
||||||
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`
|
- **Multi-CLI prefix discipline** — env-var prefix is CLI-specific (`CLAUDE_CODE_*` vs `OPENCODE_*` vs `CODEX_*` vs `GEMINI_*` vs `ANTIGRAVITY_*` vs `PI_*`) and the `ALLOWED_ENV_PREFIXES` allowlist in `schemas.ts` enforces this; non-prefix exceptions are exact keys in `ALLOWED_ENV_KEYS` (currently only `CLAUDE_CONFIG_DIR`), never a widened prefix. Gemini additionally allowlists the **broad `GOOGLE_*`** namespace (intentional: Vertex AI auth needs `GOOGLE_CLOUD_PROJECT`/`GOOGLE_APPLICATION_CREDENTIALS`/`GOOGLE_GENAI_USE_VERTEXAI`; it is the loosest allowlist entry, affecting only the user's own spawned CLI). When adding a setting, decide which CLI(s) it applies to and gate the env export accordingly. Never blanket-forward all prefixes. ⚠️ Pi is the case that proves the rule: its ~34 provider keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `HF_TOKEN`, …) share NO prefix, and the allowlist is one GLOBAL list applied by a refine with no mode context, so admitting them for pi would widen it for every mode at once — they stay out, and pi users authenticate via `/login` or the server process's own env. Resolver design pattern: `docs/opencode-integration.md`, `docs/pi-integration.md`
|
||||||
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
- **Zod `.optional()` rejects `null`** — accepts `undefined` only. When the frontend builds a request body with `JSON.stringify`, an explicit `null` field is preserved on the wire and fails validation with `INVALID_INPUT`. Convert `null` → `undefined` before stringifying (e.g. `field: value ?? undefined`), or declare the schema `.nullish()`. This has caused real shipped bugs twice
|
||||||
|
- **Local-echo overlay stays on screen**: the overlay lays its wrapped lines out DOWNWARD from the prompt row, and the text has not reached the PTY yet, so the CLI never learns the prompt is long and nothing scrolls to make room. With the keyboard up only a handful of rows are visible, so a long prompt used to run off the bottom and the user typed blind. The block now grows UPWARD once it would pass the last visible row (optional `totalRows` in `RenderParams`; the line divs are opaque, so they cover transcript above), and a prompt taller than the viewport keeps its TAIL. ⚠️ Separately, `_shrinkPaddingToFit()` (mobile-handlers.js) must never shrink `main`'s padding-bottom below the MEASURED height of the fixed bars: on phones the toolbar and accessory bar are `position: fixed`, so that padding is the only thing reserving room for them, and taking it pulled the terminal's bottom row behind them. Tests: `packages/xterm-zerolag-input/test/overlay-renderer.test.ts`, `test/mobile-keyboard-bottom-padding.test.ts`.
|
||||||
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
- **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md`
|
||||||
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
- **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md`
|
||||||
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
- **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger)
|
||||||
@@ -223,7 +224,11 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
|||||||
|
|
||||||
**Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the 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. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
**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. The first load of EACH session per page load requests `full=1` (`_fullHistoryLoaded` Set); tab switches keep the cheap `?tail=` path, and scrolling up at the TOP of the buffer re-pulls `full=1` on demand (cooldown-guarded — tmux repaints bursty output in place, so browser scrollback shrinks while tmux's history stays complete). ⚠️ That re-pull must never DOWNGRADE the buffer: a repaint-mode CLI pane keeps no tmux history, so its capture is one frame and the reset+rewrite would delete history mid-scroll — `_replayWouldShrinkBuffer()` refuses it and slows that session's cooldown to 60s. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay)
|
||||||
|
|
||||||
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
**Terminal touch gestures: link taps and text selection**: on a touch device xterm's own handlers see neither — `touch-action: none` plus touchstart's preventDefault suppress the browser's compatibility mouse events, `_installMobileTapMouseGuard` drops the trusted ones that still arrive, and the synthetic `mousedown`/`mouseup` pair dispatched for mouse REPORTING goes to the `.xterm` root, an ANCESTOR of the screen element the linkifier and SelectionService listen on. So both gestures are driven explicitly. ⚠️ **A tap activates the link under it** through the SAME provider that feeds the hover linkifier (`_terminalLinkAtPoint`, containment mirroring xterm's `_linkAtPosition`), synchronously inside `touchend` — that is what keeps the user gesture `window.open` needs — and BEFORE any mouse report, mirroring `_handleDesktopTerminalClick`'s skip for a hovered link. Two rows keep their meaning: the caret's logical line (`_tapIsOnCaretLine`, where a tap places the cursor in text the USER typed) and TUI-owned rows (`_isActionableMobileTerminalTap`, answering a dialog). ⚠️ The caret line is the boundary rather than the tap INTENT, because a shell classifies every tap as `'input'` and gating on that would leave every URL in shell output inert. ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). Tests: `test/terminal-touch-tap.test.ts`.
|
||||||
|
|
||||||
|
**Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ It fires at the END of a gesture, never in `onSelectionChange` (that callback runs per cell crossed, so copying there is one clipboard write per mouse move); it only ARMS `_autoCopyPending`, and a document-level `mouseup` listener flushes. ⚠️ The flush is SYNCHRONOUS inside the handler because both clipboard paths need user activation (Firefox gates `navigator.clipboard.writeText` on it, and the plain-HTTP `execCommand` fallback must run in the gesture's own task); a timer or a wait for `onSelectionChange` loses it, invisibly in Chrome. ⚠️ Touch needs its OWN calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()`: that path `preventDefault()`s its touchend, so no mouseup ever arrives and the toggle would be dead on phones. ⚠️ Unlike `copyTerminalSelection()` it must NOT clear the selection (the text would vanish under the cursor that highlighted it) and must NOT focus the terminal (that opens the on-screen keyboard over it); focus is RESTORED to whatever held it, which only matters for the `execCommand` fallback. Guards are pure in `decideAutoCopy()` (constants.js): off, blank/whitespace-only, and a 1M-char cap (an autoscrolling drag can sweep the whole 50k-line scrollback), refused rather than truncated with a toast pointing at Ctrl+C. Silent on success except once per page load; failures toast, throttled 10s. Tests: `test/terminal-auto-copy.test.ts`.
|
||||||
|
|
||||||
|
**Terminal scrollback strip + wheel/touch forwarding** (#205): codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed shell/opencode/antigravity get a NARROW strip (alt-screen toggles only — it removes tmux's own attach-time `smcup`, which otherwise parks xterm in the scrollback-less alt buffer and turns the wheel into arrow keys). ⚠️ Gated on `useMux`: direct-PTY fallback sessions must keep the alt screen for vim/less/htop. Wheel AND touch forward to the CLI transcript for **claude ≥ 2.1.187 ONLY** at ANY scroll position (snap-to-bottom first); Shift+wheel and the `terminalWheelLocalScrollback` setting stay local. ⚠️ Codex was in that list and must never go back without a fresh measurement: codex-cli 0.147.0 ignores SGR wheel reports entirely (`mouse_any_flag=0`, inline viewport, transcript pushed into terminal scrollback), so forwarding produced a dead wheel (#227 follow-up). `_wheelScrollLines()` reads `ev.deltaMode` (Firefox = LINE units). ⚠️ When that gate is FALSE on a claude session whose local buffer is hollow (`baseY === 0`), the gesture becomes coalesced PageUp/PageDown key sends (`_maybePageCliTranscript`) instead of a no-op; ⚠️ and `getClaudeCliVersion()` must never cache a FAILED probe (one timeout used to disable forwarding process-wide until restart). ⚠️ **A click is hand-reported to the CLI only while the CLI actually has mouse tracking on.** The full strip removes the mouse DECSETs, so xterm's `mouseTrackingMode` is permanently `none` there and the browser hand-encodes SGR reports (`_sendSyntheticSgrTap`); without state it did that on EVERY click, so a stripped-mode pane running a plain shell (CLI exited, or a shell started inside a claude-mode session) received reports it never asked for and printed them as literal text (`[<0;88;20M`), garbling the next typed line. `_recordStrippedMouseMode()` (session.ts) records what the strip removes, `toState()` publishes `cliMouseTracking`, and `_shouldReportMouseToCli()` gates all three report sites on it. Only 1000/1001/1002/1003 count (1005/1006 are encodings, 1007 is alt-scroll), and the change broadcasts UNdebounced since a dialog can be clicked inside the 500ms window. `_logScrollRouting()` prints the routing decision and its inputs once per session — read it before diagnosing a scroll report. → [architecture-invariants#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding](docs/architecture-invariants.md#terminal-scrollback-strip-flavors-and-wheeltouch-forwarding)
|
||||||
|
|
||||||
**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
**Detached start + service install** (issue #231): `codeman web -d` relaunches the SAME entry script with `detached:true` (setsid), so there is no controlling terminal and no shell job entry. ⚠️ `nohup` is NOT what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and `cli.ts` handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. ⚠️ Both `-d` and `service install` must REFUSE when a server is already up on this data dir (pidfile check + `/api/status` probe): a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. ⚠️ Neither may report success it has not observed — the parent polls `/api/status` until the child answers or dies, since `launchctl load` and a clean spawn are both silent about a server that starts and immediately exits. `--stop` verifies the pid still LOOKS like a Codeman server (`ps -o command=`) before signalling, because pids get recycled. Unit/label names live in `config/service-names.ts` so install.sh, `detectSupervisor()` and `service install` cannot drift into supervising two copies; they are instance-scoped, and identical to the historical names for the default instance. `service install` bakes the installing shell's PATH into the unit (launchd gives a job `/usr/bin:/bin:/usr/sbin:/sbin`, which finds neither a Homebrew/nvm `node` nor `tmux`/`claude`) and never writes `CODEMAN_PASSWORD` into it. → [architecture-invariants#detached-start-and-service-install](docs/architecture-invariants.md#detached-start-and-service-install)
|
||||||
|
|
||||||
@@ -303,7 +308,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
|||||||
|
|
||||||
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
**SSE staleness watchdog** (`computeSseStale()` in constants.js, `_checkSseStale()` + a 5s interval in app.js): an `EventSource` that stops delivering does not always error, so `onerror` never fires, the header dot stays green, and every SSE-driven surface (tab status dots, sessions created on another device, renames) freezes until the user reloads. ⚠️ The 15s server keepalive was an SSE **comment** (`:keepalive`), and comments are **invisible to `EventSource` by spec**, so there was nothing a client could observe: it is now the named `sse:heartbeat` event (`cleanupDeadClients()`, sse-stream-manager.ts), which is exactly why the frame had to change type. ⚠️ Staleness is judged **only while the status is `connected`** and the device is online; that guard is the loop breaker, since a forced `connectSSE()` leaves `connected` immediately and cannot re-fire while a reconnect is in flight. ⚠️ The liveness stamp is applied inside `addListener` itself, so every registered handler (the `_SSE_HANDLER_MAP` wrappers AND the directly-registered ones) feeds it from one place; the heartbeat's own listener is a no-op that exists **only** to be registered, since `EventSource` drops named events nobody listens for. ⚠️ The watchdog interval is cleared at the top of `connectSSE()` and nowhere else (its only teardown path); clearing it elsewhere stacks intervals. Recovery needs no new sync path: the reconnect re-runs `handleInit` → `_resetAllAppState()`. The forced reconnect logs one diagnostic line, because a middlebox that strips heartbeats presents as "silently reconnects every 45s".
|
||||||
|
|
||||||
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), local echo overlay (7).
|
**Z-index layers**: subagent windows (1000), plan agents (1100), mobile/tablet fixed header (1200, `mobile.css`), modals on ≤768px (1300 — must beat the fixed header or the modal close button is buried), log viewers (2000), connection-loss overlay (2500, above the fixed header and modals), image popups (3000), response viewer (5000, backdrop 4999), file-preview overlay (5100 — must outrank the response viewer, which can launch it; at its old 2000 a path clicked in the chat opened BEHIND the chat), toasts/path picker (10000+, deliberately above the preview), terminal touch-selection bar (900 — above terminal content and the local-echo overlay, deliberately BELOW floating agent windows so it can never cover their controls), local echo overlay (7).
|
||||||
|
|
||||||
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,9 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough
|
|||||||
|
|
||||||
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
### Terminal scrollback: strip flavors and wheel/touch forwarding
|
||||||
|
|
||||||
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend `_sessionUsesServerMouseStrip()` mirror stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
**Two strip flavors, one carry** (#205, `session.ts:_handleTerminalOutput`): the FULL strip (`isAltScreenStripMode` = codex/claude/gemini) removes alt-screen toggles, `3J`, and mouse-tracking DECSETs. Every other mode (shell/opencode/antigravity/pi) gets the NARROW strip (`isMuxAltScreenOnlyStripMode`) — alt-screen toggles ONLY — and only when tmux-backed (`useMux`). Rationale: the tmux CLIENT emits `smcup` as its first bytes at attach, before any program runs, parking xterm in the scrollback-less alternate buffer for the whole session (touch scrolling no-ops; xterm's own wheel handler converts the wheel to Up/Down arrows = readline history cycling — both #205 symptoms). tmux never forwards a pane program's alt-screen toggles to its client (it repaints instead; measured — vim/less inside a pane emit zero to the client), so the only thing the narrow strip ever removes is tmux's own smcup. It keeps `3J` (a user's `clear` is a deliberate scrollback wipe) and the mouse DECSETs (tmux passes those through even with `mouse off`; stripping them would break htop/vim mouse support). ⚠️ The `useMux` gate is load-bearing: `startShell()`/`startInteractive()` fall back to a DIRECT PTY when mux creation fails, and there the inner program's own `?1049h` really does reach xterm — stripping it would break vim/less/htop for real. The replay path (`session-routes.ts`, via `session.usesMux`) applies the same narrow branch; the frontend mirror (`_shouldReportMouseToCli()`) stays claude/codex/gemini because only the FULL strip touches mouse DECSETs. The chunk-boundary carry (`_altScreenSeqCarry`) runs for both flavors. Tests: `test/claude-scrollback-strip.test.ts`.
|
||||||
|
|
||||||
|
⚠️ **What the full strip removes, it must REMEMBER.** Stripping the mouse DECSETs means xterm's `modes.mouseTrackingMode` is permanently `'none'` for those modes, so the browser hand-encodes click reports to compensate (`_sendSyntheticSgrTap`). With no state to consult it did that on EVERY click, which delivered mouse reports to programs that never asked for them: the same pane runs a plain shell whenever the CLI has exited or a `shell` was started inside a claude-mode session, and a shell prints the report as literal text (`[<0;88;20M`), garbling the next line typed. `_recordStrippedMouseMode()` therefore records each stripped sequence as it goes and publishes `cliMouseTracking` through `toState()`, and `_shouldReportMouseToCli()` requires it. ⚠️ Only the TRACKING modes count (1000/1001/1002/1003): 1005/1006 select an ENCODING and 1007 is alt-scroll, and counting those would put the stray reports straight back. ⚠️ The change broadcasts IMMEDIATELY rather than through `broadcastSessionStateDebounced`, because the flag flips when a dialog opens and the user can click that dialog inside the 500ms debounce window. Measured on a live claude 2.x: the CLI holds a tracking mode on continuously (so clicks keep being reported exactly as before), while a bash prompt in the same stripped mode reports nothing. Fails toward silence: after a server restart the flag is false until the CLI re-emits, which tmux does at client attach.
|
||||||
|
|
||||||
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful.
|
**Only claude ≥ 2.1.187 forwards the wheel; every other mode scrolls local scrollback** (#227 follow-up, `terminal-ui.js:_shouldForwardWheelToApp`). Codex was in the forward list until a reporter hit a completely dead wheel in codex tabs while the scrollbar drag worked. Measured against codex-cli 0.147.0 in a bare tmux: it never enables mouse tracking (`mouse_any_flag=0`) and SGR wheel reports fed to its PTY change nothing on screen, because it runs an INLINE viewport (`alternate_on=0`) and pushes its transcript into the terminal's own scrollback (tmux `history_size` grows) instead of paging in-app. So for codex, local scrollback IS the transcript and forwarding swallowed every tick. ⚠️ "The TUI is a strip mode" is NOT evidence that it consumes wheel reports — verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR TAPS stay enabled for codex (`_sessionUsesServerMouseStrip`); measured, they are no-ops that insert nothing, so click-to-position is simply unavailable there rather than harmful.
|
||||||
|
|
||||||
@@ -258,6 +260,22 @@ Invariants:
|
|||||||
|
|
||||||
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
Copy goes through `_copyText()` (Clipboard API, then hidden-textarea + `execCommand`), not raw `navigator.clipboard`, because `install.sh`'s LAN option serves plain HTTP where `navigator.clipboard` is undefined; the fallback steals focus, so the terminal is refocused afterwards. Related: xterm registers its own `copy` listener on the terminal element gated on `hasSelection()`, which is why right-click → Copy has always worked. Selection itself is unavailable on touch devices by design (`user-select: none` on the terminal subtree), and in `shell`/`opencode`/`antigravity` tabs the TUI owns the mouse, so selecting there needs Shift+drag. Tests: `test/terminal-copy-selection.test.ts` (gate + wiring invariants), `test/terminal-copy-shortcut.test.ts` (browser, real key presses).
|
||||||
|
|
||||||
|
### Auto Copy (copy-on-select)
|
||||||
|
|
||||||
|
**Auto Copy** (`autoCopySelection`, per-device, default OFF) puts a finished terminal selection on the clipboard without a keystroke. It is a thin layer over the smart-copy machinery above and shares `_copyText()` with it, but the two paths differ in every decision that matters:
|
||||||
|
|
||||||
|
- **It fires at the END of a gesture, never on selection change.** `onSelectionChange` runs for every cell a drag crosses, so copying there would be one clipboard write per mouse move. The callback only ARMS `_autoCopyPending`; the flush is a document-level `mouseup` listener installed once in `initTerminal`, plus explicit calls from the touch selection path.
|
||||||
|
- **The flush is synchronous inside the handler.** Both clipboard paths need user activation: Firefox gates `navigator.clipboard.writeText` on it, and `document.execCommand('copy')` (the plain-HTTP fallback that `install.sh`'s LAN option lands on) has to run in the gesture's own task. Deferring to a timer or waiting for `onSelectionChange` loses it, and the failure is browser-specific and invisible in Chrome.
|
||||||
|
- **The listener is on `document`, not the terminal container**, because a drag that ends outside the terminal (sweeping up past the header) delivers its mouseup to the document. Unrelated mouseups elsewhere on the page are filtered by the decision helper, not by the listener's target.
|
||||||
|
- **Touch has its own entry point.** `_endTouchSelectionGesture()` and `_selectTouchSelectionLine()` call the flush directly, because the touch path `preventDefault()`s its touchend (that is what stops the compat mouse pair from stealing the selection back), so no mouseup ever reaches the document there. Without those two calls the toggle is simply dead on a phone.
|
||||||
|
- **It must NOT do what `copyTerminalSelection()` does.** That one clears the selection (so a second `Ctrl+C` is an interrupt) and focuses the terminal. Clearing would make text vanish from under the cursor that just highlighted it, and focusing opens the on-screen keyboard over it on a phone. Focus is instead RESTORED to whatever held it before the copy, which only matters for the `execCommand` fallback (it focuses a temp textarea on the way through); the Clipboard API path never moves focus at all.
|
||||||
|
|
||||||
|
`decideAutoCopy()` (constants.js, pure) holds the guards: setting off, blank or whitespace-only text (what a drag across empty cells produces), and a `AUTO_COPY_MAX_CHARS` (1M) cap. ⚠️ The cap is not decoration: a drag off the top of the viewport autoscrolls, so one gesture can sweep the whole 50k-line scrollback. Past it the copy is REFUSED rather than truncated, with a toast pointing at `Ctrl+C`, which still copies everything through the explicit path.
|
||||||
|
|
||||||
|
⚠️ **Two dedupe rules, and both earn their place.** A genuine selection change (`pending`) always copies, so re-selecting the same text after copying something else in between still works. Otherwise only text differing from the last auto-copy does, which is what stops an unrelated mouseup from re-copying a stale selection AND what makes the first copy of a drag work at all: xterm fires `onSelectionChange` from its own document `mouseup` handler, and listener order between the two is registration order, not something this code controls. Gating on `pending` alone silently drops that first copy.
|
||||||
|
|
||||||
|
Feedback is silent on success except ONCE per page load (a feature that works by doing nothing visible cannot otherwise be told from a dead toggle); failures and refusals toast, throttled to 10s so a permanently blocked clipboard cannot paint a toast on every drag. The setting is per-device on both counts required by the settings rule: it is in `displayKeys` AND absent from the `.strict()` `SettingsUpdateSchema` (clipboard access differs by device and by origin, and the plain-HTTP LAN install has no `navigator.clipboard` at all). Tests: `test/terminal-auto-copy.test.ts`.
|
||||||
|
|
||||||
### Settings surface: App Settings, Session Options, Add Case
|
### Settings surface: App Settings, Session Options, Add Case
|
||||||
|
|
||||||
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
|
**One visual language, three modals.** `#appSettingsModal`, `#sessionOptionsModal` and `#createCaseModal` share the `set-*` surface (left rail, sections of grouped row cards, label + description on the left, control pinned right) through a single `:is(#appSettingsModal, #sessionOptionsModal, #createCaseModal)` scope in `styles.css`. An `:is()` list takes the specificity of its **most specific argument**, and all three arguments are ids, so every rule kept exactly the weight it had when the block was `#appSettingsModal`-only: nothing downstream shifted in the cascade. That property is what let the surface absorb Session Options and then Add Case in two separate commits without a cascade audit each time.
|
||||||
|
|||||||
@@ -88,6 +88,22 @@ looks exactly like a dead button.
|
|||||||
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
|
On phones this button replaces the desktop's **Run Shell** control; starting a shell moved
|
||||||
into the Run dropdown.
|
into the Run dropdown.
|
||||||
|
|
||||||
|
## Tapping, links and copying
|
||||||
|
|
||||||
|
- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's
|
||||||
|
answer in the response viewer — it opens a tab rather than navigating the dashboard away,
|
||||||
|
which on a phone would unload the whole session view.
|
||||||
|
- **Tap a file path** an agent printed and the file-preview overlay opens; a log path opens the
|
||||||
|
log viewer. Works in scrolled-up transcript too.
|
||||||
|
- A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's
|
||||||
|
numbered choice still answers the dialog even when the row contains a path — the dialog wins,
|
||||||
|
because on a phone it is the only interaction that matters.
|
||||||
|
- **Long-press to select text**, then drag, or tap the other end to extend the selection — no
|
||||||
|
hairline handles to grab. A small bar offers **Copy**, **Line** (the whole logical line,
|
||||||
|
wrapped rows included) and dismiss. Copy works on plain-HTTP installs too, where the browser
|
||||||
|
clipboard API is unavailable.
|
||||||
|
- A swipe is never mistaken for a long-press, and the keyboard stays down while you select.
|
||||||
|
|
||||||
## Scrolling and the keyboard
|
## Scrolling and the keyboard
|
||||||
|
|
||||||
- The terminal and toolbar shift up when the keyboard opens, tracked through the browser's
|
- The terminal and toolbar shift up when the keyboard opens, tracked through the browser's
|
||||||
@@ -97,6 +113,10 @@ into the Run dropdown.
|
|||||||
keeps focus so you can place the caret.
|
keeps focus so you can place the caret.
|
||||||
- A scroll is never mistaken for a tap: travel is measured from the start of the gesture, and
|
- A scroll is never mistaken for a tap: travel is measured from the start of the gesture, and
|
||||||
multi-touch never counts.
|
multi-touch never counts.
|
||||||
|
- **A long prompt stays visible.** Once what you are typing wraps past the last visible row it
|
||||||
|
grows upward over the transcript instead of sliding under the keyboard, so the end of the
|
||||||
|
sentence — where the cursor is — is always on screen. A prompt taller than the visible strip
|
||||||
|
shows its tail.
|
||||||
|
|
||||||
## Voice
|
## Voice
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ supervised by systemd or launchd; npm installs report as non-updatable. See
|
|||||||
| CJK Input | Off | IME composition through a dedicated text field. |
|
| CJK Input | Off | IME composition through a dedicated text field. |
|
||||||
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
|
| Extended Keyboard Bar | Per device | Which accessory bar phones get. Shell sessions override it while they are active. |
|
||||||
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
|
| Wheel Scrolls Local History | Off | Keeps the wheel on the local buffer instead of forwarding it to the CLI. |
|
||||||
|
| Auto Copy Selection | Off | Copies highlighted terminal text to the clipboard the moment you finish selecting it. Ctrl+C still copies on demand. |
|
||||||
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
| WebGL Renderer | On | With a GPU-stall watchdog that falls back to DOM rendering. |
|
||||||
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
| Gesture Control | Off | Camera hand tracking. Also needs `CODEMAN_GESTURE=1` on the server. |
|
||||||
|
|
||||||
|
|||||||
Generated
+46
-29
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.6",
|
"version": "1.19.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.6",
|
"version": "1.19.7",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"@fastify/compress": "^8.3.1",
|
"@fastify/compress": "^8.3.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^10.1.3",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-serialize": "^0.14.0",
|
"@xterm/addon-serialize": "^0.14.0",
|
||||||
@@ -1454,9 +1454,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/static": {
|
"node_modules/@fastify/static": {
|
||||||
"version": "9.1.3",
|
"version": "10.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz",
|
||||||
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
|
"integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1470,13 +1470,30 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/accept-negotiator": "^2.0.0",
|
"@fastify/accept-negotiator": "^2.0.0",
|
||||||
|
"@fastify/error": "^4.0.0",
|
||||||
"@fastify/send": "^4.0.0",
|
"@fastify/send": "^4.0.0",
|
||||||
"content-disposition": "^1.0.1",
|
"content-disposition": "^2.0.1",
|
||||||
"fastify-plugin": "^5.0.0",
|
"fastify-plugin": "^6.0.0",
|
||||||
"fastq": "^1.17.1",
|
"fastq": "^1.17.1",
|
||||||
"glob": "^13.0.0"
|
"glob": "^13.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fastify/static/node_modules/fastify-plugin": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@fastify/websocket": {
|
"node_modules/@fastify/websocket": {
|
||||||
"version": "11.2.0",
|
"version": "11.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.2.0.tgz",
|
||||||
@@ -4136,16 +4153,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
@@ -5078,9 +5095,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5431,9 +5448,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.1.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
|
||||||
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -6552,9 +6569,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -6661,9 +6678,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/find-my-way": {
|
"node_modules/find-my-way": {
|
||||||
"version": "9.6.0",
|
"version": "9.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.8.0.tgz",
|
||||||
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
|
"integrity": "sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
@@ -6905,15 +6922,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "5.0.6",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/minimatch": {
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
@@ -12344,7 +12361,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/xterm-zerolag-input": {
|
"packages/xterm-zerolag-input": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@xterm/headless": "^6.0.0",
|
"@xterm/headless": "^6.0.0",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aicodeman",
|
"name": "aicodeman",
|
||||||
"version": "1.19.6",
|
"version": "1.19.7",
|
||||||
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "Mission control for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
"@fastify/compress": "^8.3.1",
|
"@fastify/compress": "^8.3.1",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^10.1.3",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-serialize": "^0.14.0",
|
"@xterm/addon-serialize": "^0.14.0",
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# xterm-zerolag-input
|
# xterm-zerolag-input
|
||||||
|
|
||||||
|
## 0.3.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Mobile catches up: links open from a tap, terminal text can be selected and copied, long prompts stay visible while you type. Plus Files panel search, a bundled Nerd Font symbols fallback, and a per-device terminal font setting.
|
||||||
|
- **Terminal and chat links work on phones** (#321): tapping a URL or file path in terminal output now opens it (new tab, file preview, or log viewer), resolved through the same provider desktop hover uses, so tap and click can never disagree about what is a link. Dialog rows and the composer keep their existing meaning. Response-viewer links open in a new tab with `rel="noopener noreferrer"` instead of navigating the dashboard away. Wrapped links open whole: the logical-line reconstruction now stitches hard wraps through the indent their continuation carries, which also fixes desktop hover-click truncating wrapped URLs.
|
||||||
|
- **Terminal text can be copied on touch devices** (#321): long-press selects the token under the finger, drag or tap the other end to extend, and a small bar offers Copy, Line (the whole logical line, wraps included) and dismiss. Copy works on plain-HTTP installs too. Three guards keep the keyboard down and the selection alive through the browser's own long-press handling.
|
||||||
|
- **A long prompt stays visible on phones** (#321): the local-echo overlay grows upward once it would run past the last visible row (a prompt taller than the screen keeps its tail, where the cursor is), and the keyboard-driven padding shrink can no longer reclaim the space the fixed toolbar and accessory bar stand in.
|
||||||
|
- **Files panel search** (#324): `GET /api/sessions/:id/files?q=...` answers a flat match list (name or path substring, `*`/`?` globs), recursing past non-matching directories with its own match cap on top of the existing bounds; without `q` the response is byte-identical to before. Glob queries are matched without regex so a pathological pattern cannot stall the server.
|
||||||
|
- **Nerd Font prompt glyphs out of the box, custom terminal font** (#320): a bundled icons-only Symbols Nerd Font Mono fallback renders powerlevel10k/starship/oh-my-posh glyphs on every device with no font install, and App Settings gains a per-device terminal font family that is prepended to the built-in stack.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
Three contributor PRs in one release: thanks to @rounakdatta (#321), @aakhter (#324) and @comzine (#320).
|
||||||
|
|
||||||
## 0.3.0
|
## 0.3.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "xterm-zerolag-input",
|
"name": "xterm-zerolag-input",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
"description": "Instant keystroke feedback overlay for xterm.js: Mosh-inspired local echo that removes perceived input latency over SSH, tunnels and other high-RTT connections",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.cjs",
|
"main": "dist/index.cjs",
|
||||||
|
|||||||
@@ -65,38 +65,71 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams):
|
|||||||
charTop,
|
charTop,
|
||||||
charHeight,
|
charHeight,
|
||||||
promptRow,
|
promptRow,
|
||||||
|
totalRows,
|
||||||
font,
|
font,
|
||||||
showCursor,
|
showCursor,
|
||||||
cursorColor,
|
cursorColor,
|
||||||
terminal,
|
terminal,
|
||||||
} = params;
|
} = params;
|
||||||
|
|
||||||
// Position container at prompt row.
|
// ── Keep what is being typed ON SCREEN ────────────────────────────
|
||||||
|
//
|
||||||
|
// The overlay lays its wrapped lines out DOWNWARD from the prompt row, and
|
||||||
|
// nothing past the last terminal row is visible. On a phone the strip left
|
||||||
|
// above the on-screen keyboard is only a handful of rows, so a prompt long
|
||||||
|
// enough to wrap ran off the bottom and the user was typing blind — the tail
|
||||||
|
// of their own sentence, the part they are actually looking at, hidden behind
|
||||||
|
// the keyboard.
|
||||||
|
//
|
||||||
|
// So the composer grows UPWARD once it reaches the last row, exactly as a real
|
||||||
|
// terminal's does: every line div is opaque (see makeLine), so the lines cover
|
||||||
|
// transcript rows above instead of vanishing under the keyboard below, and the
|
||||||
|
// newest text stays where the eye is. A prompt taller than the whole viewport
|
||||||
|
// keeps its TAIL for the same reason.
|
||||||
|
//
|
||||||
|
// `startCol` indents only the line that begins at the prompt marker, so it is
|
||||||
|
// dropped along with that line when the tail is all that fits.
|
||||||
|
const rows = totalRows && totalRows > 0 ? totalRows : terminal?.rows;
|
||||||
|
let visibleLines = lines;
|
||||||
|
let keepsPromptLine = true;
|
||||||
|
let topRow = promptRow;
|
||||||
|
if (rows && rows > 0) {
|
||||||
|
if (lines.length > rows) {
|
||||||
|
visibleLines = lines.slice(lines.length - rows);
|
||||||
|
keepsPromptLine = false;
|
||||||
|
topRow = 0;
|
||||||
|
} else if (promptRow + lines.length > rows) {
|
||||||
|
topRow = rows - lines.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
topRow = Math.max(0, topRow);
|
||||||
|
|
||||||
container.style.left = '0px';
|
container.style.left = '0px';
|
||||||
container.style.top = promptRow * cellH + 'px';
|
container.style.top = topRow * cellH + 'px';
|
||||||
|
|
||||||
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
// Clear and rebuild (typically 1-3 line divs, negligible cost)
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
const fullWidthPx = totalCols * cellW;
|
const fullWidthPx = totalCols * cellW;
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < visibleLines.length; i++) {
|
||||||
const leftPx = i === 0 ? startCol * cellW : 0;
|
const indents = i === 0 && keepsPromptLine;
|
||||||
const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx;
|
const leftPx = indents ? startCol * cellW : 0;
|
||||||
|
const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx;
|
||||||
const topPx = i * cellH;
|
const topPx = i * cellH;
|
||||||
const lineEl = makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
const lineEl = makeLine(visibleLines[i], leftPx, topPx, widthPx, cellH, cellW, charTop, charHeight, font, terminal);
|
||||||
container.appendChild(lineEl);
|
container.appendChild(lineEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block cursor at end of last line (use visual width for CJK support)
|
// Block cursor at end of last line (use visual width for CJK support)
|
||||||
if (showCursor) {
|
if (showCursor) {
|
||||||
const lastLine = lines[lines.length - 1];
|
const lastLine = visibleLines[visibleLines.length - 1];
|
||||||
const lastLineLeft = lines.length === 1 ? startCol : 0;
|
const lastLineLeft = visibleLines.length === 1 && keepsPromptLine ? startCol : 0;
|
||||||
const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
|
const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine);
|
||||||
if (cursorCol < totalCols) {
|
if (cursorCol < totalCols) {
|
||||||
const cursor = document.createElement('span');
|
const cursor = document.createElement('span');
|
||||||
cursor.style.cssText = 'position:absolute;display:inline-block';
|
cursor.style.cssText = 'position:absolute;display:inline-block';
|
||||||
cursor.style.left = cursorCol * cellW + 'px';
|
cursor.style.left = cursorCol * cellW + 'px';
|
||||||
cursor.style.top = (lines.length - 1) * cellH + 'px';
|
cursor.style.top = (visibleLines.length - 1) * cellH + 'px';
|
||||||
cursor.style.width = cellW + 'px';
|
cursor.style.width = cellW + 'px';
|
||||||
cursor.style.height = cellH + 'px';
|
cursor.style.height = cellH + 'px';
|
||||||
cursor.style.backgroundColor = cursorColor;
|
cursor.style.backgroundColor = cursorColor;
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ export interface RenderParams {
|
|||||||
/** Height of the character rendering area (px). */
|
/** Height of the character rendering area (px). */
|
||||||
charHeight: number;
|
charHeight: number;
|
||||||
promptRow: number;
|
promptRow: number;
|
||||||
|
/**
|
||||||
|
* Visible terminal rows. When given, the overlay is kept ON SCREEN: it grows
|
||||||
|
* upward instead of running off the bottom edge, and a wrapped prompt taller
|
||||||
|
* than the viewport keeps its tail. Omit to lay out straight down from
|
||||||
|
* `promptRow` (the historical behaviour).
|
||||||
|
*/
|
||||||
|
totalRows?: number;
|
||||||
font: FontStyle;
|
font: FontStyle;
|
||||||
showCursor: boolean;
|
showCursor: boolean;
|
||||||
cursorColor: string;
|
cursorColor: string;
|
||||||
|
|||||||
@@ -565,7 +565,10 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
|
|
||||||
// Skip redundant re-renders — include text content to detect
|
// Skip redundant re-renders — include text content to detect
|
||||||
// same-length changes (e.g., setFlushed with different text)
|
// same-length changes (e.g., setFlushed with different text)
|
||||||
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`;
|
// `rows` is part of the key: the layout is clamped to the visible rows
|
||||||
|
// (see renderOverlay), so a keyboard opening — which changes rows without
|
||||||
|
// changing the text — must not be skipped as a redundant render.
|
||||||
|
const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._terminal.rows}:${this._flushedOffset}`;
|
||||||
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return;
|
||||||
this._lastRenderKey = renderKey;
|
this._lastRenderKey = renderKey;
|
||||||
|
|
||||||
@@ -612,6 +615,7 @@ export class ZerolagInputAddon implements XtermAddon {
|
|||||||
charTop,
|
charTop,
|
||||||
charHeight,
|
charHeight,
|
||||||
promptRow: activePrompt.row,
|
promptRow: activePrompt.row,
|
||||||
|
totalRows: this._terminal.rows,
|
||||||
font: this._font,
|
font: this._font,
|
||||||
showCursor: this._options.showCursor,
|
showCursor: this._options.showCursor,
|
||||||
cursorColor,
|
cursorColor,
|
||||||
|
|||||||
@@ -418,3 +418,88 @@ describe('stringCellWidth', () => {
|
|||||||
expect(stringCellWidth(null, '')).toBe(0);
|
expect(stringCellWidth(null, '')).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('renderOverlay — staying on screen (totalRows)', () => {
|
||||||
|
// A phone with the keyboard up leaves only a handful of terminal rows. The
|
||||||
|
// overlay lays its wrapped lines out downward from the prompt row, so a long
|
||||||
|
// prompt used to run off the bottom edge and the user typed blind, with the
|
||||||
|
// tail of their own sentence behind the keyboard. With totalRows known, the
|
||||||
|
// composer grows UPWARD instead — the line divs are opaque, so they cover
|
||||||
|
// transcript above rather than disappearing below.
|
||||||
|
const linesOf = (n: number) => Array.from({ length: n }, (_, i) => `line${i}`);
|
||||||
|
const lineDivs = (container: HTMLDivElement) =>
|
||||||
|
Array.from(container.children).filter((el) => el.tagName === 'DIV') as HTMLDivElement[];
|
||||||
|
|
||||||
|
it('lifts the block so its last line lands on the last visible row', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: linesOf(5), promptRow: 10, totalRows: 12, cellH: 17 }));
|
||||||
|
|
||||||
|
// 10 + 5 would end on row 14 of a 12-row screen; the block starts at 7 instead.
|
||||||
|
expect(container.style.top).toBe(7 * 17 + 'px');
|
||||||
|
expect(lineDivs(container)).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the prompt row alone when the block already fits', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: linesOf(3), promptRow: 5, totalRows: 24, cellH: 17 }));
|
||||||
|
|
||||||
|
expect(container.style.top).toBe(5 * 17 + 'px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the TAIL when the prompt is taller than the whole viewport', () => {
|
||||||
|
// The end is where the cursor is, and where the user is looking.
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, cellH: 20 }));
|
||||||
|
|
||||||
|
const divs = lineDivs(container);
|
||||||
|
expect(container.style.top).toBe('0px');
|
||||||
|
expect(divs).toHaveLength(3);
|
||||||
|
expect(divs.map((d) => d.textContent)).toEqual(['line3', 'line4', 'line5']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops the prompt indent once the prompt line is no longer shown', () => {
|
||||||
|
// startCol indents only the line that begins at the prompt marker.
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({ lines: linesOf(6), promptRow: 2, totalRows: 3, startCol: 5, cellW: 10, totalCols: 80 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = lineDivs(container)[0];
|
||||||
|
expect(first.style.left).toBe('0px');
|
||||||
|
expect(first.style.width).toBe(80 * 10 + 'px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rides the cursor on the last VISIBLE line', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({ lines: ['aaa', 'bbb', 'ccc', 'ddd'], promptRow: 9, totalRows: 3, cellH: 20, cellW: 10, startCol: 4 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const cursor = Array.from(container.children).find((el) => el.tagName === 'SPAN') as HTMLSpanElement;
|
||||||
|
// Tail is the last 3 lines, so the cursor sits on row 2 (0-based) of the block…
|
||||||
|
expect(cursor.style.top).toBe(2 * 20 + 'px');
|
||||||
|
// …at column 3, NOT startCol + 3: the indented prompt line is not shown.
|
||||||
|
expect(cursor.style.left).toBe(3 * 10 + 'px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lays out straight down when totalRows is absent (unchanged behaviour)', () => {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(container, makeParams({ lines: linesOf(9), promptRow: 20, cellH: 17 }));
|
||||||
|
|
||||||
|
expect(container.style.top).toBe(20 * 17 + 'px');
|
||||||
|
expect(lineDivs(container)).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the terminal row count when totalRows is not passed', () => {
|
||||||
|
// The addon passes totalRows, but a stale bundle / third-party caller may not.
|
||||||
|
const container = document.createElement('div');
|
||||||
|
renderOverlay(
|
||||||
|
container,
|
||||||
|
makeParams({ lines: linesOf(4), promptRow: 8, cellH: 17, terminal: { rows: 10, cols: 80 } as never })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.style.top).toBe(6 * 17 + 'px');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+59
-1
@@ -415,6 +415,15 @@ export class Session extends EventEmitter {
|
|||||||
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
// sequences split across PTY chunks can't slip past the alt-screen/scrollback
|
||||||
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
// strip (see _handleTerminalOutput / isAltScreenStripMode)
|
||||||
private _altScreenSeqCarry: string = '';
|
private _altScreenSeqCarry: string = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mouse-tracking DECSET modes the CLI currently has ON, as observed while
|
||||||
|
* STRIPPING them out of the stream below. Kept as a set rather than a boolean
|
||||||
|
* because a TUI may enable 1002 and later disable 1000 (a mode it never
|
||||||
|
* enabled); tracking is on while any of them is.
|
||||||
|
*/
|
||||||
|
private _cliMouseModes = new Set<number>();
|
||||||
|
private _cliMouseTracking = false;
|
||||||
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null;
|
||||||
private rejectPromise: ((reason: Error) => void) | null = null;
|
private rejectPromise: ((reason: Error) => void) | null = null;
|
||||||
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
private _promptResolved: boolean = false; // Guard against race conditions in runPrompt
|
||||||
@@ -1285,6 +1294,7 @@ export class Session extends EventEmitter {
|
|||||||
niceValue: this._niceConfig.niceValue,
|
niceValue: this._niceConfig.niceValue,
|
||||||
color: this._color,
|
color: this._color,
|
||||||
flickerFilterEnabled: this._flickerFilterEnabled,
|
flickerFilterEnabled: this._flickerFilterEnabled,
|
||||||
|
cliMouseTracking: this._cliMouseTracking || undefined,
|
||||||
cliVersion: this._cliVersion || undefined,
|
cliVersion: this._cliVersion || undefined,
|
||||||
cliModel: this._cliModel || undefined,
|
cliModel: this._cliModel || undefined,
|
||||||
cliAccountType: this._cliAccountType || undefined,
|
cliAccountType: this._cliAccountType || undefined,
|
||||||
@@ -1546,6 +1556,46 @@ export class Session extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember whether the CLI currently wants to be told about mouse clicks.
|
||||||
|
*
|
||||||
|
* The strip in {@link _handleTerminalOutput} is the ONLY place these sequences
|
||||||
|
* exist. After it, neither the browser nor xterm can ever learn that the CLI
|
||||||
|
* asked for mouse tracking, so `terminal.modes.mouseTrackingMode` is
|
||||||
|
* permanently 'none' for a stripped mode. The browser hand-encodes SGR reports
|
||||||
|
* to compensate (`_sendSyntheticSgrTap` in terminal-ui.js), and with no state
|
||||||
|
* to consult it had to do that on EVERY click, delivering mouse reports to a
|
||||||
|
* CLI that never asked for them. Publishing this through `toState()` is what
|
||||||
|
* lets the browser report a click only when the CLI is listening.
|
||||||
|
*
|
||||||
|
* Only the TRACKING modes count. 1005/1006 select an encoding and 1007 is
|
||||||
|
* alt-scroll; a CLI that picks SGR encoding without turning a tracking mode on
|
||||||
|
* is not asking about clicks, and counting those would put the stray reports
|
||||||
|
* straight back.
|
||||||
|
*
|
||||||
|
* This must stay in lockstep with the strip regex that calls it: a sequence
|
||||||
|
* removed from the stream but not recorded here is one the browser can neither
|
||||||
|
* see nor be told about.
|
||||||
|
*/
|
||||||
|
private _recordStrippedMouseMode(seq: string): void {
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const match = /\x1b\[\?(\d+)([hl])$/.exec(seq);
|
||||||
|
if (!match) return;
|
||||||
|
const mode = Number(match[1]);
|
||||||
|
if (mode !== 1000 && mode !== 1001 && mode !== 1002 && mode !== 1003) return;
|
||||||
|
if (match[2] === 'h') this._cliMouseModes.add(mode);
|
||||||
|
else this._cliMouseModes.delete(mode);
|
||||||
|
this._syncCliMouseTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit only on a real transition: a TUI re-emitting its enable on every repaint costs nothing. */
|
||||||
|
private _syncCliMouseTracking(): void {
|
||||||
|
const active = this._cliMouseModes.size > 0;
|
||||||
|
if (active === this._cliMouseTracking) return;
|
||||||
|
this._cliMouseTracking = active;
|
||||||
|
this.emit('mouseTrackingChanged', active);
|
||||||
|
}
|
||||||
|
|
||||||
private _handleTerminalOutput(data: string): void {
|
private _handleTerminalOutput(data: string): void {
|
||||||
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
// Codex AND Claude Code emit sequences that wipe xterm.js scrollback, plus
|
||||||
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
// mouse-tracking enables that hijack the scroll wheel so the user can't reach
|
||||||
@@ -1598,7 +1648,10 @@ export class Session extends EventEmitter {
|
|||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/\x1b\[3J/g, '')
|
.replace(/\x1b\[3J/g, '')
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, '');
|
.replace(/\x1b\[\?(?:1000|1001|1002|1003|1005|1006|1007)[hl]/g, (seq) => {
|
||||||
|
this._recordStrippedMouseMode(seq);
|
||||||
|
return '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2525,6 +2578,11 @@ export class Session extends EventEmitter {
|
|||||||
this._messages = [];
|
this._messages = [];
|
||||||
this._lineBuffer = '';
|
this._lineBuffer = '';
|
||||||
this._altScreenSeqCarry = '';
|
this._altScreenSeqCarry = '';
|
||||||
|
// A restarted pane starts with no mouse mode: the new program has not asked
|
||||||
|
// for one yet, and carrying the old CLI's state over would report clicks
|
||||||
|
// into a program that never enabled tracking.
|
||||||
|
this._cliMouseModes.clear();
|
||||||
|
this._syncCliMouseTracking();
|
||||||
this._markActivity(true);
|
this._markActivity(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -500,6 +500,14 @@ export interface SessionState {
|
|||||||
color?: SessionColor;
|
color?: SessionColor;
|
||||||
/** Flicker filter enabled (buffers output after screen clears) */
|
/** Flicker filter enabled (buffers output after screen clears) */
|
||||||
flickerFilterEnabled?: boolean;
|
flickerFilterEnabled?: boolean;
|
||||||
|
/**
|
||||||
|
* True while the CLI in the pane has a mouse-tracking DECSET on, as observed
|
||||||
|
* by the server on its way out of the stream (those sequences are stripped for
|
||||||
|
* claude/codex/gemini, so the browser can never see them itself). The browser
|
||||||
|
* hand-encodes a click report ONLY when this is true; without it, every click
|
||||||
|
* sent mouse reports to a CLI that never asked for them.
|
||||||
|
*/
|
||||||
|
cliMouseTracking?: boolean;
|
||||||
/** Claude Code CLI version (parsed from terminal, e.g., "2.1.27") */
|
/** Claude Code CLI version (parsed from terminal, e.g., "2.1.27") */
|
||||||
cliVersion?: string;
|
cliVersion?: string;
|
||||||
/** Claude model in use (parsed from terminal, e.g., "Opus 4.5") */
|
/** Claude model in use (parsed from terminal, e.g., "Opus 4.5") */
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Pure file-name/path query matcher for the Files panel search
|
||||||
|
* (COD-236). Compiles a user query string into a reusable predicate so the
|
||||||
|
* server-side file walk can prune to matching entries instead of streaming the
|
||||||
|
* whole tree.
|
||||||
|
*
|
||||||
|
* Semantics:
|
||||||
|
* - Empty / whitespace-only query → `compileFileQuery` returns `null` (the
|
||||||
|
* caller treats this as "no search", falling back to the full tree). A query
|
||||||
|
* longer than `MAX_QUERY_LENGTH` compiles to `null` too: no honest filename
|
||||||
|
* search is that long, and the glob walk below is O(text · pattern).
|
||||||
|
* - A query containing a glob metachar (`*` or `?`) matches anchored and
|
||||||
|
* case-insensitively, `*` spanning any run (slashes included) and `?` exactly
|
||||||
|
* one character; every other character matches literally.
|
||||||
|
* - Otherwise the query is a plain case-insensitive substring.
|
||||||
|
* - When the query contains a `/` it matches against the relative path; else it
|
||||||
|
* matches against the bare entry name.
|
||||||
|
*
|
||||||
|
* ⚠️ Globs are matched by `globMatch` below, never by compiling the query into
|
||||||
|
* a RegExp: `*a*a*a…` translated to `^.*a.*a.*a…$` is a classic backtracking
|
||||||
|
* blowup, evaluated synchronously against every walked path — a pathological
|
||||||
|
* query could freeze the event loop for the whole server (the same reason
|
||||||
|
* `search-service.ts` is regex-free). The two-pointer wildcard walk is
|
||||||
|
* O(text · pattern) worst case, with both operands short by construction.
|
||||||
|
*
|
||||||
|
* No fs / IO — safe to unit-test directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type FileQueryMatcher = (name: string, relativePath: string) => boolean;
|
||||||
|
|
||||||
|
// Longer than any honest file search; bounds the O(text · pattern) glob walk.
|
||||||
|
const MAX_QUERY_LENGTH = 256;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anchored glob match, linear-space two-pointer walk (no RegExp — see the
|
||||||
|
* fileoverview). `pattern` must already be lowercased; `text` is lowercased
|
||||||
|
* here so one compiled matcher serves many entries.
|
||||||
|
*/
|
||||||
|
function globMatch(pattern: string, rawText: string): boolean {
|
||||||
|
const text = rawText.toLowerCase();
|
||||||
|
let p = 0;
|
||||||
|
let t = 0;
|
||||||
|
let starP = -1;
|
||||||
|
let starT = -1;
|
||||||
|
while (t < text.length) {
|
||||||
|
const pc = p < pattern.length ? pattern[p] : '';
|
||||||
|
if (pc === '?' || pc === text[t]) {
|
||||||
|
p++;
|
||||||
|
t++;
|
||||||
|
} else if (pc === '*') {
|
||||||
|
// Remember the star; try matching zero characters first, and on a later
|
||||||
|
// mismatch re-expand it one character at a time from here.
|
||||||
|
starP = p++;
|
||||||
|
starT = t;
|
||||||
|
} else if (starP !== -1) {
|
||||||
|
p = starP + 1;
|
||||||
|
t = ++starT;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (p < pattern.length && pattern[p] === '*') p++;
|
||||||
|
return p === pattern.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compile a query string into a matcher predicate, or `null` when the query is
|
||||||
|
* empty/whitespace or overlong (caller treats null as "no search").
|
||||||
|
*/
|
||||||
|
export function compileFileQuery(query: string): FileQueryMatcher | null {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed === '' || trimmed.length > MAX_QUERY_LENGTH) return null;
|
||||||
|
|
||||||
|
const matchesPath = trimmed.includes('/');
|
||||||
|
const isGlob = trimmed.includes('*') || trimmed.includes('?');
|
||||||
|
|
||||||
|
if (isGlob) {
|
||||||
|
const pattern = trimmed.toLowerCase();
|
||||||
|
return (name, relativePath) => globMatch(pattern, matchesPath ? relativePath : name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const needle = trimmed.toLowerCase();
|
||||||
|
return (name, relativePath) => (matchesPath ? relativePath : name).toLowerCase().includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: compile the query and apply it in one call. Returns false when
|
||||||
|
* the query compiles to null (empty).
|
||||||
|
*/
|
||||||
|
export function matchFileQuery(query: string, name: string, relativePath: string): boolean {
|
||||||
|
const matcher = compileFileQuery(query);
|
||||||
|
return matcher ? matcher(name, relativePath) : false;
|
||||||
|
}
|
||||||
@@ -35,3 +35,5 @@ export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
|
|||||||
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
|
||||||
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-cli-resolver.js';
|
||||||
export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js';
|
export { resolvePiDir, isPiAvailable, getPiCliVersion } from './pi-cli-resolver.js';
|
||||||
|
export { compileFileQuery, matchFileQuery } from './file-query.js';
|
||||||
|
export type { FileQueryMatcher } from './file-query.js';
|
||||||
|
|||||||
@@ -2053,6 +2053,28 @@ class CodemanApp {
|
|||||||
wrap.appendChild(actions);
|
wrap.appendChild(actions);
|
||||||
wrap.appendChild(pre);
|
wrap.appendChild(pre);
|
||||||
});
|
});
|
||||||
|
// Links open in a NEW tab.
|
||||||
|
//
|
||||||
|
// marked emits a bare `<a href>` and the sanitizer's allowlist has no
|
||||||
|
// `target`, so a tap in the chat NAVIGATED THE APP AWAY: on a phone that
|
||||||
|
// unloads the whole dashboard — SSE, terminal buffers, unsent composer
|
||||||
|
// text — and the OS back gesture reloads it from scratch, which is what
|
||||||
|
// "links don't open" reads as on mobile, with no middle-click or
|
||||||
|
// open-in-new-tab affordance to work around it.
|
||||||
|
//
|
||||||
|
// This pass runs AFTER sanitizing, so it is the only source of these two
|
||||||
|
// attributes: whatever an agent wrote is already gone, and `rel` is set on
|
||||||
|
// the same element in the same breath, so no page Codeman opens ever gets
|
||||||
|
// a `window.opener` handle back (reverse tabnabbing).
|
||||||
|
//
|
||||||
|
// A fragment link stays in-page, and mailto:/tel: are handed to the OS —
|
||||||
|
// giving those a target just strands an empty tab.
|
||||||
|
tmpl.content.querySelectorAll('a[href]').forEach((a) => {
|
||||||
|
const href = a.getAttribute('href') || '';
|
||||||
|
if (!href || href.startsWith('#') || /^(?:mailto|tel):/i.test(href)) return;
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
a.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
});
|
||||||
return tmpl.innerHTML;
|
return tmpl.innerHTML;
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -527,6 +527,90 @@ function sortSessionsByActivity(rows) {
|
|||||||
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
return (Array.isArray(rows) ? rows.slice() : []).sort(compareSessionActivity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Terminal font stack — the single source for every xterm surface (the main
|
||||||
|
// terminal in terminal-ui.js, the log-viewer terminal in panels-ui.js).
|
||||||
|
// "Symbols Nerd Font Mono" is a bundled icons-only webfont (fonts/ +
|
||||||
|
// @font-face in styles.css): browsers fall back PER GLYPH, so Nerd Font
|
||||||
|
// prompt icons (powerline segments, folder/git glyphs from p10k, starship,
|
||||||
|
// oh-my-posh) render even though the text fonts carry no private-use-area
|
||||||
|
// symbols — while all readable text keeps coming from the text fonts.
|
||||||
|
const TERMINAL_FONT_DEFAULT_STACK =
|
||||||
|
'"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Symbols Nerd Font Mono", monospace';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the xterm fontFamily from the per-device `terminalFontFamily`
|
||||||
|
* setting. A user-set family (or comma-separated list) is PREPENDED to the
|
||||||
|
* built-in stack, never a replacement — the symbols fallback and a final
|
||||||
|
* `monospace` must survive whatever the user types. Blank input yields the
|
||||||
|
* default. Unquoted names that need quoting for CSS (spaces, digits leading,
|
||||||
|
* etc.) are quoted; embedded quotes are stripped rather than escaped, since
|
||||||
|
* a font name cannot contain them anyway.
|
||||||
|
*/
|
||||||
|
function resolveTerminalFontFamily(custom) {
|
||||||
|
const raw = typeof custom === 'string' ? custom.trim() : '';
|
||||||
|
if (!raw) return TERMINAL_FONT_DEFAULT_STACK;
|
||||||
|
const families = raw
|
||||||
|
.split(',')
|
||||||
|
.map((f) => f.trim().replace(/^["']|["']$/g, '').replace(/["']/g, '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
// Drop generic families the user may append — the default stack already
|
||||||
|
// ends in `monospace`, and a duplicate earlier entry would shadow the
|
||||||
|
// symbols fallback behind it.
|
||||||
|
.filter((f) => !/^(monospace|serif|sans-serif|system-ui)$/i.test(f))
|
||||||
|
.map((f) => (/^[A-Za-z][A-Za-z0-9-]*$/.test(f) ? f : `"${f}"`));
|
||||||
|
if (!families.length) return TERMINAL_FONT_DEFAULT_STACK;
|
||||||
|
return `${families.join(', ')}, ${TERMINAL_FONT_DEFAULT_STACK}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Auto Copy (copy-on-select). Pure decision, so every guard below is testable
|
||||||
|
// without a terminal, a clipboard, or a browser.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper bound on an AUTO-copied selection.
|
||||||
|
*
|
||||||
|
* A drag that runs off the top of the viewport autoscrolls, so one gesture can
|
||||||
|
* sweep the entire 50k-line scrollback (millions of characters), and writing
|
||||||
|
* that to the clipboard on every mouseup is a real hazard on a phone. Past the
|
||||||
|
* cap the copy is REFUSED rather than truncated (half a selection on the
|
||||||
|
* clipboard is worse than none) and the user is told to press Ctrl+C, which
|
||||||
|
* still copies the whole thing through the explicit path.
|
||||||
|
*/
|
||||||
|
const AUTO_COPY_MAX_CHARS = 1_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an auto-copy attempt should do at the end of a selection gesture.
|
||||||
|
*
|
||||||
|
* `pending` is set by xterm's onSelectionChange and cleared on every flush;
|
||||||
|
* `lastCopied` is the text this surface auto-copied last. Either one alone is
|
||||||
|
* wrong, which is why both are here:
|
||||||
|
*
|
||||||
|
* - onSelectionChange does not reliably fire BEFORE the mouseup that ends the
|
||||||
|
* drag (xterm fires it from its own document-level mouseup handler, and
|
||||||
|
* listener order between the two is registration order, not something this
|
||||||
|
* code controls). Gating on `pending` alone would silently drop the first
|
||||||
|
* copy of a drag-selection.
|
||||||
|
* - Gating on `text !== lastCopied` alone drops a deliberate re-selection of
|
||||||
|
* the same text after the user copied something else in between, and it
|
||||||
|
* would let any unrelated mouseup on the page re-copy a stale selection.
|
||||||
|
*
|
||||||
|
* So: a genuine selection change (`pending`) always copies, and otherwise only
|
||||||
|
* text that differs from the last auto-copy does.
|
||||||
|
*
|
||||||
|
* @param {{enabled?: boolean, text?: string, lastCopied?: string, pending?: boolean}} params
|
||||||
|
* @returns {'copy'|'skip'|'too-large'}
|
||||||
|
*/
|
||||||
|
function decideAutoCopy({ enabled, text, lastCopied, pending } = {}) {
|
||||||
|
if (!enabled) return 'skip';
|
||||||
|
// Whitespace-only is what a drag across blank cells produces; putting a wall
|
||||||
|
// of spaces on the clipboard is never what the gesture meant.
|
||||||
|
if (typeof text !== 'string' || !text.trim()) return 'skip';
|
||||||
|
if (!pending && text === lastCopied) return 'skip';
|
||||||
|
if (text.length > AUTO_COPY_MAX_CHARS) return 'too-large';
|
||||||
|
return 'copy';
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
|
||||||
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
|
||||||
@@ -560,6 +644,14 @@ if (typeof window !== 'undefined') {
|
|||||||
compare: compareSessionActivity,
|
compare: compareSessionActivity,
|
||||||
sort: sortSessionsByActivity,
|
sort: sortSessionsByActivity,
|
||||||
};
|
};
|
||||||
|
window.CodemanAutoCopy = {
|
||||||
|
decide: decideAutoCopy,
|
||||||
|
MAX_CHARS: AUTO_COPY_MAX_CHARS,
|
||||||
|
};
|
||||||
|
window.CodemanTerminalFont = {
|
||||||
|
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
|
||||||
|
resolve: resolveTerminalFontFamily,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scheduler API — prioritize terminal writes over background UI updates.
|
// Scheduler API — prioritize terminal writes over background UI updates.
|
||||||
@@ -1035,7 +1127,112 @@ function previewsInFileViewer(filePath) {
|
|||||||
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
return FILE_PREVIEW_EXTENSIONS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The LOGICAL line a terminal row belongs to — the rows it spans, its text as one
|
||||||
|
* string, and a two-way map between that string and terminal cells.
|
||||||
|
*
|
||||||
|
* One definition, two consumers: the link provider matches its patterns over this
|
||||||
|
* text (`registerFilePathLinkProvider`) and touch selection measures words and
|
||||||
|
* whole lines with it (`_touchSelectionLogicalLine`). They MUST agree — a link that
|
||||||
|
* spans a wrap and a "Line" that stops at the screen edge is the same bug twice.
|
||||||
|
*
|
||||||
|
* Two kinds of continuation, and handling only the first is not enough:
|
||||||
|
*
|
||||||
|
* 1. **Soft wrap** — the emulator ran out of columns and flags the next row
|
||||||
|
* `isWrapped`. It inserts nothing, so the row's text is joined verbatim.
|
||||||
|
* 2. **Hard wrap** — the program wrapped the text itself and emitted a real
|
||||||
|
* newline, so nothing is flagged. A row that fills the last column is taken
|
||||||
|
* as continuing into the next; that is the only trace a hard wrap leaves.
|
||||||
|
*
|
||||||
|
* ⚠️ A hard-wrapped continuation may carry the program's own INDENT, and joining
|
||||||
|
* that verbatim puts whitespace in the middle of the token being stitched. That is
|
||||||
|
* why an agent's numbered list —
|
||||||
|
*
|
||||||
|
* 1. https://github.com/users/someone/packages/container/p
|
||||||
|
* ackage/thing
|
||||||
|
*
|
||||||
|
* — opened only `…/container/p`: the URL pattern stops at the space the indent
|
||||||
|
* contributed. So the leading whitespace of a HARD continuation is dropped, and
|
||||||
|
* `colStart` on that segment records how much, keeping the cell mapping exact. A
|
||||||
|
* soft continuation keeps its leading whitespace, since the terminal never adds
|
||||||
|
* any and it is therefore real content.
|
||||||
|
*
|
||||||
|
* ⚠️ Only the final row is trimmed. Continuation rows are read UNTRIMMED so each
|
||||||
|
* contributes exactly `cols` cells; trimming one would shift every later offset.
|
||||||
|
*
|
||||||
|
* The row span is bounded by `maxRows` (12 by default): this runs on every hover,
|
||||||
|
* and a screenful of full-width output would otherwise re-scan the viewport each
|
||||||
|
* time.
|
||||||
|
*
|
||||||
|
* @param {{getLine: (row: number) => any, length: number}} buffer xterm buffer.
|
||||||
|
* @param {number} row 0-based ABSOLUTE buffer row to expand around.
|
||||||
|
* @param {number} cols Terminal width.
|
||||||
|
* @param {number} [maxRows] Row-span bound.
|
||||||
|
* @returns {{startRow: number, endRow: number, text: string,
|
||||||
|
* offsetToCell: (offset: number) => {row: number, col: number},
|
||||||
|
* cellToOffset: (row: number, col: number) => number} | null}
|
||||||
|
* 0-based rows and columns throughout; null when the row does not exist.
|
||||||
|
*/
|
||||||
|
function terminalLogicalLine(buffer, row, cols, maxRows) {
|
||||||
|
if (!buffer || typeof buffer.getLine !== 'function') return null;
|
||||||
|
const width = Math.max(1, cols || 1);
|
||||||
|
const bound = Math.max(1, maxRows || 12);
|
||||||
|
const lineAt = (r) => (r >= 0 ? buffer.getLine(r) : undefined);
|
||||||
|
if (!lineAt(row)) return null;
|
||||||
|
|
||||||
|
const continuesPrevious = (r) => {
|
||||||
|
if (r <= 0) return false;
|
||||||
|
if (lineAt(r)?.isWrapped) return true;
|
||||||
|
const prev = lineAt(r - 1);
|
||||||
|
return !!prev && (prev.translateToString(true) || '').length >= width;
|
||||||
|
};
|
||||||
|
|
||||||
|
let startRow = row;
|
||||||
|
while (startRow > 0 && row - startRow < bound && continuesPrevious(startRow)) startRow--;
|
||||||
|
let endRow = row;
|
||||||
|
const length = Number.isFinite(buffer.length) ? buffer.length : endRow + 1;
|
||||||
|
while (endRow + 1 < length && endRow - startRow < bound && continuesPrevious(endRow + 1)) endRow++;
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
let text = '';
|
||||||
|
for (let r = startRow; r <= endRow; r++) {
|
||||||
|
const line = lineAt(r);
|
||||||
|
if (!line) break;
|
||||||
|
let rowText = line.translateToString(r === endRow) || '';
|
||||||
|
let colStart = 0;
|
||||||
|
if (r > startRow && !line.isWrapped) {
|
||||||
|
const indent = rowText.length - rowText.replace(/^\s+/, '').length;
|
||||||
|
colStart = indent;
|
||||||
|
rowText = rowText.slice(indent);
|
||||||
|
}
|
||||||
|
segments.push({ row: r, textStart: text.length, colStart, length: rowText.length });
|
||||||
|
text += rowText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsetToCell = (offset) => {
|
||||||
|
for (let i = segments.length - 1; i >= 0; i--) {
|
||||||
|
const seg = segments[i];
|
||||||
|
if (offset >= seg.textStart || i === 0) {
|
||||||
|
return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { row: startRow, col: offset };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cellToOffset = (targetRow, targetCol) => {
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (seg.row !== targetRow) continue;
|
||||||
|
return seg.textStart + Math.max(0, targetCol - seg.colStart);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { startRow, endRow, text, offsetToCell, cellToOffset };
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine };
|
||||||
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS };
|
||||||
|
window.CodemanTerminalLines = { terminalLogicalLine };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Ryan L McIntyre
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
Binary file not shown.
@@ -326,11 +326,20 @@
|
|||||||
|
|
||||||
// Input settings
|
// Input settings
|
||||||
Input: '输入',
|
Input: '输入',
|
||||||
|
Font: '字体',
|
||||||
|
'Terminal font': '终端字体',
|
||||||
|
'Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.':
|
||||||
|
'置于内置字体栈之前,回退字体(包括内置的 Nerd Font 图标)仍然生效。需已安装在本设备上。留空使用默认值。',
|
||||||
'Local Echo': '本地回显',
|
'Local Echo': '本地回显',
|
||||||
'CJK Input': '中日韩输入',
|
'CJK Input': '中日韩输入',
|
||||||
'Extended Keyboard Bar': '扩展键盘栏',
|
'Extended Keyboard Bar': '扩展键盘栏',
|
||||||
'Gesture Control (beta)': '手势控制(测试版)',
|
'Gesture Control (beta)': '手势控制(测试版)',
|
||||||
'Wheel Scrolls Local History': '滚轮滚动本地历史',
|
'Wheel Scrolls Local History': '滚轮滚动本地历史',
|
||||||
|
'Auto Copy Selection': '自动复制选中内容',
|
||||||
|
'Selection & clipboard': '选中与剪贴板',
|
||||||
|
'Auto Copy: selection copied': '自动复制:已复制选中内容',
|
||||||
|
'Auto Copy failed: the browser blocked clipboard access': '自动复制失败:浏览器阻止了剪贴板访问',
|
||||||
|
'Selection too large to copy automatically. Press Ctrl+C.': '选中内容过大,无法自动复制。请按 Ctrl+C。',
|
||||||
'Instant typing feedback with local echo': '通过本地回显即时显示输入',
|
'Instant typing feedback with local echo': '通过本地回显即时显示输入',
|
||||||
'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框',
|
'Dedicated IME input field for CJK languages': '为中日韩语言提供专用输入法文本框',
|
||||||
'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O',
|
'Extra keys: Tab, Esc, arrows, Ctrl+O': '附加按键:Tab、Esc、方向键、Ctrl+O',
|
||||||
@@ -500,6 +509,11 @@
|
|||||||
'Respawn Blocked': '重生已阻止',
|
'Respawn Blocked': '重生已阻止',
|
||||||
'Task Complete': '任务完成',
|
'Task Complete': '任务完成',
|
||||||
'Copied to clipboard': '已复制到剪贴板',
|
'Copied to clipboard': '已复制到剪贴板',
|
||||||
|
// Terminal touch-selection bar (long-press to select). The bar is a sibling of
|
||||||
|
// `.xterm`, not a descendant, so SKIP_SELECTOR does not cover it and these apply.
|
||||||
|
Copy: '复制',
|
||||||
|
Line: '整行',
|
||||||
|
'Clear selection': '清除选择',
|
||||||
'Failed to copy': '复制失败',
|
'Failed to copy': '复制失败',
|
||||||
'Checking…': '正在检查…',
|
'Checking…': '正在检查…',
|
||||||
'Starting…': '正在启动…',
|
'Starting…': '正在启动…',
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<!-- Preload critical resources — lets browser discover these during HTML parse
|
<!-- Preload critical resources — lets browser discover these during HTML parse
|
||||||
instead of waiting until <script> tags at bottom-of-body are reached. -->
|
instead of waiting until <script> tags at bottom-of-body are reached. -->
|
||||||
<link rel="preload" href="vendor/xterm.min.js" as="script">
|
<link rel="preload" href="vendor/xterm.min.js" as="script">
|
||||||
|
<!-- Symbols font before the terminal first paints — a late-loading icon font
|
||||||
|
leaves tofu in xterm's glyph atlas until something forces a re-render. -->
|
||||||
|
<link rel="preload" href="fonts/symbols-nerd-font-mono.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="preload" href="constants.js" as="script">
|
<link rel="preload" href="constants.js" as="script">
|
||||||
<link rel="preload" href="app.js" as="script">
|
<link rel="preload" href="app.js" as="script">
|
||||||
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
|
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
|
||||||
@@ -1624,6 +1627,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="set-group">
|
||||||
|
<div class="set-group-head"><h4>Selection & clipboard</h4><span class="set-scope">device</span></div>
|
||||||
|
<div class="set-group-body">
|
||||||
|
<div class="set-row" data-search="auto copy selection clipboard highlight copy on select mouse">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Auto Copy Selection</span>
|
||||||
|
<span class="set-row-desc">Put highlighted terminal text on the clipboard as soon as you finish selecting it, with mouse, double-click or long-press. Ctrl+C still copies on demand, and nothing outside the terminal is copied.</span>
|
||||||
|
</div>
|
||||||
|
<label class="switch switch-sm"><input type="checkbox" id="appSettingsAutoCopySelection"><span class="slider"></span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="set-group">
|
||||||
|
<div class="set-group-head"><h4>Font</h4><span class="set-scope">device</span></div>
|
||||||
|
<div class="set-group-body">
|
||||||
|
<div class="set-row has-field" data-search="terminal font family nerd custom typeface">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Terminal font</span>
|
||||||
|
<span class="set-row-desc">Prepended to the built-in stack, so fallbacks (including bundled Nerd Font symbols) keep working. Must be installed on this device. Leave empty for the default.</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="appSettingsTerminalFont" class="set-input" placeholder='e.g. JetBrainsMono Nerd Font'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="set-group">
|
<div class="set-group">
|
||||||
<div class="set-group-head"><h4>Scrolling & rendering</h4></div>
|
<div class="set-group-head"><h4>Scrolling & rendering</h4></div>
|
||||||
<div class="set-group-body">
|
<div class="set-group-body">
|
||||||
|
|||||||
@@ -573,6 +573,42 @@ const KeyboardHandler = {
|
|||||||
* space below the last row. After fitAddon.fit(), measure the gap and
|
* space below the last row. After fitAddon.fit(), measure the gap and
|
||||||
* reduce padding by that amount so the terminal sits flush against the bars.
|
* reduce padding by that amount so the terminal sits flush against the bars.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Combined height of the fixed bars that overlay the terminal's bottom edge.
|
||||||
|
*
|
||||||
|
* On phones the toolbar and the accessory bar are `position: fixed`, so they
|
||||||
|
* occupy no layout space of their own — `main`'s padding-bottom is the only
|
||||||
|
* thing reserving room for them, and any pixel taken out of it is a pixel of
|
||||||
|
* terminal painted underneath them.
|
||||||
|
*/
|
||||||
|
_fixedBottomBarsHeight() {
|
||||||
|
let px = 0;
|
||||||
|
for (const selector of ['.toolbar', '.keyboard-accessory-bar', '#cjkInput.cjk-input-visible']) {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (!el) continue;
|
||||||
|
const style = window.getComputedStyle?.(el);
|
||||||
|
if (style && (style.display === 'none' || style.visibility === 'hidden')) continue;
|
||||||
|
px += el.offsetHeight || 0;
|
||||||
|
}
|
||||||
|
return px;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reclaim sub-row slack at the bottom of the terminal — but never the space the
|
||||||
|
* fixed bars stand in.
|
||||||
|
*
|
||||||
|
* Shrinking the padding by the whole slack pulled the terminal's bottom edge
|
||||||
|
* DOWN under those bars, and the row the following re-fit then gained was
|
||||||
|
* painted behind them: on a long wrapped prompt the last line was clipped by
|
||||||
|
* the accessory bar, i.e. the bottom half of the text being typed. The floor is
|
||||||
|
* now the bars' MEASURED height, so a device where the hard-coded 84px
|
||||||
|
* over-reserves still reclaims the difference, while one that genuinely needs
|
||||||
|
* it keeps every pixel.
|
||||||
|
*
|
||||||
|
* ⚠️ The floor can only ever prevent a shrink, never cause a grow
|
||||||
|
* (`Math.min(currentPadding, …)`): a measured height LARGER than the current
|
||||||
|
* padding makes this a no-op rather than silently resizing the terminal.
|
||||||
|
*/
|
||||||
_shrinkPaddingToFit() {
|
_shrinkPaddingToFit() {
|
||||||
try {
|
try {
|
||||||
const container = document.getElementById('terminalContainer');
|
const container = document.getElementById('terminalContainer');
|
||||||
@@ -583,7 +619,8 @@ const KeyboardHandler = {
|
|||||||
const gap = container.clientHeight - app.terminal.rows * cellH;
|
const gap = container.clientHeight - app.terminal.rows * cellH;
|
||||||
if (gap > 0 && gap < cellH) {
|
if (gap > 0 && gap < cellH) {
|
||||||
const currentPadding = parseInt(main.style.paddingBottom) || 0;
|
const currentPadding = parseInt(main.style.paddingBottom) || 0;
|
||||||
main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px';
|
const floor = Math.min(currentPadding, this._fixedBottomBarsHeight());
|
||||||
|
main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px';
|
||||||
if (app.fitAddon)
|
if (app.fitAddon)
|
||||||
try {
|
try {
|
||||||
app.fitAddon.fit();
|
app.fitAddon.fit();
|
||||||
|
|||||||
@@ -2279,7 +2279,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
theme: { ...window.codemanCurrentXtermTheme() },
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
minimumContrastRatio: window.codemanCurrentSkinIsLight() ? 4.5 : 1,
|
||||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
|
|||||||
@@ -381,6 +381,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
|
||||||
this.loadTunnelStatus();
|
this.loadTunnelStatus();
|
||||||
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
|
||||||
|
// Auto Copy (copy-on-select): per-device, default OFF everywhere. It quietly
|
||||||
|
// overwrites the system clipboard on a gesture the user may have meant only as
|
||||||
|
// a way to read, so it is opt-in rather than a default anyone has to discover.
|
||||||
|
document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;
|
||||||
|
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
|
||||||
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
document.getElementById('appSettingsTerminalWheelLocal').checked =
|
||||||
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
|
||||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||||
@@ -2006,6 +2011,8 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
|
||||||
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
|
||||||
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
|
||||||
|
autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,
|
||||||
|
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
|
||||||
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
|
||||||
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
|
||||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||||
@@ -2052,6 +2059,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Save to localStorage
|
// Save to localStorage
|
||||||
this.saveAppSettingsToStorage(settings);
|
this.saveAppSettingsToStorage(settings);
|
||||||
this._updateLocalEchoState();
|
this._updateLocalEchoState();
|
||||||
|
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
|
||||||
|
|
||||||
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
// A real OFF→ON flip of the WebGL toggle retires the GPU-stall auto-fallback
|
||||||
// marker so the next reload actually re-tries WebGL. Only the transition
|
// marker so the next reload actually re-tries WebGL. Only the transition
|
||||||
@@ -2200,6 +2208,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
showFileViewerButton: _fvb,
|
showFileViewerButton: _fvb,
|
||||||
webglRendererEnabled: _wgl,
|
webglRendererEnabled: _wgl,
|
||||||
terminalWheelLocalScrollback: _twls,
|
terminalWheelLocalScrollback: _twls,
|
||||||
|
// Copy-on-select. Per-device (clipboard access differs by device and by
|
||||||
|
// origin: the plain-HTTP LAN install has no navigator.clipboard at all)
|
||||||
|
// and absent from SettingsUpdateSchema (.strict()), so sending it would
|
||||||
|
// 400 the whole settings PUT.
|
||||||
|
autoCopySelection: _acs,
|
||||||
|
// Per-device by nature (the font must exist on the device) and absent
|
||||||
|
// from SettingsUpdateSchema (.strict()) — sending it would 400 the PUT.
|
||||||
|
terminalFontFamily: _tff,
|
||||||
// Per-device header/toolbar button toggles — client-only, and absent from
|
// Per-device header/toolbar button toggles — client-only, and absent from
|
||||||
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
|
||||||
showSessionButton: _ssb,
|
showSessionButton: _ssb,
|
||||||
@@ -2874,8 +2890,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||||
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||||
|
'terminalFontFamily',
|
||||||
'language',
|
'language',
|
||||||
'terminalWheelLocalScrollback',
|
'terminalWheelLocalScrollback',
|
||||||
|
'autoCopySelection',
|
||||||
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
|
||||||
'showTabDetachButton',
|
'showTabDetachButton',
|
||||||
'mobileOverviewEnabled',
|
'mobileOverviewEnabled',
|
||||||
|
|||||||
@@ -16,6 +16,19 @@
|
|||||||
font-weight: 400 700;
|
font-weight: 400 700;
|
||||||
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
src: url('fonts/jetbrains-mono-variable.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
/* Icons-only per-glyph fallback for the terminal (Symbols Nerd Font Mono, MIT,
|
||||||
|
fonts/LICENSE-nerd-fonts.txt). Sits BEHIND the text fonts in the xterm stack
|
||||||
|
(constants.js: TERMINAL_FONT_DEFAULT_STACK), so it only ever supplies the
|
||||||
|
private-use-area glyphs shell prompts draw (powerline, p10k/starship folder
|
||||||
|
and git icons) — text rendering is untouched. `block` display, not `swap`:
|
||||||
|
there is no fallback that CAN render these glyphs, so swapping in tofu first
|
||||||
|
would poison xterm's glyph atlas until the next re-render. */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Symbols Nerd Font Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-display: block;
|
||||||
|
src: url('fonts/symbols-nerd-font-mono.woff2') format('woff2');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* Dark is the safe fallback. Light skins override this so native selects,
|
/* Dark is the safe fallback. Light skins override this so native selects,
|
||||||
@@ -3395,6 +3408,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
|
|||||||
-webkit-touch-callout: none !important;
|
-webkit-touch-callout: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Touch text-selection bar (long-press → select → Copy).
|
||||||
|
Lives in styles.css, NOT mobile.css: the gesture is touch-driven, not
|
||||||
|
width-driven, and mobile.css is media-gated to ≤1023px — a touch tablet in
|
||||||
|
landscape would get the gesture with no bar to copy from.
|
||||||
|
Built in JS (index.html is read once at server start, so markup added there
|
||||||
|
would need a restart to appear). z-index 900 sits above terminal content and
|
||||||
|
the local-echo overlay (7) and deliberately BELOW floating agent windows
|
||||||
|
(1000), so it can never cover their controls. */
|
||||||
|
.term-select-bar {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 900;
|
||||||
|
display: none;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.term-select-bar.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.term-select-btn {
|
||||||
|
min-height: 38px;
|
||||||
|
min-width: 46px;
|
||||||
|
padding: 0 0.7rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
/* The bar is the one place in the terminal subtree a tap must land on a
|
||||||
|
control rather than a cell, so it opts out of the gesture styles above. */
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.term-select-btn:active {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.term-select-btn--close {
|
||||||
|
min-width: 38px;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Welcome Overlay */
|
/* Welcome Overlay */
|
||||||
.welcome-overlay {
|
.welcome-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+735
-73
@@ -38,6 +38,19 @@
|
|||||||
// a gesture the terminal treats as a scroll but the dismiss handler treats as
|
// a gesture the terminal treats as a scroll but the dismiss handler treats as
|
||||||
// a tap would close the keyboard mid-scroll and drop the composer.
|
// a tap would close the keyboard mid-scroll and drop the composer.
|
||||||
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
|
const MOBILE_KEYBOARD_DISMISS_TAP_SLOP = 8;
|
||||||
|
// Hold this long, finger still, before a press becomes a text selection.
|
||||||
|
//
|
||||||
|
// ⚠️ It must fire well BEFORE the platform's own long-press threshold (~500ms on
|
||||||
|
// Android), not just under it: the guards this gesture installs are armed when it
|
||||||
|
// fires, and at 450ms they were still being armed as Chrome ran its own handling
|
||||||
|
// — which focuses the nearest editable element, so the keyboard shot up over the
|
||||||
|
// selection the moment it appeared. 350ms is still far above a tap (~100-150ms).
|
||||||
|
const TOUCH_SELECT_LONG_PRESS_MS = 350;
|
||||||
|
// How long after a selection gesture the terminal input stays un-focusable. Long
|
||||||
|
// enough to cover the platform's long-press handling and the compatibility events
|
||||||
|
// that trail a touchend; short and self-expiring, so a stuck flag can never leave
|
||||||
|
// the keyboard unreachable.
|
||||||
|
const TOUCH_SELECT_FOCUS_GUARD_MS = 800;
|
||||||
// Regions where a tap must NOT dismiss the on-screen keyboard
|
// Regions where a tap must NOT dismiss the on-screen keyboard
|
||||||
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
|
// (_installMobileKeyboardDismiss). Two groups: anything that is about to take
|
||||||
// focus itself, and the accessory bar, which is built to be used while the
|
// focus itself, and the accessory bar, which is built to be used while the
|
||||||
@@ -206,6 +219,8 @@
|
|||||||
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM,
|
||||||
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR,
|
||||||
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
|
MOBILE_KEYBOARD_DISMISS_TAP_SLOP,
|
||||||
|
TOUCH_SELECT_LONG_PRESS_MS,
|
||||||
|
TOUCH_SELECT_FOCUS_GUARD_MS,
|
||||||
};
|
};
|
||||||
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES;
|
||||||
global.codemanCurrentXtermTheme = currentXtermTheme;
|
global.codemanCurrentXtermTheme = currentXtermTheme;
|
||||||
@@ -225,7 +240,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
this.terminal = new Terminal({
|
this.terminal = new Terminal({
|
||||||
theme: { ...window.codemanCurrentXtermTheme() },
|
theme: { ...window.codemanCurrentXtermTheme() },
|
||||||
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace',
|
fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily),
|
||||||
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
// Use smaller font on mobile to fit more columns (prevents wrapping of Claude's status line)
|
||||||
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
@@ -270,6 +285,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const container = document.getElementById('terminalContainer');
|
const container = document.getElementById('terminalContainer');
|
||||||
this.terminal.open(container);
|
this.terminal.open(container);
|
||||||
this._installMobileTapMouseGuard();
|
this._installMobileTapMouseGuard();
|
||||||
|
this._installTouchSelectionFocusGuard();
|
||||||
|
|
||||||
// Suppress xterm key handling during CJK IME composition.
|
// Suppress xterm key handling during CJK IME composition.
|
||||||
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
|
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
|
||||||
@@ -567,6 +583,44 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Register link provider for clickable file paths in Bash tool output
|
// Register link provider for clickable file paths in Bash tool output
|
||||||
this.registerFilePathLinkProvider();
|
this.registerFilePathLinkProvider();
|
||||||
|
|
||||||
|
// Bar visible ⟺ a selection exists. xterm drops the selection on any keypress,
|
||||||
|
// on reset and on a tab switch, and a Copy button floating over nothing is a
|
||||||
|
// trap — one that would copy the PREVIOUS session's text if it still worked.
|
||||||
|
this.terminal.onSelectionChange?.(() => {
|
||||||
|
if (!this.terminal?.hasSelection?.()) {
|
||||||
|
this._touchSelecting = false;
|
||||||
|
this._touchSelectionActive = false;
|
||||||
|
this._touchSelectionAnchor = null;
|
||||||
|
this._hideTouchSelectionBar();
|
||||||
|
// Nothing highlighted, nothing for Auto Copy to flush. xterm drops the
|
||||||
|
// selection on any keypress, so without this a keystroke would leave a
|
||||||
|
// stale flush armed for the next unrelated mouseup.
|
||||||
|
this._autoCopyPending = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Auto Copy arms here and fires at the END of the gesture (below). This
|
||||||
|
// callback runs on EVERY cell the drag crosses, so copying here would be
|
||||||
|
// one clipboard write per mouse move.
|
||||||
|
this._autoCopyPending = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto Copy (copy-on-select, per-device `autoCopySelection`, default OFF).
|
||||||
|
//
|
||||||
|
// ⚠️ The flush is SYNCHRONOUS inside the mouseup handler, deliberately: both
|
||||||
|
// clipboard paths need user activation (Firefox gates navigator.clipboard
|
||||||
|
// .writeText on it, and Safari requires document.execCommand('copy'), the
|
||||||
|
// plain-HTTP fallback install.sh's LAN option lands on, to run inside the
|
||||||
|
// gesture's own task). Deferring to a timer or to onSelectionChange loses it.
|
||||||
|
//
|
||||||
|
// ⚠️ document-level, because a drag that ends outside the terminal (the user
|
||||||
|
// sweeps up past the header) delivers its mouseup to the document, not to
|
||||||
|
// the container. Unrelated mouseups elsewhere on the page are filtered by
|
||||||
|
// decideAutoCopy, not by the listener's target.
|
||||||
|
if (!this._autoCopyListenerInstalled) {
|
||||||
|
this._autoCopyListenerInstalled = true;
|
||||||
|
document.addEventListener('mouseup', () => this._flushAutoCopySelection());
|
||||||
|
}
|
||||||
|
|
||||||
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
// Mouse wheel: forward to the TUI only for sessions verified to handle SGR
|
||||||
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
|
// wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local
|
||||||
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
// scrollback otherwise. Claude Code 2.1.187+ scrolls its own
|
||||||
@@ -686,6 +740,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
let pixelAccum = 0;
|
let pixelAccum = 0;
|
||||||
|
|
||||||
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
let didScroll = false; // track whether touchmove fired (tap vs scroll)
|
||||||
|
let longPressTimer = null; // armed on touchstart, becomes a text selection
|
||||||
|
let longPressStartX = 0;
|
||||||
|
let longPressStartY = 0;
|
||||||
let touchStartY = 0;
|
let touchStartY = 0;
|
||||||
let tapStartedWithTerminalFocus = false;
|
let tapStartedWithTerminalFocus = false;
|
||||||
let tapStartIntentCache = null;
|
let tapStartIntentCache = null;
|
||||||
@@ -695,6 +752,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
'touchstart',
|
'touchstart',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
|
// The selection bar is a child of this container: its buttons own their
|
||||||
|
// own taps and must not arm a gesture on the terminal underneath.
|
||||||
|
if (ev.target?.closest?.('.term-select-bar')) return;
|
||||||
|
if (ev.touches.length !== 1) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
}
|
||||||
if (ev.touches.length === 1) {
|
if (ev.touches.length === 1) {
|
||||||
touchLastX = ev.touches[0].clientX;
|
touchLastX = ev.touches[0].clientX;
|
||||||
touchLastY = ev.touches[0].clientY;
|
touchLastY = ev.touches[0].clientY;
|
||||||
@@ -725,6 +789,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
this._blurMobileTerminalInput();
|
this._blurMobileTerminalInput();
|
||||||
}
|
}
|
||||||
|
// Hold still and this press becomes a text selection. Cancelled by any
|
||||||
|
// travel past the shared tap slop below, so a scroll can never become one.
|
||||||
|
longPressStartX = touchLastX;
|
||||||
|
longPressStartY = touchLastY;
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = setTimeout(() => {
|
||||||
|
longPressTimer = null;
|
||||||
|
this._beginTouchSelection(longPressStartX, longPressStartY);
|
||||||
|
}, window.CodemanTerminalInput.TOUCH_SELECT_LONG_PRESS_MS);
|
||||||
lastTime = 0;
|
lastTime = 0;
|
||||||
if (scrollFrame) {
|
if (scrollFrame) {
|
||||||
cancelAnimationFrame(scrollFrame);
|
cancelAnimationFrame(scrollFrame);
|
||||||
@@ -738,6 +811,24 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
'touchmove',
|
'touchmove',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
|
// A drag that follows the long press grows the selection instead of
|
||||||
|
// scrolling; preventDefault keeps the page from taking the gesture back.
|
||||||
|
if (this._touchSelecting) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const selTouch = ev.touches[0];
|
||||||
|
if (selTouch) this._extendTouchSelection(selTouch.clientX, selTouch.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (longPressTimer && ev.touches.length === 1) {
|
||||||
|
const t = ev.touches[0];
|
||||||
|
if (
|
||||||
|
Math.abs(t.clientX - longPressStartX) > TAP_THRESHOLD ||
|
||||||
|
Math.abs(t.clientY - longPressStartY) > TAP_THRESHOLD
|
||||||
|
) {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (ev.touches.length === 1 && isTouching) {
|
if (ev.touches.length === 1 && isTouching) {
|
||||||
const touchY = ev.touches[0].clientY;
|
const touchY = ev.touches[0].clientY;
|
||||||
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
|
if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) {
|
||||||
@@ -779,7 +870,21 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
'touchend',
|
'touchend',
|
||||||
(ev) => {
|
(ev) => {
|
||||||
|
if (ev.target?.closest?.('.term-select-bar')) return;
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
isTouching = false;
|
isTouching = false;
|
||||||
|
if (this._touchSelecting) {
|
||||||
|
// Lifting ends the DRAG, not the selection: the bar stays up so the
|
||||||
|
// range can still be extended by tapping, or copied. preventDefault
|
||||||
|
// cancels the compatibility mouse events this touchend would otherwise
|
||||||
|
// synthesize — see _endTouchSelectionGesture.
|
||||||
|
ev.preventDefault();
|
||||||
|
velocity = 0;
|
||||||
|
this._endTouchSelectionGesture();
|
||||||
|
tapStartedWithTerminalFocus = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
if (!scrollFrame && Math.abs(velocity) > 0.3) {
|
||||||
scrollFrame = requestAnimationFrame(scrollLoop);
|
scrollFrame = requestAnimationFrame(scrollLoop);
|
||||||
}
|
}
|
||||||
@@ -798,13 +903,28 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
tapStartedWithTerminalFocus = false;
|
tapStartedWithTerminalFocus = false;
|
||||||
},
|
},
|
||||||
{ passive: true }
|
// NOT passive: the selection branch above must be able to preventDefault
|
||||||
|
// the compatibility mouse events. Every other path leaves the event alone.
|
||||||
|
{ passive: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Android Chrome fires `contextmenu` at its long-press threshold and then runs
|
||||||
|
// its default long-press behaviour. Suppressed ONLY while a selection gesture
|
||||||
|
// is in flight — a desktop right-click keeps its menu, since the timer is null
|
||||||
|
// and no gesture is active there.
|
||||||
|
container.addEventListener('contextmenu', (ev) => {
|
||||||
|
if (longPressTimer !== null || this._touchSelecting || this._touchSelectionActive) {
|
||||||
|
ev.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
container.addEventListener(
|
container.addEventListener(
|
||||||
'touchcancel',
|
'touchcancel',
|
||||||
() => {
|
() => {
|
||||||
|
clearTimeout(longPressTimer);
|
||||||
|
longPressTimer = null;
|
||||||
isTouching = false;
|
isTouching = false;
|
||||||
|
this._touchSelecting = false;
|
||||||
velocity = 0;
|
velocity = 0;
|
||||||
pixelAccum = 0;
|
pixelAccum = 0;
|
||||||
tapStartedWithTerminalFocus = false;
|
tapStartedWithTerminalFocus = false;
|
||||||
@@ -1306,7 +1426,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Debug: Track if provider is being invoked
|
// Debug: Track if provider is being invoked
|
||||||
let lastInvokedLine = -1;
|
let lastInvokedLine = -1;
|
||||||
|
|
||||||
this.terminal.registerLinkProvider({
|
const provider = {
|
||||||
provideLinks(bufferLineNumber, callback) {
|
provideLinks(bufferLineNumber, callback) {
|
||||||
// Debug logging - only log if line changed to avoid spam
|
// Debug logging - only log if line changed to avoid spam
|
||||||
if (bufferLineNumber !== lastInvokedLine) {
|
if (bufferLineNumber !== lastInvokedLine) {
|
||||||
@@ -1325,63 +1445,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Stitch the LOGICAL line back together.
|
// Stitch the LOGICAL line back together.
|
||||||
//
|
//
|
||||||
// xterm invokes this provider per visible ROW, and translateToString returns
|
// xterm invokes this provider per visible ROW and translateToString returns
|
||||||
// that row alone (the old comment here claimed otherwise). A URL or path
|
// that row alone, so a URL or path longer than the terminal is wide matched
|
||||||
// longer than the terminal is wide therefore matched only as far as the row
|
// only as far as the row boundary and the link opened a PREFIX of the real
|
||||||
// boundary, and the link opened a PREFIX of the real target. Walk out to both
|
// target. `terminalLogicalLine` (constants.js) owns the reconstruction —
|
||||||
// ends of the continuation, match against the joined text, and map offsets
|
// both continuation kinds, the indent a hard wrap leaves on its
|
||||||
// back to (x, y) so a link can span rows.
|
// continuation, and the offset↔cell mapping — because touch selection
|
||||||
//
|
// measures the SAME lines and the two must not disagree.
|
||||||
// Two different kinds of continuation, and handling only the first is not
|
|
||||||
// enough:
|
|
||||||
// 1. SOFT wrap: the emulator ran out of columns and flags the next row
|
|
||||||
// `isWrapped`.
|
|
||||||
// 2. HARD wrap: the program did its own wrapping and emitted a real
|
|
||||||
// newline, so nothing is flagged. Ink does this, which is why Claude
|
|
||||||
// Code's own `/login` URL was cut at the window edge, and why the
|
|
||||||
// clickable part grew when the window was widened.
|
|
||||||
// A row that fills the full width is treated as continuing into the next:
|
|
||||||
// that is the signal a hard wrap leaves behind, and a line that genuinely
|
|
||||||
// ended would stop short of the last column.
|
|
||||||
const cols = self.terminal.cols;
|
|
||||||
const rowAt = (r) => buffer.getLine(r - 1);
|
|
||||||
const continuesPrevious = (r) => {
|
|
||||||
if (r <= 1) return false;
|
|
||||||
if (rowAt(r)?.isWrapped) return true;
|
|
||||||
const prev = rowAt(r - 1);
|
|
||||||
return !!prev && prev.translateToString(true).length >= cols;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
// Bounded so a screenful of full-width output (wide tables, box drawing)
|
||||||
// cannot make every hover stitch and re-scan the entire viewport.
|
// cannot make every hover stitch and re-scan the entire viewport.
|
||||||
const MAX_STITCHED_ROWS = 12;
|
const MAX_STITCHED_ROWS = 12;
|
||||||
let startRow = bufferLineNumber;
|
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
|
||||||
while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) {
|
buffer,
|
||||||
startRow--;
|
bufferLineNumber - 1,
|
||||||
|
self.terminal.cols,
|
||||||
|
MAX_STITCHED_ROWS
|
||||||
|
);
|
||||||
|
if (!logical) {
|
||||||
|
callback(undefined);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
let endRow = bufferLineNumber;
|
const lineText = logical.text;
|
||||||
while (endRow < buffer.length && endRow - startRow < MAX_STITCHED_ROWS && continuesPrevious(endRow + 1)) {
|
|
||||||
endRow++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rowTexts = [];
|
|
||||||
for (let r = startRow; r <= endRow; r++) {
|
|
||||||
const row = rowAt(r);
|
|
||||||
if (!row) break;
|
|
||||||
// Only the final row may be trimmed. Continuation rows fill the width by
|
|
||||||
// definition, and trimming one would shift every later offset.
|
|
||||||
rowTexts.push(row.translateToString(r === endRow));
|
|
||||||
}
|
|
||||||
const lineText = rowTexts.join('');
|
|
||||||
|
|
||||||
/** Map an offset in the stitched text back to a 1-based terminal cell. */
|
/** Map an offset in the stitched text back to a 1-based terminal cell. */
|
||||||
const coordAt = (index) => {
|
const coordAt = (index) => {
|
||||||
let rest = index;
|
const cell = logical.offsetToCell(index);
|
||||||
for (let i = 0; i < rowTexts.length - 1; i++) {
|
return { x: cell.col + 1, y: cell.row + 1 };
|
||||||
if (rest < rowTexts[i].length) return { x: rest + 1, y: startRow + i };
|
|
||||||
rest -= rowTexts[i].length;
|
|
||||||
}
|
|
||||||
return { x: rest + 1, y: startRow + rowTexts.length - 1 };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!lineText || !lineText.includes('/')) {
|
if (!lineText || !lineText.includes('/')) {
|
||||||
@@ -1523,11 +1612,432 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
}
|
}
|
||||||
callback(links.length > 0 ? links : undefined);
|
callback(links.length > 0 ? links : undefined);
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Keep the provider reachable: on touch devices xterm's linkifier never
|
||||||
|
// resolves a link (it is driven by mousemove/mouseup, which a tap does not
|
||||||
|
// produce), so the tap path asks this SAME provider what is under the finger
|
||||||
|
// rather than growing a second, driftable copy of the patterns.
|
||||||
|
// See _terminalLinkAtPoint.
|
||||||
|
this._terminalLinkProvider = provider;
|
||||||
|
this.terminal.registerLinkProvider(provider);
|
||||||
|
|
||||||
console.log('[LinkProvider] File path link provider registered');
|
console.log('[LinkProvider] File path link provider registered');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The terminal link under a viewport point, or null.
|
||||||
|
*
|
||||||
|
* Resolved through the provider registered above, so a tap and a desktop click
|
||||||
|
* can never disagree about what is a link or where it ends. Containment
|
||||||
|
* mirrors xterm's own `_linkAtPosition` — flattened `y * cols + x`, inclusive
|
||||||
|
* at both ends — for the same reason.
|
||||||
|
*
|
||||||
|
* ⚠️ The provider answers its callback SYNCHRONOUSLY (every path in
|
||||||
|
* `registerFilePathLinkProvider` does, including the empty ones). xterm's
|
||||||
|
* ILinkProvider contract permits an async reply, so this reads whatever
|
||||||
|
* arrived by the time the call returns and answers null otherwise: a tap then
|
||||||
|
* keeps its normal meaning instead of opening a link late, after the gesture
|
||||||
|
* that made `window.open` permissible is gone.
|
||||||
|
*/
|
||||||
|
_terminalLinkAtPoint(clientX, clientY) {
|
||||||
|
const provider = this._terminalLinkProvider;
|
||||||
|
const buffer = this.terminal?.buffer?.active;
|
||||||
|
if (!provider || !buffer) return null;
|
||||||
|
const pos = this._clientPointToCell(clientX, clientY);
|
||||||
|
if (!pos) return null;
|
||||||
|
// Link ranges are 1-based ABSOLUTE buffer lines (xterm adds ydisp to the
|
||||||
|
// viewport row before asking), which is what the provider's coordAt() emits.
|
||||||
|
const y = (buffer.viewportY || 0) + pos.row;
|
||||||
|
let links = null;
|
||||||
|
try {
|
||||||
|
provider.provideLinks(y, (result) => {
|
||||||
|
links = result || [];
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!links || links.length === 0) return null;
|
||||||
|
const cols = Math.max(1, this.terminal.cols || 1);
|
||||||
|
const current = y * cols + pos.col;
|
||||||
|
return (
|
||||||
|
links.find((link) => {
|
||||||
|
const start = link?.range?.start;
|
||||||
|
const end = link?.range?.end;
|
||||||
|
if (!start || !end) return false;
|
||||||
|
return start.y * cols + start.x <= current && current <= end.y * cols + end.x;
|
||||||
|
}) || null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is this point on the caret's logical line — the editable composer?
|
||||||
|
*
|
||||||
|
* There a tap means "put the cursor here", so a URL the USER typed or pasted
|
||||||
|
* into a prompt must stay editable rather than opening itself. The caret is the
|
||||||
|
* signal that works for every CLI: claude's composer row carries it, and in a
|
||||||
|
* plain shell it sits on the prompt line while output scrolls above, so the
|
||||||
|
* same test covers both without asking what mode is running (tap
|
||||||
|
* classification cannot answer this — a shell session classifies EVERY tap as
|
||||||
|
* 'input', which would leave every URL in shell output inert).
|
||||||
|
*
|
||||||
|
* The caret's line is walked out through soft wraps, since a long prompt spans
|
||||||
|
* rows.
|
||||||
|
*/
|
||||||
|
_tapIsOnCaretLine(clientX, clientY) {
|
||||||
|
const buffer = this.terminal?.buffer?.active;
|
||||||
|
if (!buffer?.getLine) return false;
|
||||||
|
const pos = this._clientPointToCell(clientX, clientY);
|
||||||
|
if (!pos) return false;
|
||||||
|
const rows = Math.max(1, this.terminal.rows || 1);
|
||||||
|
const cursorRow = Math.max(0, Math.min(rows - 1, buffer.cursorY || 0));
|
||||||
|
const tappedRow = pos.row - 1;
|
||||||
|
if (tappedRow === cursorRow) return true;
|
||||||
|
let start = cursorRow;
|
||||||
|
while (start > 0 && buffer.getLine(buffer.viewportY + start)?.isWrapped) start--;
|
||||||
|
let end = cursorRow;
|
||||||
|
while (end + 1 < rows && buffer.getLine(buffer.viewportY + end + 1)?.isWrapped) end++;
|
||||||
|
return tappedRow >= start && tappedRow <= end;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate the terminal link under a touch point. Returns true when one was.
|
||||||
|
*
|
||||||
|
* xterm activates a link from a `mousemove` that resolves what is under the
|
||||||
|
* pointer, followed by a `mouseup` on its SCREEN element — and on a touch
|
||||||
|
* device it receives neither: `touch-action: none` plus touchstart's
|
||||||
|
* preventDefault suppress the browser's compatibility mouse events,
|
||||||
|
* _installMobileTapMouseGuard drops the ones that still arrive, and the
|
||||||
|
* synthetic pair dispatched for mouse REPORTING goes to the `.xterm` root,
|
||||||
|
* an ANCESTOR of the node the linkifier listens on (so it cannot reach it) and
|
||||||
|
* carries no mousemove either way. Every URL and file path in the terminal was
|
||||||
|
* therefore inert on phones and tablets — Claude Code's own `/login` URL
|
||||||
|
* included, which is unfinishable from a phone without this.
|
||||||
|
*
|
||||||
|
* Activating here, synchronously inside the touchend handler, is what keeps
|
||||||
|
* the user gesture that lets the URL branch's `window.open` through the popup
|
||||||
|
* blocker; a later activation (a timer, a promise) is silently swallowed.
|
||||||
|
*/
|
||||||
|
_activateTerminalLinkAtPoint(clientX, clientY) {
|
||||||
|
const link = this._terminalLinkAtPoint(clientX, clientY);
|
||||||
|
if (!link || typeof link.activate !== 'function') return false;
|
||||||
|
try {
|
||||||
|
link.activate(null, link.text);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[LinkProvider] tap activation failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// Touch text selection — long-press to select, tap to extend, Copy
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// There was no way to copy terminal text from a phone at all. Three layers
|
||||||
|
// ruled it out at once: `user-select: none` on the whole terminal subtree
|
||||||
|
// (taps are cursor gestures there, so the OS callout had to go), the WebGL
|
||||||
|
// renderer drawing glyphs as pixels with only the accessibility tree behind
|
||||||
|
// them, and xterm's own selection being a mouse DRAG — while the tap path
|
||||||
|
// dispatches a zero-movement mousedown/mouseup pair, i.e. a click.
|
||||||
|
//
|
||||||
|
// So the gesture drives xterm's selection API directly (`select`, public and
|
||||||
|
// renderer-independent, and the highlight is drawn by xterm itself). Long-press
|
||||||
|
// is free real estate: tap and swipe are taken, long-press and double-tap are
|
||||||
|
// used by nothing.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While a selection gesture is in flight, the terminal input must not hold focus.
|
||||||
|
*
|
||||||
|
* ⚠️ This is the guard that actually fixes "the keyboard pops up the moment the
|
||||||
|
* selection appears". The mouse-event guard cannot: the focus does not arrive
|
||||||
|
* through a mouse event at all. Android Chrome runs its own long-press handling
|
||||||
|
* at ~500ms and focuses the nearest editable element — xterm's helper textarea,
|
||||||
|
* a real <textarea> parked at the cursor — and nothing in the touch path can
|
||||||
|
* preventDefault an event it never sees. Blurring on focus is the one move that
|
||||||
|
* works regardless of which path did the focusing.
|
||||||
|
*
|
||||||
|
* Bounded by a self-expiring deadline rather than by the bar's visibility, so a
|
||||||
|
* flag left set can never make the keyboard permanently unreachable.
|
||||||
|
*/
|
||||||
|
_installTouchSelectionFocusGuard() {
|
||||||
|
const textarea = this.terminal?.textarea;
|
||||||
|
if (!textarea || textarea._codemanSelectionFocusGuard) return;
|
||||||
|
textarea._codemanSelectionFocusGuard = true;
|
||||||
|
textarea.addEventListener('focus', () => {
|
||||||
|
if (!this._touchSelectionFocusGuarded()) return;
|
||||||
|
// Same task as the focus: a keyboard that opens and closes still shoves the
|
||||||
|
// viewport, and the selection with it.
|
||||||
|
this._blurMobileTerminalInput();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
_touchSelectionFocusGuarded() {
|
||||||
|
if (this._touchSelecting) return true;
|
||||||
|
return performance.now() < (this._touchSelectionFocusGuardUntil || 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Re-arm the focus guard; called at every step of the gesture. */
|
||||||
|
_armTouchSelectionFocusGuard() {
|
||||||
|
this._touchSelectionFocusGuardUntil =
|
||||||
|
performance.now() + (window.CodemanTerminalInput?.TOUCH_SELECT_FOCUS_GUARD_MS || 800);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The absolute 0-based buffer cell under a viewport point, or null. */
|
||||||
|
_touchSelectionCellAt(clientX, clientY) {
|
||||||
|
const pos = this._clientPointToCell(clientX, clientY);
|
||||||
|
const buffer = this.terminal?.buffer?.active;
|
||||||
|
if (!pos || !buffer) return null;
|
||||||
|
return { col: pos.col - 1, row: (buffer.viewportY || 0) + pos.row - 1 };
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The logical line a buffer row belongs to — the SAME reconstruction the link
|
||||||
|
* provider matches against (`terminalLogicalLine`, constants.js).
|
||||||
|
*
|
||||||
|
* ⚠️ Walking only `isWrapped` was not enough: Claude Code and every other Ink CLI
|
||||||
|
* wrap their own output and emit real newlines, so nothing is flagged and "Line"
|
||||||
|
* grabbed the one row on screen instead of the whole wrapped line. The shared
|
||||||
|
* helper treats a row that fills the last column as continuing, and drops the
|
||||||
|
* indent such a continuation carries.
|
||||||
|
*/
|
||||||
|
_touchSelectionLogicalLine(row) {
|
||||||
|
const buffer = this.terminal?.buffer?.active;
|
||||||
|
const cols = Math.max(1, this.terminal?.cols || 1);
|
||||||
|
if (!buffer || typeof window.CodemanTerminalLines?.terminalLogicalLine !== 'function') return null;
|
||||||
|
return window.CodemanTerminalLines.terminalLogicalLine(buffer, row, cols);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The run of NON-WHITESPACE around a cell, as {index, length} in absolute cells.
|
||||||
|
*
|
||||||
|
* Whitespace is the only delimiter on purpose: in a terminal the thing worth
|
||||||
|
* grabbing is a path, a URL, a container id or a hash, and every punctuation-
|
||||||
|
* aware word rule cuts those in half.
|
||||||
|
*
|
||||||
|
* ⚠️ Bounds are found in the reconstructed TEXT (so a token is not cut at a wrap)
|
||||||
|
* and then converted to CELLS, because an xterm selection is one contiguous run of
|
||||||
|
* cells. A token spanning a hard wrap therefore also covers the indent cells
|
||||||
|
* between its halves — the alternative, a selection that skips them, cannot be
|
||||||
|
* expressed and would not match what is highlighted.
|
||||||
|
*/
|
||||||
|
_touchSelectionWordAt(cell) {
|
||||||
|
const cols = Math.max(1, this.terminal?.cols || 1);
|
||||||
|
const line = this._touchSelectionLogicalLine(cell.row);
|
||||||
|
if (!line) return null;
|
||||||
|
const offset = line.cellToOffset(cell.row, cell.col);
|
||||||
|
if (offset < 0) return null;
|
||||||
|
const ch = line.text[offset];
|
||||||
|
if (!ch || !ch.trim()) return null; // pressed on blank space: nothing to select
|
||||||
|
let from = offset;
|
||||||
|
while (from > 0 && line.text[from - 1] && line.text[from - 1].trim()) from--;
|
||||||
|
let to = offset;
|
||||||
|
while (to + 1 < line.text.length && line.text[to + 1] && line.text[to + 1].trim()) to++;
|
||||||
|
const startCell = line.offsetToCell(from);
|
||||||
|
const endCell = line.offsetToCell(to);
|
||||||
|
const index = startCell.row * cols + startCell.col;
|
||||||
|
const length = endCell.row * cols + endCell.col - index + 1;
|
||||||
|
return length > 0 ? { index, length } : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Apply a selection given absolute cell indices; `select()` wraps a length across rows. */
|
||||||
|
_applyTouchSelection(index, length) {
|
||||||
|
const cols = Math.max(1, this.terminal?.cols || 1);
|
||||||
|
if (length <= 0) return;
|
||||||
|
this.terminal?.select?.(index % cols, Math.floor(index / cols), length);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Long-press fired: select the word under the finger and arm drag-to-extend. */
|
||||||
|
_beginTouchSelection(clientX, clientY) {
|
||||||
|
const cell = this._touchSelectionCellAt(clientX, clientY);
|
||||||
|
if (!cell) return false;
|
||||||
|
const word = this._touchSelectionWordAt(cell);
|
||||||
|
if (!word) return false;
|
||||||
|
// The keyboard must not sit on top of the thing being selected, and the
|
||||||
|
// composer would eat the selection on its next keystroke anyway.
|
||||||
|
this._blurMobileTerminalInput();
|
||||||
|
this._touchSelectionAnchor = word;
|
||||||
|
this._touchSelecting = true;
|
||||||
|
this._touchSelectionActive = true;
|
||||||
|
// From here until the gesture ends, no trusted mouse event may reach xterm —
|
||||||
|
// see _endTouchSelectionGesture for why — and the terminal input may not take
|
||||||
|
// focus. Both are re-armed as the gesture continues, since their windows are
|
||||||
|
// short and a press can be held for much longer.
|
||||||
|
this._suppressTrustedTapMouseEvents();
|
||||||
|
this._armTouchSelectionFocusGuard();
|
||||||
|
this._applyTouchSelection(word.index, word.length);
|
||||||
|
// Android answers; iOS ignores it silently. Both are fine.
|
||||||
|
try {
|
||||||
|
navigator.vibrate?.(15);
|
||||||
|
} catch {
|
||||||
|
/* vibration is a nicety, never a requirement */
|
||||||
|
}
|
||||||
|
this._showTouchSelectionBar();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend the selection to a point, from the anchor word.
|
||||||
|
*
|
||||||
|
* Used by BOTH the drag that follows the long-press and a tap while the bar is
|
||||||
|
* up. The tap form is the one that makes this usable on a phone: picking up a
|
||||||
|
* 4px handle with a fingertip is a coin flip, tapping the other end is not.
|
||||||
|
*/
|
||||||
|
_extendTouchSelection(clientX, clientY) {
|
||||||
|
const anchor = this._touchSelectionAnchor;
|
||||||
|
const cell = this._touchSelectionCellAt(clientX, clientY);
|
||||||
|
if (!anchor || !cell) return;
|
||||||
|
const cols = Math.max(1, this.terminal?.cols || 1);
|
||||||
|
const point = cell.row * cols + cell.col;
|
||||||
|
// Grow from whichever END of the anchor word is further away, so the word the
|
||||||
|
// press landed on always stays inside the selection.
|
||||||
|
const from = Math.min(anchor.index, point);
|
||||||
|
const to = Math.max(anchor.index + anchor.length, point + 1);
|
||||||
|
this._applyTouchSelection(from, to - from);
|
||||||
|
this._armTouchSelectionFocusGuard();
|
||||||
|
this._positionTouchSelectionBar();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finger up: end the DRAG, keep the selection and the bar.
|
||||||
|
*
|
||||||
|
* ⚠️ The browser synthesizes a trusted `mousedown`/`mouseup` pair after this
|
||||||
|
* touchend, and xterm acts on BOTH: `CoreBrowserTerminal` calls `focus()` from
|
||||||
|
* its screen-element mousedown — so the on-screen keyboard springs up over the
|
||||||
|
* text you just selected — and `SelectionService` resets the model there, so the
|
||||||
|
* selection, and with it the Copy bar (hidden when `hasSelection()` goes false),
|
||||||
|
* vanishes the instant you lift your finger. That is exactly what long-press-to-
|
||||||
|
* copy did before this: keyboard up, selection gone, nothing to copy.
|
||||||
|
*
|
||||||
|
* The tap path already owns a guard for those events; it simply never armed it
|
||||||
|
* here. Arming it is the fix, and the caller additionally `preventDefault()`s the
|
||||||
|
* touchend so the synthesis is stopped at the source rather than swatted after.
|
||||||
|
*/
|
||||||
|
_endTouchSelectionGesture() {
|
||||||
|
this._touchSelecting = false;
|
||||||
|
this._suppressTrustedTapMouseEvents();
|
||||||
|
this._armTouchSelectionFocusGuard();
|
||||||
|
this._positionTouchSelectionBar();
|
||||||
|
// Auto Copy's other entry point. The touchend that ends this gesture is
|
||||||
|
// preventDefault()ed (that is what stops the compat mouse pair from
|
||||||
|
// stealing the selection back), so the document mouseup listener never sees
|
||||||
|
// this surface at all, so a phone would have a dead toggle without this call.
|
||||||
|
this._flushAutoCopySelection();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Whole logical line under the anchor — the common case a word selection just missed. */
|
||||||
|
_selectTouchSelectionLine() {
|
||||||
|
const anchor = this._touchSelectionAnchor;
|
||||||
|
const cols = Math.max(1, this.terminal?.cols || 1);
|
||||||
|
if (!anchor) return;
|
||||||
|
const line = this._touchSelectionLogicalLine(Math.floor(anchor.index / cols));
|
||||||
|
if (!line) return;
|
||||||
|
// Every row of the logical line, wraps included — that is the whole point of
|
||||||
|
// the button. The end is the last row's last non-blank cell: trailing cells are
|
||||||
|
// padding, and copying them would put a wall of spaces on the clipboard.
|
||||||
|
const buffer = this.terminal?.buffer?.active;
|
||||||
|
const lastRow = (buffer?.getLine(line.endRow)?.translateToString(true) || '').length;
|
||||||
|
const index = line.startRow * cols;
|
||||||
|
const length = line.endRow * cols + Math.max(0, lastRow - 1) - index + 1;
|
||||||
|
if (length <= 0) return;
|
||||||
|
this._touchSelectionAnchor = { index, length };
|
||||||
|
this._applyTouchSelection(index, length);
|
||||||
|
this._positionTouchSelectionBar();
|
||||||
|
// Widening to the whole line is a new selection the user asked for, and the
|
||||||
|
// button's click carries the activation both clipboard paths need.
|
||||||
|
this._flushAutoCopySelection();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Copy through the shared path: Clipboard API, else execCommand (plain-HTTP installs). */
|
||||||
|
async _copyTouchSelection() {
|
||||||
|
const ok = await this.copyTerminalSelection();
|
||||||
|
this._clearTouchSelection();
|
||||||
|
// copyTerminalSelection hands focus back to the terminal, which is right on a
|
||||||
|
// desktop and wrong on a phone: it opens the on-screen keyboard over whatever
|
||||||
|
// was just copied, with nothing waiting to be typed. The execCommand fallback
|
||||||
|
// focuses its own temp textarea on the way through, so this runs after both.
|
||||||
|
if (typeof MobileDetection !== 'undefined' && MobileDetection.isTouchDevice?.()) {
|
||||||
|
this._blurMobileTerminalInput();
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearTouchSelection() {
|
||||||
|
this._touchSelecting = false;
|
||||||
|
this._touchSelectionActive = false;
|
||||||
|
this._touchSelectionAnchor = null;
|
||||||
|
this.terminal?.clearSelection?.();
|
||||||
|
this._hideTouchSelectionBar();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The Copy/Line/dismiss bar. Built in JS — index.html is read once at server start. */
|
||||||
|
_ensureTouchSelectionBar() {
|
||||||
|
if (this._touchSelectionBar?.isConnected) return this._touchSelectionBar;
|
||||||
|
const container = document.getElementById('terminalContainer');
|
||||||
|
if (!container) return null;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.className = 'term-select-bar';
|
||||||
|
bar.setAttribute('role', 'toolbar');
|
||||||
|
bar.innerHTML =
|
||||||
|
'<button type="button" class="term-select-btn" data-act="copy">Copy</button>' +
|
||||||
|
'<button type="button" class="term-select-btn" data-act="line">Line</button>' +
|
||||||
|
'<button type="button" class="term-select-btn term-select-btn--close" data-act="close" aria-label="Clear selection">✕</button>';
|
||||||
|
// Pointer events only: the container's touch handlers are what own gestures in
|
||||||
|
// this subtree, and they skip anything inside the bar (see initTerminal).
|
||||||
|
bar.addEventListener('click', (ev) => {
|
||||||
|
const act = ev.target?.closest?.('[data-act]')?.dataset?.act;
|
||||||
|
if (!act) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (act === 'copy') void this._copyTouchSelection();
|
||||||
|
else if (act === 'line') this._selectTouchSelectionLine();
|
||||||
|
else this._clearTouchSelection();
|
||||||
|
});
|
||||||
|
container.appendChild(bar);
|
||||||
|
this._touchSelectionBar = bar;
|
||||||
|
return bar;
|
||||||
|
},
|
||||||
|
|
||||||
|
_showTouchSelectionBar() {
|
||||||
|
const bar = this._ensureTouchSelectionBar();
|
||||||
|
if (!bar) return;
|
||||||
|
bar.classList.add('visible');
|
||||||
|
this._positionTouchSelectionBar();
|
||||||
|
},
|
||||||
|
|
||||||
|
_hideTouchSelectionBar() {
|
||||||
|
this._touchSelectionBar?.classList.remove('visible');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Park the bar just above the selection, or below it when the selection starts
|
||||||
|
* at the top of the screen. Clamped to the container so it can never sit
|
||||||
|
* off-screen with the only Copy button on it.
|
||||||
|
*/
|
||||||
|
_positionTouchSelectionBar() {
|
||||||
|
const bar = this._touchSelectionBar;
|
||||||
|
const container = document.getElementById('terminalContainer');
|
||||||
|
const screen = this.terminal?.element?.querySelector('.xterm-screen');
|
||||||
|
const cell = this.terminal?._core?._renderService?.dimensions?.css?.cell;
|
||||||
|
const range = this.terminal?.getSelectionPosition?.();
|
||||||
|
const buffer = this.terminal?.buffer?.active;
|
||||||
|
if (!bar || !container || !screen || !cell?.height || !range || !buffer) return;
|
||||||
|
const screenRect = screen.getBoundingClientRect();
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const viewportRow = (row) => row - (buffer.viewportY || 0);
|
||||||
|
const topPx = screenRect.top - containerRect.top + viewportRow(range.start.y) * cell.height;
|
||||||
|
const bottomPx = screenRect.top - containerRect.top + (viewportRow(range.end.y) + 1) * cell.height;
|
||||||
|
const barHeight = bar.offsetHeight || 36;
|
||||||
|
const gap = 6;
|
||||||
|
let top = topPx - barHeight - gap;
|
||||||
|
if (top < 0) top = bottomPx + gap;
|
||||||
|
top = Math.max(0, Math.min(top, containerRect.height - barHeight));
|
||||||
|
const left = screenRect.left - containerRect.left + range.start.x * (cell.width || 8);
|
||||||
|
const barWidth = bar.offsetWidth || 150;
|
||||||
|
bar.style.top = `${Math.round(top)}px`;
|
||||||
|
bar.style.left = `${Math.round(Math.max(0, Math.min(left, containerRect.width - barWidth)))}px`;
|
||||||
|
},
|
||||||
|
|
||||||
showWelcome() {
|
showWelcome() {
|
||||||
// Phones get the session overview instead of the welcome screen: on a small
|
// Phones get the session overview instead of the welcome screen: on a small
|
||||||
// screen "which session is blocked on me" beats "how do I start one". The
|
// screen "which session is blocked on me" beats "how do I start one". The
|
||||||
@@ -3402,19 +3912,96 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return ok;
|
return ok;
|
||||||
},
|
},
|
||||||
|
|
||||||
async copyTerminal() {
|
/**
|
||||||
|
* Auto Copy's ON/OFF, read at flush time from the CACHED settings object
|
||||||
|
* (loadAppSettingsFromStorage memoizes, so this is not a localStorage hit).
|
||||||
|
*
|
||||||
|
* Reading it here rather than mirroring it into a field is deliberate: there
|
||||||
|
* is then no apply-path a future settings save can forget to call, and the
|
||||||
|
* toggle takes effect on the very next selection instead of the next reload.
|
||||||
|
*/
|
||||||
|
_autoCopySelectionEnabled() {
|
||||||
try {
|
try {
|
||||||
const buffer = this.terminal.buffer.active;
|
return this.loadAppSettingsFromStorage?.()?.autoCopySelection === true;
|
||||||
let text = '';
|
} catch {
|
||||||
for (let i = 0; i < buffer.length; i++) {
|
return false;
|
||||||
const line = buffer.getLine(i);
|
|
||||||
if (line) text += line.translateToString(true) + '\n';
|
|
||||||
}
|
}
|
||||||
await navigator.clipboard.writeText(text.replace(/\n+$/, '\n'));
|
},
|
||||||
this.showToast('Copied to clipboard', 'success');
|
|
||||||
} catch (err) {
|
/**
|
||||||
this.showToast('Failed to copy', 'error');
|
* Copy the current terminal selection because the user finished highlighting
|
||||||
|
* it. Called at the end of a selection GESTURE: the document mouseup
|
||||||
|
* installed in initTerminal, and the touch-selection gesture end (a touchend
|
||||||
|
* the touch path preventDefaults, so no mouseup ever arrives there).
|
||||||
|
*
|
||||||
|
* ⚠️ This is NOT copyTerminalSelection(): that one clears the selection (so a
|
||||||
|
* second Ctrl+C is an interrupt) and hands focus back to the terminal. Both
|
||||||
|
* are wrong here: clearing would make the text vanish from under the cursor
|
||||||
|
* that just highlighted it, and focusing opens the on-screen keyboard over
|
||||||
|
* the text on a phone. Focus is instead RESTORED to whatever held it, which
|
||||||
|
* only matters for the execCommand fallback (it focuses a temp textarea on
|
||||||
|
* the way through); the Clipboard API path never moves focus at all.
|
||||||
|
*/
|
||||||
|
async _flushAutoCopySelection() {
|
||||||
|
const decide = window.CodemanAutoCopy?.decide;
|
||||||
|
if (!decide || !this.terminal) return;
|
||||||
|
const text = this.terminal.hasSelection?.() ? this.terminal.getSelection() : '';
|
||||||
|
const verdict = decide({
|
||||||
|
enabled: this._autoCopySelectionEnabled(),
|
||||||
|
text,
|
||||||
|
lastCopied: this._autoCopyLastText,
|
||||||
|
pending: !!this._autoCopyPending,
|
||||||
|
});
|
||||||
|
this._autoCopyPending = false;
|
||||||
|
if (verdict === 'skip') return;
|
||||||
|
if (verdict === 'too-large') {
|
||||||
|
this._autoCopyNotify('Selection too large to copy automatically. Press Ctrl+C.', 'warning');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._autoCopyLastText = text;
|
||||||
|
const focusedBefore = document.activeElement;
|
||||||
|
const ok = await this._copyText(text);
|
||||||
|
if (
|
||||||
|
focusedBefore &&
|
||||||
|
focusedBefore !== document.activeElement &&
|
||||||
|
focusedBefore.isConnected &&
|
||||||
|
typeof focusedBefore.focus === 'function'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
focusedBefore.focus();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!ok) {
|
||||||
|
// Let the next gesture retry the same text rather than dedupe itself into
|
||||||
|
// silence after a transient clipboard refusal.
|
||||||
|
this._autoCopyLastText = '';
|
||||||
|
this._autoCopyNotify('Auto Copy failed: the browser blocked clipboard access', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Silent on success, like every terminal emulator's copy-on-select, except
|
||||||
|
// ONCE per page load: a feature that works by doing nothing visible needs to
|
||||||
|
// say so the first time, or the user cannot tell it from a dead toggle.
|
||||||
|
if (!this._autoCopyHintShown) {
|
||||||
|
this._autoCopyHintShown = true;
|
||||||
|
this.showToast('Auto Copy: selection copied', 'success');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Failure/refusal toast, throttled. Auto Copy fires per gesture, so an
|
||||||
|
* install where the clipboard is permanently blocked would otherwise paint a
|
||||||
|
* toast on every drag, which trains the user to ignore toasts everywhere.
|
||||||
|
*/
|
||||||
|
_autoCopyNotify(message, type) {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = this._autoCopyNotifiedAt;
|
||||||
|
// `last === undefined` is checked rather than falling back to 0: the first
|
||||||
|
// failure must always be reported, and a `now - 0 < throttle` comparison
|
||||||
|
// only happens to hold because the wall clock is a big number.
|
||||||
|
if (last !== undefined && now - last < 10_000) return;
|
||||||
|
this._autoCopyNotifiedAt = now;
|
||||||
|
this.showToast(message, type);
|
||||||
},
|
},
|
||||||
|
|
||||||
_syncMobileHelperTextareaToCursor() {
|
_syncMobileHelperTextareaToCursor() {
|
||||||
@@ -3457,7 +4044,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
const mouseMode = this.terminal.modes?.mouseTrackingMode;
|
||||||
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
const mouseTrackingOn = !!mouseMode && mouseMode !== 'none';
|
||||||
if (!mouseTrackingOn && !this._sessionUsesServerMouseStrip()) return 'input';
|
if (!mouseTrackingOn && !this._shouldReportMouseToCli()) return 'input';
|
||||||
|
|
||||||
const buffer = this.terminal.buffer?.active;
|
const buffer = this.terminal.buffer?.active;
|
||||||
if (!buffer?.getLine) return 'input';
|
if (!buffer?.getLine) return 'input';
|
||||||
@@ -3704,7 +4291,42 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (!touch || !this.terminal) return null;
|
if (!touch || !this.terminal) return null;
|
||||||
// touchstart already classified this exact point; reuse it rather than paying
|
// touchstart already classified this exact point; reuse it rather than paying
|
||||||
// a second full-viewport scan for the same gesture.
|
// a second full-viewport scan for the same gesture.
|
||||||
|
// While a selection is up, a tap EXTENDS it instead of doing its usual job —
|
||||||
|
// picking up a 4px handle with a fingertip is a coin flip, tapping the other
|
||||||
|
// end is not. Dismissal stays explicit (the bar's ✕, or Copy), so no tap is
|
||||||
|
// ever spent on getting out of a mode the user is still using.
|
||||||
|
if (this._touchSelectionActive) {
|
||||||
|
this._extendTouchSelection(touch.clientX, touch.clientY);
|
||||||
|
return 'select';
|
||||||
|
}
|
||||||
|
|
||||||
const intent = cachedIntent ?? this._classifyMobileTerminalTap(touch.clientX, touch.clientY);
|
const intent = cachedIntent ?? this._classifyMobileTerminalTap(touch.clientX, touch.clientY);
|
||||||
|
// Computed once and reused by the keyboard decision at the tail of this
|
||||||
|
// method: both ask the same question, and the pane cannot change in between
|
||||||
|
// (a mouse report only reaches the PTY; its output lands on a later turn).
|
||||||
|
const actionable = this._isActionableMobileTerminalTap(touch.clientX, touch.clientY);
|
||||||
|
|
||||||
|
// A tap that lands ON a link activates it, at any scroll position and before
|
||||||
|
// any mouse report — exactly what a desktop click does, where the provider's
|
||||||
|
// activate() runs and _handleDesktopTerminalClick deliberately skips the SGR
|
||||||
|
// tap for a hovered link so the CLI never also sees a click there.
|
||||||
|
//
|
||||||
|
// Two kinds of row keep their existing meaning instead: the composer, where a
|
||||||
|
// tap places the caret in text the USER typed (_tapIsOnCaretLine), and
|
||||||
|
// TUI-owned rows, where a numbered choice or an expandable readback is
|
||||||
|
// answering a dialog and routinely carries the very path the tap would
|
||||||
|
// otherwise open — on a phone the dialog is the only interaction that
|
||||||
|
// matters, so it wins.
|
||||||
|
if (
|
||||||
|
!actionable &&
|
||||||
|
!this._tapIsOnCaretLine(touch.clientX, touch.clientY) &&
|
||||||
|
this._activateTerminalLinkAtPoint(touch.clientX, touch.clientY)
|
||||||
|
) {
|
||||||
|
// No focus change: a 'content' tap was already blurred by touchstart, and
|
||||||
|
// popping the keyboard behind a tab that is about to take over is noise.
|
||||||
|
return 'link';
|
||||||
|
}
|
||||||
|
|
||||||
if (intent === 'history') {
|
if (intent === 'history') {
|
||||||
// Scrolled up: send NO mouse report — a tap on old output must not be
|
// Scrolled up: send NO mouse report — a tap on old output must not be
|
||||||
// delivered to the CLI as a click on whatever row now occupies that cell.
|
// delivered to the CLI as a click on whatever row now occupies that cell.
|
||||||
@@ -3722,13 +4344,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// xterm's mouse encoder owns live DECSET modes. The synthetic DOM click
|
// xterm's mouse encoder owns live DECSET modes. The synthetic DOM click
|
||||||
// follows the same path as a desktop click.
|
// follows the same path as a desktop click.
|
||||||
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
this._dispatchSyntheticTerminalClick(touch.clientX, touch.clientY);
|
||||||
} else if (shouldActivate && this._sessionUsesServerMouseStrip()) {
|
} else if (shouldActivate && this._shouldReportMouseToCli()) {
|
||||||
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
|
// Claude/Codex/Gemini DECSETs are stripped from the browser stream, so
|
||||||
// report directly to the PTY while retaining local touch scrollback.
|
// report directly to the PTY while retaining local touch scrollback. Only
|
||||||
|
// while the CLI actually has tracking on (see _shouldReportMouseToCli).
|
||||||
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
this._sendSyntheticSgrTap(touch.clientX, touch.clientY);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === 'content' && this._isActionableMobileTerminalTap(touch.clientX, touch.clientY)) {
|
if (intent === 'content' && actionable) {
|
||||||
// A synthetic xterm click can focus its helper textarea. Blur after the
|
// A synthetic xterm click can focus its helper textarea. Blur after the
|
||||||
// report so collapsing a readback never opens or retains the keyboard.
|
// report so collapsing a readback never opens or retains the keyboard.
|
||||||
this._blurMobileTerminalInput();
|
this._blurMobileTerminalInput();
|
||||||
@@ -3792,9 +4415,32 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// output stream has mouse-tracking DECSET sequences stripped before reaching the
|
// output stream has mouse-tracking DECSET sequences stripped before reaching the
|
||||||
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
|
// browser. For these, xterm's live mouseTrackingMode is useless as a gate — the
|
||||||
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
|
// PTY-side TUI keeps tracking enabled, we just never see the enable sequence.
|
||||||
_sessionUsesServerMouseStrip() {
|
/**
|
||||||
const mode = this.sessions?.get(this.activeSessionId)?.mode || 'claude';
|
* True when the browser has to hand-encode a click report for the CLI.
|
||||||
return mode === 'claude' || mode === 'codex' || mode === 'gemini';
|
*
|
||||||
|
* Two conditions, and dropping either one is a bug that has already happened:
|
||||||
|
*
|
||||||
|
* 1. The session's mode is one whose mouse DECSETs the server STRIPS out of
|
||||||
|
* the stream (claude/codex/gemini, `isAltScreenStripMode`), which is why
|
||||||
|
* xterm's own encoder is permanently idle here and something has to stand
|
||||||
|
* in for it.
|
||||||
|
* 2. The CLI actually has a mouse-tracking mode on right now. The server
|
||||||
|
* records that as it strips (`_recordStrippedMouseMode` in session.ts) and
|
||||||
|
* publishes it as `cliMouseTracking`. Without this half the browser
|
||||||
|
* reported EVERY click, so a CLI sitting at its composer with no dialog
|
||||||
|
* open, or a pane that has fallen back to a shell prompt, received mouse
|
||||||
|
* reports it never asked for. A shell prints those as literal text
|
||||||
|
* (`[<0;88;20M`) and they garble the next line typed.
|
||||||
|
*
|
||||||
|
* Fails toward silence: an unknown or stale flag reports nothing rather than
|
||||||
|
* injecting bytes. After a server restart the flag is false until the CLI
|
||||||
|
* re-emits its DECSET, which closing and reopening a dialog does.
|
||||||
|
*/
|
||||||
|
_shouldReportMouseToCli() {
|
||||||
|
const session = this.sessions?.get(this.activeSessionId);
|
||||||
|
const mode = session?.mode || 'claude';
|
||||||
|
if (mode !== 'claude' && mode !== 'codex' && mode !== 'gemini') return false;
|
||||||
|
return session?.cliMouseTracking === true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// True when xterm's viewport shows the live PTY screen (not scrolled up into
|
// True when xterm's viewport shows the live PTY screen (not scrolled up into
|
||||||
@@ -4060,7 +4706,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
|
// Desktop counterpart of the touchend tap branch: hand-encode an SGR report
|
||||||
// for a plain left-click when the server strips mouse DECSETs (see
|
// for a plain left-click when the server strips mouse DECSETs (see
|
||||||
// _sessionUsesServerMouseStrip). Every skip below is a click that already has
|
// _shouldReportMouseToCli). Every skip below is a click that already has
|
||||||
// a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend
|
// a meaning elsewhere: synthetic/compat clicks after a touch tap (touchend
|
||||||
// reported already), modified clicks (shift keeps xterm's selection
|
// reported already), modified clicks (shift keeps xterm's selection
|
||||||
// override), double/triple clicks (word/line selection), drag-selections,
|
// override), double/triple clicks (word/line selection), drag-selections,
|
||||||
@@ -4074,7 +4720,7 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
if (ev.shiftKey || ev.altKey || ev.ctrlKey || ev.metaKey) return;
|
||||||
const mode = this.terminal.modes?.mouseTrackingMode;
|
const mode = this.terminal.modes?.mouseTrackingMode;
|
||||||
if (mode && mode !== 'none') return;
|
if (mode && mode !== 'none') return;
|
||||||
if (!this._sessionUsesServerMouseStrip()) return;
|
if (!this._shouldReportMouseToCli()) return;
|
||||||
if (this.terminal.hasSelection?.()) return;
|
if (this.terminal.hasSelection?.()) return;
|
||||||
if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
|
if (this._linkHovered) return; // link provider hover/leave callbacks (registerFilePathLinkProvider)
|
||||||
if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return;
|
if (performance.now() <= (this._trustedTapMouseSuppressUntil || 0)) return;
|
||||||
@@ -4122,6 +4768,22 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this._predictiveEcho?.refreshFont();
|
this._predictiveEcho?.refreshFont();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the per-device `terminalFontFamily` setting to the live terminal.
|
||||||
|
* The custom family is resolved against the built-in stack (constants.js),
|
||||||
|
* so passing '' / undefined restores the default. Mirrors setFontSize():
|
||||||
|
* refit for the new cell metrics, then refresh the echo overlays' cached
|
||||||
|
* font so predictions keep landing on the right cells.
|
||||||
|
*/
|
||||||
|
applyTerminalFontFamily(custom) {
|
||||||
|
const resolved = window.CodemanTerminalFont.resolve(custom);
|
||||||
|
if (!this.terminal || this.terminal.options.fontFamily === resolved) return;
|
||||||
|
this.terminal.options.fontFamily = resolved;
|
||||||
|
this.fitAddon?.fit();
|
||||||
|
this._localEchoOverlay?.refreshFont();
|
||||||
|
this._predictiveEcho?.refreshFont();
|
||||||
|
},
|
||||||
|
|
||||||
loadFontSize() {
|
loadFontSize() {
|
||||||
const saved = localStorage.getItem('codeman-font-size');
|
const saved = localStorage.getItem('codeman-font-size');
|
||||||
if (saved) {
|
if (saved) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import type {
|
|||||||
FileWriteData,
|
FileWriteData,
|
||||||
} from '../../types.js';
|
} from '../../types.js';
|
||||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
|
import { compileFileQuery } from '../../utils/file-query.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||||
import {
|
import {
|
||||||
AUDIO_ATTACHMENT_EXTENSIONS,
|
AUDIO_ATTACHMENT_EXTENSIONS,
|
||||||
@@ -937,12 +938,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
// File tree listing
|
// File tree listing
|
||||||
app.get('/api/sessions/:id/files', async (req) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
|
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
|
||||||
const session = findSessionOrFail(ctx, id, req);
|
const session = findSessionOrFail(ctx, id, req);
|
||||||
|
|
||||||
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
|
||||||
const includeHidden = showHidden === 'true';
|
const includeHidden = showHidden === 'true';
|
||||||
const workingDir = session.workingDir;
|
const workingDir = session.workingDir;
|
||||||
|
// null for an empty/whitespace query, which is what keeps the default
|
||||||
|
// tree response byte-identical when no search is requested.
|
||||||
|
const matcher = compileFileQuery(q ?? '');
|
||||||
|
|
||||||
// Default excludes - large/generated directories
|
// Default excludes - large/generated directories
|
||||||
const excludeDirs = new Set([
|
const excludeDirs = new Set([
|
||||||
@@ -976,6 +980,92 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
let truncated = false;
|
let truncated = false;
|
||||||
const maxFiles = 5000;
|
const maxFiles = 5000;
|
||||||
|
|
||||||
|
// ===== Search mode =====
|
||||||
|
// A query turns this endpoint into a FLAT match list rather than a nested
|
||||||
|
// tree. It recurses past non-matching directories on purpose — the whole
|
||||||
|
// point of searching is to reach a file whose ancestors do not match — so
|
||||||
|
// it is bounded independently by maxMatches on top of the shared maxFiles
|
||||||
|
// and maxDepth caps, and reports `truncated` when it stops early.
|
||||||
|
if (matcher) {
|
||||||
|
const matches: FileTreeNode[] = [];
|
||||||
|
const maxMatches = 1000;
|
||||||
|
|
||||||
|
const searchDirectory = async (dirPath: string, currentDepth: number): Promise<void> => {
|
||||||
|
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
|
||||||
|
truncated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries: import('node:fs').Dirent[];
|
||||||
|
try {
|
||||||
|
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
// Can't read directory (permission denied, etc.)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||||
|
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (totalFiles + totalDirectories > maxFiles || matches.length >= maxMatches) {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!includeHidden && entry.name.startsWith('.')) continue;
|
||||||
|
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
|
||||||
|
|
||||||
|
const fullPath = join(dirPath, entry.name);
|
||||||
|
const relativePath = relative(workingDir, fullPath);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
totalDirectories++;
|
||||||
|
if (matcher(entry.name, relativePath)) {
|
||||||
|
matches.push({ name: entry.name, path: relativePath, type: 'directory' });
|
||||||
|
}
|
||||||
|
// Always recurse, even when this directory does not match.
|
||||||
|
await searchDirectory(fullPath, currentDepth + 1);
|
||||||
|
} else {
|
||||||
|
totalFiles++;
|
||||||
|
if (matcher(entry.name, relativePath)) {
|
||||||
|
let size: number | undefined;
|
||||||
|
try {
|
||||||
|
size = (await fs.stat(fullPath)).size;
|
||||||
|
} catch {
|
||||||
|
// Skip size if we can't stat the match.
|
||||||
|
}
|
||||||
|
matches.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: relativePath,
|
||||||
|
type: 'file',
|
||||||
|
size,
|
||||||
|
extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await searchDirectory(workingDir, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
root: workingDir,
|
||||||
|
tree: [],
|
||||||
|
matches,
|
||||||
|
totalFiles,
|
||||||
|
totalDirectories,
|
||||||
|
truncated,
|
||||||
|
matchCount: matches.length,
|
||||||
|
query: (q ?? '').trim(),
|
||||||
|
mode: 'search' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<FileTreeNode[]> => {
|
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<FileTreeNode[]> => {
|
||||||
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
|
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
|
||||||
truncated = true;
|
truncated = true;
|
||||||
|
|||||||
+16
-4
@@ -781,19 +781,31 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
// Serve static files — content-hashed assets (e.g. app.a3f8c2e1.js) are immutable, cache aggressively.
|
||||||
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys.
|
// HTML must revalidate every time so browsers pick up new hashed filenames after deploys.
|
||||||
// cacheControl disabled so setHeaders has full control (fastify-static's reply.headers() overwrites setHeaders otherwise).
|
// cacheControl disabled so setHeaders owns Cache-Control for plain static assets.
|
||||||
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
// preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression
|
||||||
await this.app.register(fastifyStatic, {
|
await this.app.register(fastifyStatic, {
|
||||||
root: join(__dirname, 'public'),
|
root: join(__dirname, 'public'),
|
||||||
prefix: '/',
|
prefix: '/',
|
||||||
cacheControl: false,
|
cacheControl: false,
|
||||||
preCompressed: true,
|
preCompressed: true,
|
||||||
setHeaders: (res, path) => {
|
// ⚠️ @fastify/static v10 changed this callback's first argument from a Node
|
||||||
|
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
|
||||||
|
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static
|
||||||
|
// request, which is every page load. See the v10.0.0 release notes.
|
||||||
|
setHeaders: (reply, path) => {
|
||||||
|
// ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
|
||||||
|
// callback wrote to the raw response and Fastify's staged reply headers then
|
||||||
|
// overwrote it, so a route that set its own Cache-Control before .sendFile()
|
||||||
|
// won. Under v10 the callback writes to the reply itself and now wins instead,
|
||||||
|
// which handed `/sw.js` a year of `immutable` in place of the `no-cache,
|
||||||
|
// no-store` its route asks for — a service worker that can never update.
|
||||||
|
// So: a route that already decided keeps its answer.
|
||||||
|
if (reply.getHeader('Cache-Control') !== undefined) return;
|
||||||
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
|
// Use .includes() not .endsWith() — preCompressed serves .html.br/.html.gz
|
||||||
if (path.includes('.html')) {
|
if (path.includes('.html')) {
|
||||||
res.setHeader('Cache-Control', 'no-cache');
|
reply.header('Cache-Control', 'no-cache');
|
||||||
} else {
|
} else {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface SessionListenerRefs {
|
|||||||
limitResumeCancelled: (data: { reason: string }) => void;
|
limitResumeCancelled: (data: { reason: string }) => void;
|
||||||
respawnBreakerTripped: (data: { count: number }) => void;
|
respawnBreakerTripped: (data: { count: number }) => void;
|
||||||
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
|
||||||
|
mouseTrackingChanged: (active: boolean) => void;
|
||||||
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
ralphLoopUpdate: (state: RalphTrackerState) => void;
|
||||||
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
|
||||||
ralphCompletionDetected: (phrase: string) => void;
|
ralphCompletionDetected: (phrase: string) => void;
|
||||||
@@ -342,6 +343,20 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
|
|||||||
deps.broadcastSessionStateDebounced(session.id);
|
deps.broadcastSessionStateDebounced(session.id);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CLI turned mouse tracking on or off (observed while stripping the
|
||||||
|
* DECSETs out of the stream). Rides the full session state so the browser
|
||||||
|
* learns it through the session object it already merges, with no new SSE
|
||||||
|
* event to keep in sync across the two registries.
|
||||||
|
*
|
||||||
|
* Broadcast IMMEDIATELY, not debounced: this flips when a dialog opens, and
|
||||||
|
* a user can click that dialog inside the 500ms debounce window, which is
|
||||||
|
* exactly the click that has to be reported.
|
||||||
|
*/
|
||||||
|
mouseTrackingChanged: () => {
|
||||||
|
deps.broadcast(SseEvent.SessionUpdated, { session: deps.getSessionStateWithRespawn(session) });
|
||||||
|
},
|
||||||
|
|
||||||
// ─── Ralph Tracking Events ──────────────────────────────
|
// ─── Ralph Tracking Events ──────────────────────────────
|
||||||
|
|
||||||
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
/** Broadcasts `session:ralphLoopUpdate` — Ralph tracker loop state changed (iteration, phase) */
|
||||||
@@ -453,6 +468,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
session.on('limitResumeCancelled', refs.limitResumeCancelled);
|
||||||
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
|
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||||
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
session.on('cliInfoUpdated', refs.cliInfoUpdated);
|
||||||
|
session.on('mouseTrackingChanged', refs.mouseTrackingChanged);
|
||||||
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||||
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||||
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
|
session.on('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||||
@@ -487,6 +503,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
|
|||||||
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
session.off('limitResumeCancelled', refs.limitResumeCancelled);
|
||||||
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
|
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
|
||||||
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
session.off('cliInfoUpdated', refs.cliInfoUpdated);
|
||||||
|
session.off('mouseTrackingChanged', refs.mouseTrackingChanged);
|
||||||
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
|
||||||
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
|
||||||
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
|
session.off('ralphCompletionDetected', refs.ralphCompletionDetected);
|
||||||
|
|||||||
@@ -147,3 +147,94 @@ describe('tmux-backed shell: strip tmux’s own client smcup, keep everything el
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whatever the strip removes, the server has to remember, because after it runs
|
||||||
|
* nothing downstream can ever see it. The browser hand-encodes click reports for
|
||||||
|
* these modes (`_sendSyntheticSgrTap`), and with no state to consult it did that
|
||||||
|
* on EVERY click, delivering mouse reports to a CLI that never asked for them.
|
||||||
|
*/
|
||||||
|
describe('stripped mouse-tracking state', () => {
|
||||||
|
const trackingOf = (session: Session) => session.toState().cliMouseTracking;
|
||||||
|
|
||||||
|
it('starts off, and stays off for output that never enables tracking', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
expect(trackingOf(session)).toBeUndefined();
|
||||||
|
|
||||||
|
handleOutput(session, 'plain output\x1b[?1049h\x1b[3J');
|
||||||
|
|
||||||
|
expect(trackingOf(session)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows the CLI enabling and disabling a tracking mode', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
const changes: boolean[] = [];
|
||||||
|
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1002hdialog');
|
||||||
|
expect(trackingOf(session)).toBe(true);
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1002ldismissed');
|
||||||
|
expect(trackingOf(session)).toBeUndefined();
|
||||||
|
expect(changes).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores encoding and alt-scroll modes, which do not ask about clicks', () => {
|
||||||
|
// 1005/1006 pick an ENCODING and 1007 is alt-scroll. Counting them would put
|
||||||
|
// the stray reports straight back: a CLI can select SGR encoding without ever
|
||||||
|
// asking to be told where the user clicked.
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1006h\x1b[?1005h\x1b[?1007h');
|
||||||
|
|
||||||
|
expect(trackingOf(session)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays on until the LAST tracking mode goes away', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1000h\x1b[?1002h\x1b[?1006h');
|
||||||
|
expect(trackingOf(session)).toBe(true);
|
||||||
|
|
||||||
|
// A TUI may disable a mode it never enabled; that must not clear the rest.
|
||||||
|
handleOutput(session, '\x1b[?1003l');
|
||||||
|
expect(trackingOf(session)).toBe(true);
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1000l');
|
||||||
|
expect(trackingOf(session)).toBe(true);
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1002l');
|
||||||
|
expect(trackingOf(session)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits only on a real transition, so a repainting TUI costs nothing', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
const changes: boolean[] = [];
|
||||||
|
session.on('mouseTrackingChanged', (active: boolean) => changes.push(active));
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1002h\x1b[?1002h\x1b[?1002h');
|
||||||
|
|
||||||
|
expect(changes).toEqual([true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sees a sequence split across PTY chunks, like the strip that carries it', () => {
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
|
||||||
|
|
||||||
|
handleOutput(session, 'before\x1b[?100');
|
||||||
|
handleOutput(session, '2h after');
|
||||||
|
|
||||||
|
expect(session.terminalBuffer).toBe('before after');
|
||||||
|
expect(trackingOf(session)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks nothing for a mode whose DECSETs are never stripped', () => {
|
||||||
|
// shell keeps its mouse DECSETs, so xterm sees them and owns the reporting.
|
||||||
|
// A flag set here would mean a SECOND, hand-encoded report on every click.
|
||||||
|
const session = new Session({ workingDir: '/tmp', mode: 'shell', useMux: true });
|
||||||
|
|
||||||
|
handleOutput(session, '\x1b[?1002hhtop');
|
||||||
|
|
||||||
|
expect(trackingOf(session)).toBeUndefined();
|
||||||
|
expect(session.terminalBuffer).toBe('\x1b[?1002hhtop');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -90,10 +90,12 @@ function expectNoVulnerableBraceExpansion(lock: PackageLock): void {
|
|||||||
expect(versions, 'brace-expansion should be present in package-lock.json').not.toHaveLength(0);
|
expect(versions, 'brace-expansion should be present in package-lock.json').not.toHaveLength(0);
|
||||||
for (const version of versions) {
|
for (const version of versions) {
|
||||||
const major = Number(version.split('.')[0]);
|
const major = Number(version.split('.')[0]);
|
||||||
|
// GHSA-3jxr-9vmj-r5cp (exponential-time expansion DoS) covers <=1.1.17 || 3.0.0 - 5.0.8,
|
||||||
|
// which is why both live branches moved up rather than just the 5.x one.
|
||||||
if (major === 1) {
|
if (major === 1) {
|
||||||
expect(
|
expect(
|
||||||
compareVersions(version, '1.1.13'),
|
compareVersions(version, '1.1.18'),
|
||||||
`brace-expansion@${version} should be >= 1.1.13`
|
`brace-expansion@${version} should be >= 1.1.18`
|
||||||
).toBeGreaterThanOrEqual(0);
|
).toBeGreaterThanOrEqual(0);
|
||||||
} else if (major === 4) {
|
} else if (major === 4) {
|
||||||
expect(
|
expect(
|
||||||
@@ -101,7 +103,7 @@ function expectNoVulnerableBraceExpansion(lock: PackageLock): void {
|
|||||||
`brace-expansion@${version} should not remain on vulnerable 4.x`
|
`brace-expansion@${version} should not remain on vulnerable 4.x`
|
||||||
).toBeGreaterThanOrEqual(0);
|
).toBeGreaterThanOrEqual(0);
|
||||||
} else if (major === 5) {
|
} else if (major === 5) {
|
||||||
expect(compareVersions(version, '5.0.6'), `brace-expansion@${version} should be >= 5.0.6`).toBeGreaterThanOrEqual(
|
expect(compareVersions(version, '5.0.9'), `brace-expansion@${version} should be >= 5.0.9`).toBeGreaterThanOrEqual(
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,7 +115,7 @@ describe('dependency security policy', () => {
|
|||||||
const rootPackage = readJson<PackageLockPackage>('package.json');
|
const rootPackage = readJson<PackageLockPackage>('package.json');
|
||||||
const xtermPackage = readJson<PackageLockPackage>('packages/xterm-zerolag-input/package.json');
|
const xtermPackage = readJson<PackageLockPackage>('packages/xterm-zerolag-input/package.json');
|
||||||
|
|
||||||
expect(rootPackage.dependencies?.['@fastify/static']).toBe('^9.1.3');
|
expect(rootPackage.dependencies?.['@fastify/static']).toBe('^10.1.3');
|
||||||
expect(rootPackage.dependencies?.fastify).toBe('^5.8.5');
|
expect(rootPackage.dependencies?.fastify).toBe('^5.8.5');
|
||||||
expect(rootPackage.dependencies?.uuid).toBe('^14.0.0');
|
expect(rootPackage.dependencies?.uuid).toBe('^14.0.0');
|
||||||
expect(rootPackage.devDependencies?.['@remotion/cli']).toBe('4.0.473');
|
expect(rootPackage.devDependencies?.['@remotion/cli']).toBe('4.0.473');
|
||||||
@@ -130,11 +132,21 @@ describe('dependency security policy', () => {
|
|||||||
expectEveryLockedVersionAtLeast(lock, 'vitest', '4.1.0');
|
expectEveryLockedVersionAtLeast(lock, 'vitest', '4.1.0');
|
||||||
expectEveryLockedVersionAtLeast(lock, '@vitest/coverage-v8', '4.1.0');
|
expectEveryLockedVersionAtLeast(lock, '@vitest/coverage-v8', '4.1.0');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'fastify', '5.8.5');
|
expectEveryLockedVersionAtLeast(lock, 'fastify', '5.8.5');
|
||||||
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '9.1.3');
|
// GHSA-8pvw-jcv7-9cmj (authorization bypass via non-canonical URL paths) covers
|
||||||
|
// <=10.1.1, so every 9.x is affected and the fix is only on the 10.x line.
|
||||||
|
expectEveryLockedVersionAtLeast(lock, '@fastify/static', '10.1.2');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
|
expectEveryLockedVersionAtLeast(lock, 'ip-address', '10.2.0');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0');
|
expectEveryLockedVersionAtLeast(lock, 'uuid', '14.0.0');
|
||||||
|
// ⚠️ Floor stays 8.20.1, NOT 8.21.0. Production ws is already 8.21.0 and clear of
|
||||||
|
// GHSA-96hv-2xvq-fx4p, but @remotion/renderer bundles its own ws@8.20.1 and remotion
|
||||||
|
// is pinned to 4.0.473 on purpose (the compositor refuses to start on a version
|
||||||
|
// mismatch). That copy is devDependencies-only and never ships to users.
|
||||||
expectEveryLockedVersionAtLeast(lock, 'ws', '8.20.1');
|
expectEveryLockedVersionAtLeast(lock, 'ws', '8.20.1');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'fast-uri', '3.1.2');
|
// GHSA-v2hh-gcrm-f6hx (host confusion via literal backslash authority delimiter)
|
||||||
|
// covers 3.0.0 - 3.1.4.
|
||||||
|
expectEveryLockedVersionAtLeast(lock, 'fast-uri', '3.1.5');
|
||||||
|
// GHSA-c96f-x56v-gq3h (HTTP/2 DDoS) covers <=9.6.0.
|
||||||
|
expectEveryLockedVersionAtLeast(lock, 'find-my-way', '9.7.0');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
expectEveryLockedVersionAtLeast(lock, 'basic-ftp', '5.3.1');
|
||||||
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
expectEveryLockedVersionAtLeast(lock, 'flatted', '3.4.2');
|
||||||
expectNoVulnerableBraceExpansion(lock);
|
expectNoVulnerableBraceExpansion(lock);
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for the pure file-query matcher (COD-236).
|
||||||
|
*
|
||||||
|
* Node-safe: no fs, no ports, no jsdom. Just the compile/apply matcher.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { compileFileQuery, matchFileQuery } from '../src/utils/file-query.js';
|
||||||
|
|
||||||
|
describe('compileFileQuery', () => {
|
||||||
|
it('returns null for empty / whitespace-only queries', () => {
|
||||||
|
expect(compileFileQuery('')).toBeNull();
|
||||||
|
expect(compileFileQuery(' ')).toBeNull();
|
||||||
|
expect(compileFileQuery('\t\n')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does a case-insensitive substring match on the name by default', () => {
|
||||||
|
const m = compileFileQuery('Route');
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
expect(m!('file-routes.ts', 'src/web/file-routes.ts')).toBe(true);
|
||||||
|
expect(m!('FILE-ROUTES.TS', 'src/FILE-ROUTES.TS')).toBe(true);
|
||||||
|
expect(m!('session.ts', 'src/session.ts')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches against the relative path when the query contains a slash (substring)', () => {
|
||||||
|
const m = compileFileQuery('web/file');
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
// Name alone would not contain the slash — must match the relative path.
|
||||||
|
expect(m!('file-routes.ts', 'src/web/file-routes.ts')).toBe(true);
|
||||||
|
expect(m!('file-routes.ts', 'src/api/file-routes.ts')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compiles an anchored, case-insensitive glob for * against the name', () => {
|
||||||
|
const m = compileFileQuery('*.ts');
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
expect(m!('session.ts', 'src/session.ts')).toBe(true);
|
||||||
|
expect(m!('SESSION.TS', 'src/SESSION.TS')).toBe(true);
|
||||||
|
// Anchored: .ts must be at the end, not merely contained.
|
||||||
|
expect(m!('session.tsx', 'src/session.tsx')).toBe(false);
|
||||||
|
expect(m!('notes.md', 'notes.md')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats ? as a single-character glob wildcard', () => {
|
||||||
|
const m = compileFileQuery('a?c.txt');
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
expect(m!('abc.txt', 'abc.txt')).toBe(true);
|
||||||
|
expect(m!('axc.txt', 'axc.txt')).toBe(true);
|
||||||
|
// ? matches exactly one char, not zero and not two.
|
||||||
|
expect(m!('ac.txt', 'ac.txt')).toBe(false);
|
||||||
|
expect(m!('abbc.txt', 'abbc.txt')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes regex metacharacters other than * and ? in glob mode', () => {
|
||||||
|
// The dot is a literal, not "any char"; the + is literal too.
|
||||||
|
const m = compileFileQuery('v1.2+*.log');
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
expect(m!('v1.2+final.log', 'v1.2+final.log')).toBe(true);
|
||||||
|
expect(m!('v1X2Yfinal.log', 'v1X2Yfinal.log')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches a glob against the relative path when it contains a slash', () => {
|
||||||
|
const m = compileFileQuery('src/*.ts');
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
expect(m!('session.ts', 'src/session.ts')).toBe(true);
|
||||||
|
expect(m!('session.ts', 'lib/session.ts')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays fast on a pathological star-heavy pattern (no regex backtracking)', () => {
|
||||||
|
// `*a*a*a…` compiled to `^.*a.*a…$` is the classic backtracking blowup —
|
||||||
|
// as a RegExp this match takes effectively forever and this test fails by
|
||||||
|
// timeout. The two-pointer glob walk answers it in linear-ish time; the
|
||||||
|
// 500ms ceiling is generous so a loaded CI box cannot flake it.
|
||||||
|
const m = compileFileQuery('*a'.repeat(40) + 'b');
|
||||||
|
expect(m).not.toBeNull();
|
||||||
|
const started = performance.now();
|
||||||
|
expect(m!('a'.repeat(200), 'a'.repeat(200))).toBe(false);
|
||||||
|
expect(performance.now() - started).toBeLessThan(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an overlong query as no search, like an empty one', () => {
|
||||||
|
// The glob walk is O(text · pattern); the length cap is what bounds it.
|
||||||
|
expect(compileFileQuery('a'.repeat(257))).toBeNull();
|
||||||
|
expect(compileFileQuery('a'.repeat(256))).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchFileQuery', () => {
|
||||||
|
it('compiles then applies in one call', () => {
|
||||||
|
expect(matchFileQuery('route', 'file-routes.ts', 'src/file-routes.ts')).toBe(true);
|
||||||
|
expect(matchFileQuery('*.md', 'readme.md', 'docs/readme.md')).toBe(true);
|
||||||
|
expect(matchFileQuery('*.md', 'readme.txt', 'docs/readme.txt')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when the query compiles to null (empty)', () => {
|
||||||
|
expect(matchFileQuery('', 'anything.ts', 'src/anything.ts')).toBe(false);
|
||||||
|
expect(matchFileQuery(' ', 'anything.ts', 'src/anything.ts')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// Port: none (pure logic in a vm context — no browser, no server).
|
||||||
|
//
|
||||||
|
// On phones the toolbar and the keyboard accessory bar are `position: fixed`, so
|
||||||
|
// they take no layout space: `main`'s padding-bottom is the ONLY thing reserving
|
||||||
|
// room for them, and every pixel taken out of it is a pixel of terminal painted
|
||||||
|
// underneath them.
|
||||||
|
//
|
||||||
|
// `_shrinkPaddingToFit` reclaims the sub-row slack left after a keyboard-driven
|
||||||
|
// re-fit. It used to take the whole slack, which pulled the terminal's bottom edge
|
||||||
|
// down under those bars — and the row the following re-fit gained was painted
|
||||||
|
// behind them, clipping the last line of a long wrapped prompt: the bottom half of
|
||||||
|
// the text being typed. The floor is now the bars' MEASURED height.
|
||||||
|
//
|
||||||
|
// Lives outside test/mobile/ deliberately — that suite is Playwright-driven and
|
||||||
|
// excluded from `npm run test:ci`, so a regression guarded only there is invisible
|
||||||
|
// to CI (same reasoning as terminal-scroll-intent.test.ts).
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8');
|
||||||
|
|
||||||
|
interface Bar {
|
||||||
|
offsetHeight: number;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Setup {
|
||||||
|
paddingBottom: string;
|
||||||
|
containerHeight: number;
|
||||||
|
rows: number;
|
||||||
|
cellH: number;
|
||||||
|
bars: Partial<Record<'.toolbar' | '.keyboard-accessory-bar' | '#cjkInput.cjk-input-visible', Bar>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load mobile-handlers.js and hand back its KeyboardHandler plus the fake `main`
|
||||||
|
* whose inline padding the function edits.
|
||||||
|
*
|
||||||
|
* `const KeyboardHandler = {...}` is a lexical binding that does not survive to a
|
||||||
|
* second `vm.runInContext`, so the export is appended to the SAME script.
|
||||||
|
*/
|
||||||
|
function loadHandler(setup: Setup) {
|
||||||
|
const main = { style: { paddingBottom: setup.paddingBottom } };
|
||||||
|
const container = { clientHeight: setup.containerHeight };
|
||||||
|
let fits = 0;
|
||||||
|
const app = {
|
||||||
|
terminal: {
|
||||||
|
rows: setup.rows,
|
||||||
|
_core: { _renderService: { dimensions: { css: { cell: { height: setup.cellH } } } } },
|
||||||
|
},
|
||||||
|
fitAddon: {
|
||||||
|
fit: () => {
|
||||||
|
fits++;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
app,
|
||||||
|
navigator: { userAgent: 'test', maxTouchPoints: 1 },
|
||||||
|
window: {
|
||||||
|
addEventListener: () => {},
|
||||||
|
matchMedia: () => ({ matches: false }),
|
||||||
|
scrollTo: () => {},
|
||||||
|
getComputedStyle: (el: Bar) => ({ display: el.hidden ? 'none' : 'block', visibility: 'visible' }),
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
body: { classList: { add: () => {}, remove: () => {} } },
|
||||||
|
addEventListener: () => {},
|
||||||
|
getElementById: (id: string) => (id === 'terminalContainer' ? container : null),
|
||||||
|
querySelector: (sel: string) =>
|
||||||
|
sel === '.main' ? main : (setup.bars as Record<string, Bar | undefined>)[sel] || null,
|
||||||
|
},
|
||||||
|
setTimeout: () => 1,
|
||||||
|
clearTimeout: () => {},
|
||||||
|
});
|
||||||
|
vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' });
|
||||||
|
return { handler: (context as { __KH: any }).__KH, main, fits: () => fits };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10 rows × 19px = 190 in a 200px container → 10px of slack, less than one row.
|
||||||
|
const BASE: Setup = {
|
||||||
|
paddingBottom: '84px',
|
||||||
|
containerHeight: 200,
|
||||||
|
rows: 10,
|
||||||
|
cellH: 19,
|
||||||
|
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('_shrinkPaddingToFit', () => {
|
||||||
|
it('reclaims the slack when the reservation over-reserves', () => {
|
||||||
|
// Bars really need 60px, 84 is reserved → the 10px of slack is free to take.
|
||||||
|
const { handler, main } = loadHandler({
|
||||||
|
...BASE,
|
||||||
|
bars: { '.toolbar': { offsetHeight: 30 }, '.keyboard-accessory-bar': { offsetHeight: 30 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
handler._shrinkPaddingToFit();
|
||||||
|
|
||||||
|
expect(main.style.paddingBottom).toBe('74px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never shrinks into the space the bars actually occupy', () => {
|
||||||
|
// 40 + 44 = 84: the reservation is exactly right, so there is nothing to take
|
||||||
|
// even though the terminal has 10px of slack.
|
||||||
|
const { handler, main } = loadHandler(BASE);
|
||||||
|
|
||||||
|
handler._shrinkPaddingToFit();
|
||||||
|
|
||||||
|
expect(main.style.paddingBottom).toBe('84px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops part-way when only some of the slack is free', () => {
|
||||||
|
// Bars need 78px of the reserved 84 → 6px may be reclaimed, not the full 10.
|
||||||
|
const { handler, main } = loadHandler({
|
||||||
|
...BASE,
|
||||||
|
bars: { '.toolbar': { offsetHeight: 34 }, '.keyboard-accessory-bar': { offsetHeight: 44 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
handler._shrinkPaddingToFit();
|
||||||
|
|
||||||
|
expect(main.style.paddingBottom).toBe('78px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op, never a grow, when the bars are taller than the reservation', () => {
|
||||||
|
// Growing the padding here would resize the terminal as a side effect of a
|
||||||
|
// function that exists to reclaim slack.
|
||||||
|
const { handler, main } = loadHandler({
|
||||||
|
...BASE,
|
||||||
|
bars: { '.toolbar': { offsetHeight: 60 }, '.keyboard-accessory-bar': { offsetHeight: 60 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
handler._shrinkPaddingToFit();
|
||||||
|
|
||||||
|
expect(main.style.paddingBottom).toBe('84px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not count a hidden bar', () => {
|
||||||
|
// The accessory bar is display:none until the keyboard opens; counting it
|
||||||
|
// would block a reclaim that is genuinely free.
|
||||||
|
const { handler, main } = loadHandler({
|
||||||
|
...BASE,
|
||||||
|
bars: { '.toolbar': { offsetHeight: 40 }, '.keyboard-accessory-bar': { offsetHeight: 44, hidden: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
handler._shrinkPaddingToFit();
|
||||||
|
|
||||||
|
expect(main.style.paddingBottom).toBe('74px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the CJK input strip when it is on screen', () => {
|
||||||
|
const { handler, main } = loadHandler({
|
||||||
|
...BASE,
|
||||||
|
bars: {
|
||||||
|
'.toolbar': { offsetHeight: 30 },
|
||||||
|
'.keyboard-accessory-bar': { offsetHeight: 30 },
|
||||||
|
'#cjkInput.cjk-input-visible': { offsetHeight: 20 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handler._shrinkPaddingToFit();
|
||||||
|
|
||||||
|
expect(main.style.paddingBottom).toBe('80px');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the padding alone when the slack is a whole row or more', () => {
|
||||||
|
// A full row of slack means the re-fit will claim it as a row; padding is not
|
||||||
|
// the lever here.
|
||||||
|
const { handler, main, fits } = loadHandler({ ...BASE, containerHeight: 190 + 19 });
|
||||||
|
|
||||||
|
handler._shrinkPaddingToFit();
|
||||||
|
|
||||||
|
expect(main.style.paddingBottom).toBe('84px');
|
||||||
|
expect(fits()).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Response-viewer links open in a NEW tab (`CodemanApp._renderMarkdown`).
|
||||||
|
*
|
||||||
|
* `marked` emits a bare `<a href>` and the markdown sanitizer's allowlist carries no
|
||||||
|
* `target`, so every link in the chat used to navigate the CURRENT tab. On a phone that
|
||||||
|
* unloads the whole dashboard — SSE, terminal buffers, unsent composer text — and the OS
|
||||||
|
* back gesture reloads it from scratch, with no middle-click or open-in-new-tab affordance
|
||||||
|
* to work around it. That is the "links don't open on mobile" report.
|
||||||
|
*
|
||||||
|
* `_renderMarkdown` therefore decorates anchors AFTER sanitizing, which makes it the single
|
||||||
|
* source of both attributes: whatever an agent wrote is already stripped by then, and `rel`
|
||||||
|
* is set on the same element in the same pass, so no page Codeman opens can reach back
|
||||||
|
* through `window.opener` (reverse tabnabbing).
|
||||||
|
*
|
||||||
|
* Drives the SHIPPING artifacts — vendored `marked`, vendored DOMPurify + `sanitize-html.js`,
|
||||||
|
* and `app.js` itself — in a `vm` with a jsdom document injected (the technique from
|
||||||
|
* markdown-sanitizer.test.ts / response-viewer-file-links.test.ts; a per-file jsdom
|
||||||
|
* environment would externalize node:fs under vite).
|
||||||
|
*
|
||||||
|
* No port / server needed.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { performance } from 'node:perf_hooks';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { JSDOM } from 'jsdom';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
|
||||||
|
|
||||||
|
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||||
|
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
|
||||||
|
const { document, NodeFilter } = dom.window;
|
||||||
|
|
||||||
|
/** The shipping sanitizer: vendored DOMPurify bound to our jsdom window + the real config. */
|
||||||
|
function loadShippingSanitizer(): (html: string) => string {
|
||||||
|
const dpModule: { exports: unknown } = { exports: {} };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||||
|
new Function('module', 'exports', publicFile('vendor/dompurify.min.js'))(dpModule, dpModule.exports);
|
||||||
|
const DOMPurify = (dpModule.exports as (win: unknown) => unknown)(jsdomWindow);
|
||||||
|
|
||||||
|
const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = {
|
||||||
|
exports: {},
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||||
|
new Function('module', 'exports', publicFile('sanitize-html.js'))(sanModule, sanModule.exports);
|
||||||
|
const create = sanModule.exports.createMarkdownSanitizer;
|
||||||
|
if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported');
|
||||||
|
return create(DOMPurify);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The vendored `marked` build the page loads, evaluated as CommonJS. */
|
||||||
|
function loadShippingMarked(): { parse: (src: string, opts?: unknown) => string } {
|
||||||
|
const module: { exports: unknown } = { exports: {} };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
|
||||||
|
new Function('module', 'exports', publicFile('vendor/marked.min.js'))(module, module.exports);
|
||||||
|
return module.exports as { parse: (src: string, opts?: unknown) => string };
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderApp = { _renderMarkdown(text: string): string };
|
||||||
|
|
||||||
|
function loadCodemanAppClass(): { prototype: RenderApp } {
|
||||||
|
const context = vm.createContext({
|
||||||
|
console,
|
||||||
|
performance,
|
||||||
|
setInterval: vi.fn(),
|
||||||
|
clearInterval: vi.fn(),
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
requestAnimationFrame: vi.fn(),
|
||||||
|
HTMLCanvasElement: class HTMLCanvasElement {},
|
||||||
|
fetch: vi.fn(),
|
||||||
|
document,
|
||||||
|
NodeFilter,
|
||||||
|
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
|
||||||
|
// The page wires the sanitizer onto window; _sanitizeHtml fails closed without it,
|
||||||
|
// and a closed-failing render would make every assertion below vacuous.
|
||||||
|
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), sanitizeMarkdownHtml: loadShippingSanitizer() },
|
||||||
|
marked: loadShippingMarked(),
|
||||||
|
MobileDetection: {},
|
||||||
|
});
|
||||||
|
vm.runInContext(
|
||||||
|
`${publicFile('constants.js')}\n${publicFile('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
|
||||||
|
context
|
||||||
|
);
|
||||||
|
return (context as { __CodemanApp: { prototype: RenderApp } }).__CodemanApp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodemanApp = loadCodemanAppClass();
|
||||||
|
|
||||||
|
/** Render markdown the way the response viewer does and return the resulting element. */
|
||||||
|
function render(markdown: string): HTMLElement {
|
||||||
|
const app = Object.create(CodemanApp.prototype) as RenderApp;
|
||||||
|
const root = document.createElement('div');
|
||||||
|
root.className = 'rv-text';
|
||||||
|
root.innerHTML = app._renderMarkdown(markdown);
|
||||||
|
return root as unknown as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = (root: HTMLElement, index = 0) => Array.from(root.querySelectorAll('a'))[index];
|
||||||
|
|
||||||
|
describe('response viewer external links', () => {
|
||||||
|
it('opens a markdown link in a new tab, with rel set in the same pass', () => {
|
||||||
|
const root = render('See [the docs](https://example.com/docs?a=1&b=2) for details.');
|
||||||
|
|
||||||
|
const a = anchor(root);
|
||||||
|
expect(a, 'the link survived sanitizing').toBeDefined();
|
||||||
|
expect(a.getAttribute('href')).toBe('https://example.com/docs?a=1&b=2');
|
||||||
|
expect(a.getAttribute('target')).toBe('_blank');
|
||||||
|
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens an autolinked bare URL in a new tab too', () => {
|
||||||
|
// gfm autolinks a bare URL, which is how an agent usually prints one.
|
||||||
|
const root = render('Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to continue.');
|
||||||
|
|
||||||
|
const a = anchor(root);
|
||||||
|
expect(a.getAttribute('href')).toBe('https://claude.ai/oauth/authorize?code=true&client_id=abc');
|
||||||
|
expect(a.getAttribute('target')).toBe('_blank');
|
||||||
|
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a same-origin path to a new tab as well — it is still a navigation away', () => {
|
||||||
|
const root = render('Check [status](/api/status).');
|
||||||
|
|
||||||
|
expect(anchor(root).getAttribute('target')).toBe('_blank');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an in-page fragment link alone', () => {
|
||||||
|
// A target here would open a second copy of the app to scroll it.
|
||||||
|
const root = render('Jump to [the section](#results).');
|
||||||
|
|
||||||
|
const a = anchor(root);
|
||||||
|
expect(a.getAttribute('href')).toBe('#results');
|
||||||
|
expect(a.hasAttribute('target')).toBe(false);
|
||||||
|
expect(a.hasAttribute('rel')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves mailto: and tel: to the OS instead of stranding an empty tab', () => {
|
||||||
|
const root = render('Mail [me](mailto:a@example.com) or call [now](tel:+15551234).');
|
||||||
|
|
||||||
|
for (const a of Array.from(root.querySelectorAll('a'))) {
|
||||||
|
expect(a.hasAttribute('target'), a.getAttribute('href') || '').toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the ONLY source of target/rel: an agent cannot ask for an opener', () => {
|
||||||
|
// The sanitizer's allowlist has neither attribute, so agent-authored ones are gone
|
||||||
|
// before this pass runs — and the pass sets both together, so `rel` can never end up
|
||||||
|
// weaker than the target it accompanies.
|
||||||
|
const root = render('<a href="https://evil.example/" target="_self" rel="opener">click</a>');
|
||||||
|
|
||||||
|
const a = anchor(root);
|
||||||
|
expect(a.getAttribute('target')).toBe('_blank');
|
||||||
|
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still drops a javascript: link rather than decorating it', () => {
|
||||||
|
const root = render('[x](javascript:alert(1))');
|
||||||
|
|
||||||
|
const a = anchor(root);
|
||||||
|
// DOMPurify strips the unsafe href; whatever is left must not carry a target either,
|
||||||
|
// which would turn a hollow anchor into a window-opening one.
|
||||||
|
expect(a?.getAttribute('href') ?? null).toBeNull();
|
||||||
|
expect(a?.hasAttribute('target') ?? false).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps code blocks and their copy affordance intact', () => {
|
||||||
|
// The anchor pass shares the one template walk with the code-block wrapper; a mistake
|
||||||
|
// there would silently drop the toolbar rather than fail loudly.
|
||||||
|
const root = render('```\nconst a = 1;\n```');
|
||||||
|
|
||||||
|
expect(root.querySelector('.rv-code-wrap')).not.toBeNull();
|
||||||
|
expect(root.querySelector('.rv-copy-btn')).not.toBeNull();
|
||||||
|
expect(root.querySelector('pre code')?.textContent).toContain('const a = 1;');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/sessions/:id/files?q=... — search mode.
|
||||||
|
*
|
||||||
|
* A query turns the endpoint from a nested tree into a flat match list. Two
|
||||||
|
* properties are worth pinning: the walk must recurse PAST non-matching
|
||||||
|
* directories (searching is pointless if a file whose parents don't match is
|
||||||
|
* unreachable), and an empty query must leave the default tree response exactly
|
||||||
|
* as it was, since that is every existing caller.
|
||||||
|
*
|
||||||
|
* Runs against a real temp directory rather than a mocked fs: the value here is
|
||||||
|
* the traversal, and a mocked readdir would just be asserting the mock.
|
||||||
|
* Port: none (app.inject).
|
||||||
|
*/
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||||
|
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'codeman-file-search-'));
|
||||||
|
mkdirSync(join(root, 'src', 'deep', 'nested'), { recursive: true });
|
||||||
|
mkdirSync(join(root, 'node_modules', 'pkg'), { recursive: true });
|
||||||
|
writeFileSync(join(root, 'README.md'), '#\n');
|
||||||
|
writeFileSync(join(root, 'src', 'widget.ts'), 'x\n');
|
||||||
|
writeFileSync(join(root, 'src', 'deep', 'nested', 'widget-test.ts'), 'x\n');
|
||||||
|
writeFileSync(join(root, 'node_modules', 'pkg', 'widget.ts'), 'x\n');
|
||||||
|
|
||||||
|
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe('files endpoint search mode', () => {
|
||||||
|
let harness: RouteTestHarness;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
harness = await createRouteTestHarness(registerFileRoutes);
|
||||||
|
harness.ctx._session.workingDir = root;
|
||||||
|
});
|
||||||
|
|
||||||
|
const get = async (query: string) => {
|
||||||
|
const res = await harness.app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/sessions/${harness.ctx._sessionId}/files${query}`,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return JSON.parse(res.body);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('returns a flat match list and reaches files under non-matching directories', () => {
|
||||||
|
return get('?q=widget').then((body) => {
|
||||||
|
expect(body.success).toBe(true);
|
||||||
|
expect(body.data.mode).toBe('search');
|
||||||
|
|
||||||
|
const paths = body.data.matches.map((m: { path: string }) => m.path).sort();
|
||||||
|
// deep/nested matches only because the walk recursed through `src` and
|
||||||
|
// `deep`, neither of which matches 'widget' itself.
|
||||||
|
expect(paths).toEqual([join('src', 'deep', 'nested', 'widget-test.ts'), join('src', 'widget.ts')]);
|
||||||
|
expect(body.data.matchCount).toBe(2);
|
||||||
|
expect(body.data.query).toBe('widget');
|
||||||
|
// The nested tree is not built in search mode.
|
||||||
|
expect(body.data.tree).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still honours the excluded-directory list while searching', async () => {
|
||||||
|
const body = await get('?q=widget');
|
||||||
|
const paths = body.data.matches.map((m: { path: string }) => m.path);
|
||||||
|
|
||||||
|
expect(paths.some((p: string) => p.includes('node_modules'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the default tree response untouched when no query is given', async () => {
|
||||||
|
const body = await get('');
|
||||||
|
|
||||||
|
expect(body.data.mode).toBeUndefined();
|
||||||
|
expect(body.data.matches).toBeUndefined();
|
||||||
|
expect(Array.isArray(body.data.tree)).toBe(true);
|
||||||
|
expect(body.data.tree.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a whitespace-only query as no query at all', async () => {
|
||||||
|
const body = await get('?q=%20%20');
|
||||||
|
|
||||||
|
expect(body.data.mode).toBeUndefined();
|
||||||
|
expect(Array.isArray(body.data.tree)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports directories that match as well as files', async () => {
|
||||||
|
const body = await get('?q=nested');
|
||||||
|
|
||||||
|
expect(body.data.matches).toHaveLength(1);
|
||||||
|
expect(body.data.matches[0]).toMatchObject({ name: 'nested', type: 'directory' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview `@fastify/static`'s `setHeaders` callback is what sets Cache-Control
|
||||||
|
* on every asset Codeman serves, and v10 silently changed its contract.
|
||||||
|
*
|
||||||
|
* In v9 the first argument was a Node `ServerResponse`, so the body called
|
||||||
|
* `res.setHeader(...)`. In v10 it is a `FastifyReply`, which has no `setHeader`, so
|
||||||
|
* the v9 body throws `TypeError: res.setHeader is not a function` from inside
|
||||||
|
* `@fastify/static` on EVERY static request. Nothing in the type-checker catches a
|
||||||
|
* revert (the callback's parameter is inferred), and nothing else in the suite reads
|
||||||
|
* these headers, so without this file the whole caching contract is untested.
|
||||||
|
*
|
||||||
|
* That contract is load-bearing: assets are served `immutable` for a year, and
|
||||||
|
* `index.html` must revalidate every time or a deploy leaves browsers on stale
|
||||||
|
* markup (see `cacheBustAssets` in server.ts).
|
||||||
|
*
|
||||||
|
* These tests drive a REAL WebServer on purpose. Asserting against an inline
|
||||||
|
* re-registration of the plugin would keep passing after a revert in server.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { WebServer } from '../src/web/server.js';
|
||||||
|
|
||||||
|
const TEST_PORT = 3183;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and fully drain the body. Both halves matter for teardown: an unconsumed
|
||||||
|
* body leaves undici holding the socket, and a pooled keep-alive socket makes
|
||||||
|
* `server.stop()` wait for it, which times out the afterAll hook.
|
||||||
|
*/
|
||||||
|
async function get(url: string): Promise<Response> {
|
||||||
|
const res = await fetch(url, { headers: { connection: 'close' } });
|
||||||
|
await res.arrayBuffer();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('static asset Cache-Control headers', () => {
|
||||||
|
let server: WebServer;
|
||||||
|
let baseUrl: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
server = new WebServer(TEST_PORT, false, true);
|
||||||
|
await server.start();
|
||||||
|
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await server.stop();
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('serves a static asset at all (proves setHeaders did not throw)', async () => {
|
||||||
|
// The v9-form regression surfaces here first: @fastify/static invokes setHeaders
|
||||||
|
// while streaming, so a TypeError inside it takes the response down rather than
|
||||||
|
// merely omitting a header.
|
||||||
|
const res = await get(`${baseUrl}/app.js`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks long-lived assets immutable', async () => {
|
||||||
|
const res = await get(`${baseUrl}/app.js`);
|
||||||
|
expect(res.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes static HTML revalidate so deploys are picked up', async () => {
|
||||||
|
// ⚠️ upload.html, NOT index.html. `/index.html` has its own explicit route that
|
||||||
|
// answers from renderIndexHtml() and never reaches @fastify/static, so asserting
|
||||||
|
// on it passes even with setHeaders fully broken (verified: reverting server.ts
|
||||||
|
// to the v9 form fails the two /app.js tests and leaves an index.html assertion
|
||||||
|
// green). upload.html has no route of its own, so it is the only HTML that
|
||||||
|
// actually exercises the `.html` branch of setHeaders.
|
||||||
|
const res = await get(`${baseUrl}/upload.html`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('cache-control')).toBe('no-cache');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets a route keep the Cache-Control it set, so sw.js stays uncached', async () => {
|
||||||
|
// Regression guard for the OTHER half of the v10 change. setHeaders runs for
|
||||||
|
// sendFile() too, and v10 moved it from the raw response onto the reply, which
|
||||||
|
// flipped precedence: the callback started overriding routes instead of being
|
||||||
|
// overridden by them. That gave /sw.js `immutable, max-age=31536000` in place of
|
||||||
|
// the `no-cache, no-store` its route sets — a service worker pinned for a year,
|
||||||
|
// which is unrecoverable from the server side because clients stop asking.
|
||||||
|
// Measured against v9 to confirm this is the historical behaviour, not a guess.
|
||||||
|
const res = await get(`${baseUrl}/sw.js`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('cache-control')).toBe('no-cache, no-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serves the rendered index with no-cache too, via its own route', async () => {
|
||||||
|
// Different code path from the above, same contract: index.html is generated per
|
||||||
|
// request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would
|
||||||
|
// pin browsers to the asset versions current at deploy time.
|
||||||
|
const res = await get(`${baseUrl}/index.html`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get('cache-control')).toContain('no-cache');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Auto Copy (copy-on-select) guards.
|
||||||
|
*
|
||||||
|
* The feature is invisible when it works, so every bug in it is silent: a
|
||||||
|
* clipboard that quietly stops updating, or one that quietly overwrites itself
|
||||||
|
* on an unrelated click. These tests drive the SHIPPED decision helper
|
||||||
|
* (constants.js) and the SHIPPED flush (terminal-ui.js), plus the settings
|
||||||
|
* wiring that decides whether the toggle reaches the code at all.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const publicDir = resolve(import.meta.dirname, '../src/web/public');
|
||||||
|
const read = (name: string) => readFileSync(resolve(publicDir, name), 'utf8');
|
||||||
|
|
||||||
|
function loadHarness() {
|
||||||
|
const CodemanApp = function CodemanApp(this: any) {};
|
||||||
|
const windowRef: Record<string, any> = {};
|
||||||
|
const documentListeners = new Map<string, ((ev: any) => void)[]>();
|
||||||
|
let activeElement: any = null;
|
||||||
|
let now = 1_000;
|
||||||
|
|
||||||
|
const RealDate = Date;
|
||||||
|
const DateStub: any = function DateStub(this: any, ...args: any[]) {
|
||||||
|
return new (RealDate as any)(...args);
|
||||||
|
};
|
||||||
|
DateStub.now = () => now;
|
||||||
|
|
||||||
|
const context = vm.createContext({
|
||||||
|
window: windowRef,
|
||||||
|
document: {
|
||||||
|
body: { classList: { contains: () => false } },
|
||||||
|
get activeElement() {
|
||||||
|
return activeElement;
|
||||||
|
},
|
||||||
|
getElementById: () => null,
|
||||||
|
addEventListener: (type: string, listener: (ev: any) => void) => {
|
||||||
|
const list = documentListeners.get(type) ?? [];
|
||||||
|
list.push(listener);
|
||||||
|
documentListeners.set(type, list);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CodemanApp,
|
||||||
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||||
|
_crashDiag: { log: vi.fn() },
|
||||||
|
Date: DateStub,
|
||||||
|
performance: { now: () => now },
|
||||||
|
requestAnimationFrame: (_fn: () => void) => 1,
|
||||||
|
setTimeout: (_fn: () => void) => 1,
|
||||||
|
Blob: function Blob() {},
|
||||||
|
URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} },
|
||||||
|
Worker: function Worker(this: any) {
|
||||||
|
this.postMessage = () => {};
|
||||||
|
},
|
||||||
|
MobileDetection: { isTouchDevice: () => false },
|
||||||
|
KeyboardHandler: { keyboardVisible: false },
|
||||||
|
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
|
||||||
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
|
});
|
||||||
|
|
||||||
|
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
|
||||||
|
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
|
||||||
|
|
||||||
|
const app = new (CodemanApp as any)();
|
||||||
|
const toasts: { message: string; type: string }[] = [];
|
||||||
|
app.showToast = (message: string, type: string) => toasts.push({ message, type });
|
||||||
|
app._copyText = vi.fn(async () => true);
|
||||||
|
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: true });
|
||||||
|
app.terminal = {
|
||||||
|
hasSelection: () => true,
|
||||||
|
getSelection: () => 'copied text',
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
focus: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
windowRef,
|
||||||
|
toasts,
|
||||||
|
setNow: (value: number) => {
|
||||||
|
now = value;
|
||||||
|
},
|
||||||
|
setActiveElement: (element: any) => {
|
||||||
|
activeElement = element;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('decideAutoCopy', () => {
|
||||||
|
const decide = (params: Record<string, unknown>) => loadHarness().windowRef.CodemanAutoCopy.decide(params);
|
||||||
|
|
||||||
|
it('does nothing while the setting is off', () => {
|
||||||
|
expect(decide({ enabled: false, text: 'hello', pending: true })).toBe('skip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a blank or whitespace-only selection', () => {
|
||||||
|
// A drag across empty cells; a wall of spaces is never what the gesture meant.
|
||||||
|
expect(decide({ enabled: true, text: ' \n ', pending: true })).toBe('skip');
|
||||||
|
expect(decide({ enabled: true, text: '', pending: true })).toBe('skip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a genuine selection change even when the text repeats', () => {
|
||||||
|
// Re-selecting the same text after copying something else in between is a
|
||||||
|
// deliberate act, so `pending` outranks the text dedupe.
|
||||||
|
expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: true })).toBe('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies changed text when onSelectionChange has not fired yet', () => {
|
||||||
|
// xterm fires onSelectionChange from its OWN document mouseup handler, and
|
||||||
|
// listener order between the two is not something the app controls, so the
|
||||||
|
// first copy of a drag must not depend on `pending`.
|
||||||
|
expect(decide({ enabled: true, text: 'fresh', lastCopied: 'stale', pending: false })).toBe('copy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an unchanged selection alone on an unrelated mouseup', () => {
|
||||||
|
expect(decide({ enabled: true, text: 'same', lastCopied: 'same', pending: false })).toBe('skip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a selection past the size cap instead of truncating it', () => {
|
||||||
|
const { windowRef } = loadHarness();
|
||||||
|
const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1);
|
||||||
|
expect(windowRef.CodemanAutoCopy.decide({ enabled: true, text: huge, pending: true })).toBe('too-large');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('_flushAutoCopySelection', () => {
|
||||||
|
it('copies the selection without clearing it or stealing focus', async () => {
|
||||||
|
const { app } = loadHarness();
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._copyText).toHaveBeenCalledWith('copied text');
|
||||||
|
// The two things copyTerminalSelection does and this path must not: clearing
|
||||||
|
// makes the text vanish under the cursor that highlighted it, and focusing
|
||||||
|
// opens the on-screen keyboard over it on a phone.
|
||||||
|
expect(app.terminal.clearSelection).not.toHaveBeenCalled();
|
||||||
|
expect(app.terminal.focus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the copy synchronously, inside the gesture that triggered it', () => {
|
||||||
|
// Both clipboard paths need user activation: Firefox gates
|
||||||
|
// navigator.clipboard.writeText on it, and execCommand('copy') (the
|
||||||
|
// plain-HTTP fallback) must run in the gesture's own task.
|
||||||
|
const { app } = loadHarness();
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
void app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._copyText).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent while the setting is off', async () => {
|
||||||
|
const { app, toasts } = loadHarness();
|
||||||
|
app.loadAppSettingsFromStorage = () => ({ autoCopySelection: false });
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._copyText).not.toHaveBeenCalled();
|
||||||
|
expect(toasts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a missing setting as off', async () => {
|
||||||
|
const { app } = loadHarness();
|
||||||
|
app.loadAppSettingsFromStorage = () => ({});
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._copyText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces itself once per page load, then goes quiet', async () => {
|
||||||
|
const { app, toasts } = loadHarness();
|
||||||
|
let text = 'first';
|
||||||
|
app.terminal.getSelection = () => text;
|
||||||
|
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
text = 'second';
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._copyText).toHaveBeenCalledTimes(2);
|
||||||
|
expect(toasts).toEqual([{ message: 'Auto Copy: selection copied', type: 'success' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disarms the pending flag so the next unrelated mouseup copies nothing', async () => {
|
||||||
|
const { app } = loadHarness();
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._autoCopyPending).toBe(false);
|
||||||
|
expect(app._copyText).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands focus back when the execCommand fallback took it', async () => {
|
||||||
|
const { app, setActiveElement } = loadHarness();
|
||||||
|
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||||
|
setActiveElement(terminalTextarea);
|
||||||
|
// The fallback appends a temp textarea, selects it, then removes it, which
|
||||||
|
// leaves the document with no focused element at all.
|
||||||
|
app._copyText = vi.fn(async () => {
|
||||||
|
setActiveElement(null);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(terminalTextarea.focus).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves focus alone when the clipboard API never moved it', async () => {
|
||||||
|
const { app, setActiveElement } = loadHarness();
|
||||||
|
const terminalTextarea = { focus: vi.fn(), isConnected: true };
|
||||||
|
setActiveElement(terminalTextarea);
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(terminalTextarea.focus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a blocked clipboard and lets the next gesture retry the same text', async () => {
|
||||||
|
const { app, toasts } = loadHarness();
|
||||||
|
app._copyText = vi.fn(async () => false);
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
expect(toasts).toEqual([{ message: 'Auto Copy failed: the browser blocked clipboard access', type: 'error' }]);
|
||||||
|
|
||||||
|
// Without the reset, the dedupe would swallow every retry of the same text.
|
||||||
|
app._autoCopyPending = false;
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
expect(app._copyText).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throttles the failure toast so a blocked clipboard cannot spam every drag', async () => {
|
||||||
|
const { app, toasts, setNow } = loadHarness();
|
||||||
|
app._copyText = vi.fn(async () => false);
|
||||||
|
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
setNow(2_000);
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
expect(toasts).toHaveLength(1);
|
||||||
|
|
||||||
|
setNow(1_000 + 10_001);
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
expect(toasts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses an oversized selection and says how to copy it anyway', async () => {
|
||||||
|
const { app, toasts, windowRef } = loadHarness();
|
||||||
|
const huge = 'x'.repeat(windowRef.CodemanAutoCopy.MAX_CHARS + 1);
|
||||||
|
app.terminal.getSelection = () => huge;
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._copyText).not.toHaveBeenCalled();
|
||||||
|
expect(toasts).toEqual([{ message: 'Selection too large to copy automatically. Press Ctrl+C.', type: 'warning' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the selection was already dropped', async () => {
|
||||||
|
const { app } = loadHarness();
|
||||||
|
app.terminal.hasSelection = () => false;
|
||||||
|
app._autoCopyPending = true;
|
||||||
|
|
||||||
|
await app._flushAutoCopySelection();
|
||||||
|
|
||||||
|
expect(app._copyText).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Auto Copy wiring', () => {
|
||||||
|
const terminalUi = read('terminal-ui.js');
|
||||||
|
const settingsUi = read('settings-ui.js');
|
||||||
|
const html = read('index.html');
|
||||||
|
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
|
||||||
|
|
||||||
|
it('arms on a selection change and disarms when the selection is dropped', () => {
|
||||||
|
const start = terminalUi.indexOf('this.terminal.onSelectionChange?.(');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
const body = terminalUi.slice(start, start + 1200);
|
||||||
|
expect(body).toContain('this._autoCopyPending = false;');
|
||||||
|
expect(body).toContain('this._autoCopyPending = true;');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes from the document mouseup, not from the selection change', () => {
|
||||||
|
// Copying inside onSelectionChange would be one clipboard write per cell the
|
||||||
|
// drag crosses.
|
||||||
|
expect(terminalUi).toContain("document.addEventListener('mouseup', () => this._flushAutoCopySelection());");
|
||||||
|
const start = terminalUi.indexOf('this.terminal.onSelectionChange?.(');
|
||||||
|
const body = terminalUi.slice(start, terminalUi.indexOf('_autoCopyListenerInstalled', start));
|
||||||
|
expect(body).not.toContain('_flushAutoCopySelection');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flushes from the touch gesture end, which never produces a mouseup', () => {
|
||||||
|
// The touch path preventDefaults its touchend to stop the compat mouse pair
|
||||||
|
// from stealing the selection back, so phones need their own call.
|
||||||
|
const start = terminalUi.indexOf('_endTouchSelectionGesture() {');
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
expect(terminalUi.slice(start, start + 900)).toContain('this._flushAutoCopySelection();');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the toggle per-device: display key, stripped from the PUT, absent from the schema', () => {
|
||||||
|
const displayKeys = settingsUi.slice(
|
||||||
|
settingsUi.indexOf('const displayKeys = new Set(['),
|
||||||
|
settingsUi.indexOf('])', settingsUi.indexOf('const displayKeys = new Set(['))
|
||||||
|
);
|
||||||
|
expect(displayKeys).toContain("'autoCopySelection'");
|
||||||
|
// SettingsUpdateSchema is .strict(), so a key it does not declare 400s the
|
||||||
|
// whole settings PUT if the client sends it.
|
||||||
|
expect(settingsUi).toContain('autoCopySelection: _acs,');
|
||||||
|
expect(schemas).not.toContain('autoCopySelection');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the control loadable and savable by id', () => {
|
||||||
|
expect(html).toContain('id="appSettingsAutoCopySelection"');
|
||||||
|
expect(settingsUi).toContain(
|
||||||
|
"document.getElementById('appSettingsAutoCopySelection').checked = settings.autoCopySelection === true;"
|
||||||
|
);
|
||||||
|
expect(settingsUi).toContain("autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked,");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
function loadFontHelper() {
|
||||||
|
const context = vm.createContext({ window: {}, globalThis: {} });
|
||||||
|
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
vm.runInContext(source, context, { filename: 'constants.js' });
|
||||||
|
return (
|
||||||
|
context.window as {
|
||||||
|
CodemanTerminalFont: {
|
||||||
|
DEFAULT_STACK: string;
|
||||||
|
resolve: (custom?: unknown) => string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).CodemanTerminalFont;
|
||||||
|
}
|
||||||
|
|
||||||
|
const font = loadFontHelper();
|
||||||
|
|
||||||
|
describe('CodemanTerminalFont', () => {
|
||||||
|
it('returns the default stack for empty or missing input', () => {
|
||||||
|
expect(font.resolve(undefined)).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve('')).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve(' ')).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve(42)).toBe(font.DEFAULT_STACK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the symbols fallback ahead of monospace in the default stack', () => {
|
||||||
|
const symbolsAt = font.DEFAULT_STACK.indexOf('"Symbols Nerd Font Mono"');
|
||||||
|
const monoAt = font.DEFAULT_STACK.lastIndexOf('monospace');
|
||||||
|
expect(symbolsAt).toBeGreaterThan(-1);
|
||||||
|
expect(monoAt).toBeGreaterThan(symbolsAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends a custom family and preserves the full default stack', () => {
|
||||||
|
expect(font.resolve('Menlo')).toBe(`Menlo, ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quotes names that need quoting for CSS', () => {
|
||||||
|
expect(font.resolve('JetBrainsMono Nerd Font')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes already-quoted input instead of double-quoting', () => {
|
||||||
|
expect(font.resolve('"JetBrainsMono Nerd Font"')).toBe(`"JetBrainsMono Nerd Font", ${font.DEFAULT_STACK}`);
|
||||||
|
expect(font.resolve("'Iosevka Term'")).toBe(`"Iosevka Term", ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a comma-separated list', () => {
|
||||||
|
expect(font.resolve('Iosevka, MesloLGS NF')).toBe(`Iosevka, "MesloLGS NF", ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops generic families so they cannot shadow the symbols fallback', () => {
|
||||||
|
expect(font.resolve('monospace')).toBe(font.DEFAULT_STACK);
|
||||||
|
expect(font.resolve('Hack, monospace')).toBe(`Hack, ${font.DEFAULT_STACK}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
// Port: none (a pure function in a vm context — no browser, no server).
|
||||||
|
//
|
||||||
|
// `terminalLogicalLine` (constants.js) reconstructs the logical line a terminal row
|
||||||
|
// belongs to. Two consumers depend on it and must not disagree: the link provider
|
||||||
|
// matches its patterns over this text, and touch selection measures words and whole
|
||||||
|
// lines with it.
|
||||||
|
//
|
||||||
|
// The bug it exists to fix, reported from a phone: an agent printing a numbered list
|
||||||
|
//
|
||||||
|
// 1. https://github.com/users/someone/packages/container/p
|
||||||
|
// ackage/thing
|
||||||
|
//
|
||||||
|
// opened only `…/container/p`. Ink wraps its own output and emits a real newline, so
|
||||||
|
// nothing is flagged `isWrapped`, and the continuation carries the list's indent —
|
||||||
|
// joining the rows verbatim put whitespace in the middle of the URL, where the
|
||||||
|
// pattern stops. "Line" was broken by the same shape: it walked `isWrapped` only, so
|
||||||
|
// it grabbed the single row on screen.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
|
|
||||||
|
type Row = { text: string; wrapped?: boolean };
|
||||||
|
type Logical = {
|
||||||
|
startRow: number;
|
||||||
|
endRow: number;
|
||||||
|
text: string;
|
||||||
|
offsetToCell: (offset: number) => { row: number; col: number };
|
||||||
|
cellToOffset: (row: number, col: number) => number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
const COLS = 60;
|
||||||
|
|
||||||
|
function load(): (buffer: unknown, row: number, cols: number, maxRows?: number) => Logical {
|
||||||
|
const context = vm.createContext({ console, window: undefined });
|
||||||
|
vm.runInContext(`${SOURCE}\nglobalThis.__fn = terminalLogicalLine;`, context, { filename: 'constants.js' });
|
||||||
|
return (context as { __fn: never })['__fn'] as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An xterm-shaped buffer: untrimmed rows pad to the full width, as xterm's do. */
|
||||||
|
function buffer(rows: Row[]) {
|
||||||
|
return {
|
||||||
|
length: rows.length,
|
||||||
|
getLine: (r: number) =>
|
||||||
|
r >= 0 && r < rows.length
|
||||||
|
? {
|
||||||
|
isWrapped: !!rows[r].wrapped,
|
||||||
|
translateToString: (trim?: boolean) => (trim === false ? rows[r].text.padEnd(COLS) : rows[r].text),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalLogicalLine = load();
|
||||||
|
const URL_RE = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/;
|
||||||
|
|
||||||
|
describe('terminalLogicalLine', () => {
|
||||||
|
// Row 0 runs to the last column — the only trace a hard wrap leaves — and row 1
|
||||||
|
// carries the three spaces the list indent put there.
|
||||||
|
const HEAD = '1. https://example.com/';
|
||||||
|
const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length);
|
||||||
|
const ROW1 = ' ackage/thing';
|
||||||
|
const HARD = [{ text: ROW0 }, { text: ROW1 }];
|
||||||
|
|
||||||
|
it('stitches a hard wrap and drops the indent the continuation carries', () => {
|
||||||
|
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
|
||||||
|
|
||||||
|
expect(line.startRow).toBe(0);
|
||||||
|
expect(line.endRow).toBe(1);
|
||||||
|
expect(line.text).toBe(ROW0 + 'ackage/thing');
|
||||||
|
// The whole URL now matches, which is the entire point.
|
||||||
|
expect(line.text.match(URL_RE)![0]).toBe('https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds the same line from the continuation row', () => {
|
||||||
|
// A tap or hover lands on either row; both must resolve the whole thing.
|
||||||
|
const line = terminalLogicalLine(buffer(HARD), 1, COLS)!;
|
||||||
|
|
||||||
|
expect([line.startRow, line.endRow]).toEqual([0, 1]);
|
||||||
|
expect(line.text).toBe(ROW0 + 'ackage/thing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps offsets back to the right cell across the dropped indent', () => {
|
||||||
|
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
|
||||||
|
const at = line.text.indexOf('ackage/thing');
|
||||||
|
|
||||||
|
// 'a' of 'ackage' is the 4th cell of row 1 (0-based col 3), after the indent.
|
||||||
|
expect(line.offsetToCell(at)).toEqual({ row: 1, col: 3 });
|
||||||
|
// …and the reverse direction agrees.
|
||||||
|
expect(line.cellToOffset(1, 3)).toBe(at);
|
||||||
|
// Row 0 is unshifted.
|
||||||
|
expect(line.offsetToCell(3)).toEqual({ row: 0, col: 3 });
|
||||||
|
expect(line.cellToOffset(0, 3)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a SOFT continuation verbatim, indent and all', () => {
|
||||||
|
// The emulator inserts nothing when it wraps, so leading spaces there are real
|
||||||
|
// content and dropping them would corrupt the text.
|
||||||
|
const soft = [{ text: 'x'.repeat(COLS) }, { text: ' tail', wrapped: true }];
|
||||||
|
const line = terminalLogicalLine(buffer(soft), 1, COLS)!;
|
||||||
|
|
||||||
|
expect(line.text).toBe('x'.repeat(COLS) + ' tail');
|
||||||
|
expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stitch a row that stops short of the last column', () => {
|
||||||
|
// A line that genuinely ended is not a wrap, and over-reaching would glue
|
||||||
|
// unrelated output into one link.
|
||||||
|
const rows = [{ text: 'short line' }, { text: 'next line' }];
|
||||||
|
const line = terminalLogicalLine(buffer(rows), 0, COLS)!;
|
||||||
|
|
||||||
|
expect([line.startRow, line.endRow]).toEqual([0, 0]);
|
||||||
|
expect(line.text).toBe('short line');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds the span so a screenful of full-width output cannot be re-scanned per hover', () => {
|
||||||
|
const rows = Array.from({ length: 40 }, () => ({ text: 'y'.repeat(COLS) }));
|
||||||
|
const line = terminalLogicalLine(buffer(rows), 30, COLS, 4)!;
|
||||||
|
|
||||||
|
expect(line.endRow - line.startRow).toBeLessThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims only the final row, so every earlier offset stays aligned to a cell', () => {
|
||||||
|
const rows = [{ text: 'z'.repeat(COLS) }, { text: 'tail' }];
|
||||||
|
const line = terminalLogicalLine(buffer(rows), 0, COLS)!;
|
||||||
|
|
||||||
|
// Row 0 contributes exactly COLS characters; the last row is trimmed.
|
||||||
|
expect(line.text).toBe('z'.repeat(COLS) + 'tail');
|
||||||
|
expect(line.offsetToCell(COLS)).toEqual({ row: 1, col: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers null for a row that does not exist', () => {
|
||||||
|
expect(terminalLogicalLine(buffer([{ text: 'a' }]), 5, COLS)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+509
-18
@@ -8,8 +8,11 @@ function loadTerminalUiHarness() {
|
|||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
let keyboardVisible = false;
|
let keyboardVisible = false;
|
||||||
let activeElement: unknown = null;
|
let activeElement: unknown = null;
|
||||||
|
// The module hangs its constants off window (CodemanTerminalInput) and the URL branch of
|
||||||
|
// the link provider opens through window.open, so tests need a handle on the same object.
|
||||||
|
const windowRef: Record<string, any> = {};
|
||||||
const context = vm.createContext({
|
const context = vm.createContext({
|
||||||
window: {},
|
window: windowRef,
|
||||||
document: {
|
document: {
|
||||||
body: { classList: { contains: () => false } },
|
body: { classList: { contains: () => false } },
|
||||||
get activeElement() {
|
get activeElement() {
|
||||||
@@ -18,7 +21,7 @@ function loadTerminalUiHarness() {
|
|||||||
getElementById: () => null,
|
getElementById: () => null,
|
||||||
},
|
},
|
||||||
CodemanApp,
|
CodemanApp,
|
||||||
console: { warn: vi.fn(), log: vi.fn() },
|
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
|
||||||
_crashDiag: { log: vi.fn() },
|
_crashDiag: { log: vi.fn() },
|
||||||
performance: { now: () => now },
|
performance: { now: () => now },
|
||||||
requestAnimationFrame: (_fn: () => void) => 1,
|
requestAnimationFrame: (_fn: () => void) => 1,
|
||||||
@@ -43,12 +46,18 @@ function loadTerminalUiHarness() {
|
|||||||
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
TERMINAL_CHUNK_SIZE: 32 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// constants.js first: the link provider calls absoluteFilePathPattern() and
|
||||||
|
// previewsInFileViewer() at scan time, and the SHIPPED definitions are what keep a tap and
|
||||||
|
// a hover resolving the same links.
|
||||||
|
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
|
||||||
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
const code = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
|
||||||
|
vm.runInContext(constants, context, { filename: 'constants.js' });
|
||||||
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
|
||||||
|
|
||||||
const app = new (CodemanApp as any)();
|
const app = new (CodemanApp as any)();
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
|
windowRef,
|
||||||
setNow: (value: number) => {
|
setNow: (value: number) => {
|
||||||
now = value;
|
now = value;
|
||||||
},
|
},
|
||||||
@@ -88,13 +97,23 @@ function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new
|
|||||||
active: {
|
active: {
|
||||||
viewportY: 0,
|
viewportY: 0,
|
||||||
baseY: 0,
|
baseY: 0,
|
||||||
|
length: lines.length,
|
||||||
cursorY,
|
cursorY,
|
||||||
getLine: (row: number) =>
|
getLine: (row: number) =>
|
||||||
row >= 0 && row < lines.length
|
row >= 0 && row < lines.length
|
||||||
? { isWrapped: wrappedRows.has(row), translateToString: () => lines[row] }
|
? {
|
||||||
|
isWrapped: wrappedRows.has(row),
|
||||||
|
// xterm pads an UNTRIMMED row to the full width; the selection offset
|
||||||
|
// math is linear over joined rows and would shift without it.
|
||||||
|
translateToString: (trim?: boolean) => (trim === false ? lines[row].padEnd(80) : lines[row]),
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
select: vi.fn(),
|
||||||
|
clearSelection: vi.fn(),
|
||||||
|
hasSelection: () => false,
|
||||||
|
getSelectionPosition: () => undefined,
|
||||||
element: {
|
element: {
|
||||||
querySelector: (selector: string) =>
|
querySelector: (selector: string) =>
|
||||||
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
|
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
|
||||||
@@ -121,7 +140,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => {
|
it('routes a readback row to the TUI while keeping the prompt row as keyboard input', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||||
app.terminal = createTerminalGrid(
|
app.terminal = createTerminalGrid(
|
||||||
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
['Agent readback mentions › inline', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||||
4
|
4
|
||||||
@@ -136,7 +155,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
it('classifies Claude background-agent status as content rather than keyboard input', () => {
|
it('classifies Claude background-agent status as content rather than keyboard input', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliVersion: '2.1.220', cliMouseTracking: true }]]);
|
||||||
app.terminal = createTerminalGrid(
|
app.terminal = createTerminalGrid(
|
||||||
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
['', '', '', '• Working (1m 50s • esc to ', 'interrupt) · 1 background teammate', ''],
|
||||||
4,
|
4,
|
||||||
@@ -149,7 +168,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => {
|
it('keeps the live cursor focusable when Claude temporarily omits its prompt glyph', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
app.terminal = createTerminalGrid(['Prior response', '', 'ready for input', '', 'status footer', ''], 2);
|
||||||
|
|
||||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
expect(app._classifyMobileTerminalTap(9, 33)).toBe('input');
|
||||||
@@ -159,7 +178,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
it('treats a highlighted numbered choice as TUI content, not an input prompt', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
app.terminal = createTerminalGrid(['Would you like to proceed?', '', '❯ 1. Yes', ' 2. No', '', ''], 2);
|
||||||
|
|
||||||
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
expect(app._classifyMobileTerminalTap(9, 33)).toBe('content');
|
||||||
@@ -174,7 +193,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
// commits an answer) as the only thing a phone could do.
|
// commits an answer) as the only thing a phone could do.
|
||||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app.terminal = createTerminalGrid(
|
app.terminal = createTerminalGrid(
|
||||||
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
|
['Do you want to proceed?', '', '❯ 1. Yes', ' 2. No, tell Claude what to do', '', ''],
|
||||||
2
|
2
|
||||||
@@ -200,7 +219,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
it('collapses TUI readback content without opening or retaining the keyboard', () => {
|
it('collapses TUI readback content without opening or retaining the keyboard', () => {
|
||||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'codex' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]);
|
||||||
app.terminal = createTerminalGrid(
|
app.terminal = createTerminalGrid(
|
||||||
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
['Agent readback', ' tap to collapse', '', '', '› ask', 'gpt-5 · Context 80% left'],
|
||||||
4
|
4
|
||||||
@@ -233,7 +252,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
it('closes the keyboard on a second tap of INERT transcript content', () => {
|
it('closes the keyboard on a second tap of INERT transcript content', () => {
|
||||||
const { app, setActiveElement } = loadTerminalUiHarness();
|
const { app, setActiveElement } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
|
app.terminal = createTerminalGrid(['transcript line', '', '', '', '❯ ', ''], 4);
|
||||||
app._sendInputAsync = vi.fn();
|
app._sendInputAsync = vi.fn();
|
||||||
|
|
||||||
@@ -309,7 +328,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
const sent: Array<{ id: string; data: string }> = [];
|
const sent: Array<{ id: string; data: string }> = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -320,7 +339,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
_core: { _renderService: { dimensions: { css: { cell: { width: 8, height: 16 } } } } },
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(app._sessionUsesServerMouseStrip()).toBe(true);
|
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||||
// touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based)
|
// touch at x=10+8*20+1, y=20+16*5+1 → col 21, row 6 (1-based)
|
||||||
app._sendSyntheticSgrTap(171, 101);
|
app._sendSyntheticSgrTap(171, 101);
|
||||||
|
|
||||||
@@ -347,19 +366,60 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
expect(sent).toEqual(['\x1b[<0;1;24M\x1b[<0;1;24m']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not treat shell sessions as server-mouse-strip mode', () => {
|
it('never hand-reports for a shell session, even with tracking somehow set', () => {
|
||||||
|
// Shell DECSETs are NOT stripped (narrow strip), so xterm's own encoder owns
|
||||||
|
// the mouse there and a second, hand-encoded report would double-report.
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'shell', cliMouseTracking: true }]]);
|
||||||
|
|
||||||
expect(app._sessionUsesServerMouseStrip()).toBe(false);
|
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hand-reports only while the CLI actually has mouse tracking on', () => {
|
||||||
|
// The server strips the DECSETs, so xterm can never see them and the browser
|
||||||
|
// reported EVERY click. A claude pane sitting at its composer, or one that
|
||||||
|
// fell back to a shell prompt, was receiving mouse reports it never asked
|
||||||
|
// for; a shell prints those as literal text and they garble the next line.
|
||||||
|
const { app } = loadTerminalUiHarness();
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||||
|
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||||
|
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: false }]]);
|
||||||
|
expect(app._shouldReportMouseToCli()).toBe(false);
|
||||||
|
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
|
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('desktop click: sends nothing while the CLI has no mouse mode on', () => {
|
||||||
|
const { app } = loadTerminalUiHarness();
|
||||||
|
const sent: string[] = [];
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||||
|
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||||
|
app.terminal = createTerminalGrid(['some output', '', '\u276f '], 2);
|
||||||
|
app._linkHovered = false;
|
||||||
|
|
||||||
|
app._handleDesktopTerminalClick({
|
||||||
|
isTrusted: true,
|
||||||
|
button: 0,
|
||||||
|
detail: 1,
|
||||||
|
clientX: 40,
|
||||||
|
clientY: 10,
|
||||||
|
target: { closest: (sel: string) => (sel === '.xterm-screen' ? {} : null) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sent).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => {
|
it('desktop click: encodes SGR press+release for a plain left-click in strip mode', () => {
|
||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
const sent: Array<{ id: string; data: string }> = [];
|
const sent: Array<{ id: string; data: string }> = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
app._sendInputAsync = (id: string, data: string) => sent.push({ id, data });
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -434,7 +494,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
const { app } = loadTerminalUiHarness();
|
const { app } = loadTerminalUiHarness();
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -468,7 +528,7 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
const { app, setNow } = loadTerminalUiHarness();
|
const { app, setNow } = loadTerminalUiHarness();
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
app.activeSessionId = 'sess-1';
|
app.activeSessionId = 'sess-1';
|
||||||
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||||
app.terminal = {
|
app.terminal = {
|
||||||
cols: 80,
|
cols: 80,
|
||||||
@@ -732,3 +792,434 @@ describe('terminal touch tap mouse guard', () => {
|
|||||||
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
expect(event.stopImmediatePropagation).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('terminal link tap', () => {
|
||||||
|
// xterm resolves a link from mousemove and activates it on mouseup over its SCREEN element.
|
||||||
|
// A touch tap produces none of those (touch-action:none and touchstart's preventDefault
|
||||||
|
// suppress the compatibility mouse events, the post-tap guard drops the rest, and the
|
||||||
|
// synthetic pair this app dispatches for mouse REPORTING lands on the .xterm root, an
|
||||||
|
// ancestor of the node the linkifier listens on). So the tap path activates the link
|
||||||
|
// itself, through the same provider, or every URL and path in the terminal stays inert on
|
||||||
|
// a phone.
|
||||||
|
//
|
||||||
|
// Grid geometry from createTerminalGrid: 8×16 cells, screen rect at (0,0), viewportY 0 —
|
||||||
|
// so 0-based character index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
|
||||||
|
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
|
||||||
|
|
||||||
|
/** A claude-mode app with the shipped link provider registered over `lines`. */
|
||||||
|
function linkHarness(lines: string[], cursorY = lines.length - 1) {
|
||||||
|
const harness = loadTerminalUiHarness();
|
||||||
|
const { app, windowRef } = harness;
|
||||||
|
const sent: string[] = [];
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude', cliMouseTracking: true }]]);
|
||||||
|
app._sendInputAsync = (_id: string, data: string) => sent.push(data);
|
||||||
|
app.terminal = createTerminalGrid(lines, cursorY);
|
||||||
|
app.terminal.registerLinkProvider = vi.fn();
|
||||||
|
app.openFilePreview = vi.fn();
|
||||||
|
app.openLogViewerWindow = vi.fn();
|
||||||
|
app._isExternalPreviewPath = () => false;
|
||||||
|
windowRef.open = vi.fn();
|
||||||
|
app.registerFilePathLinkProvider();
|
||||||
|
return { app, windowRef, sent };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens a URL under the finger in a new tab', () => {
|
||||||
|
const line = 'Login at https://claude.ai/oauth/authorize?code=true&client_id=abc to finish';
|
||||||
|
const { app, windowRef } = linkHarness([line, '', '❯ ']);
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content')).toBe('link');
|
||||||
|
expect(windowRef.open).toHaveBeenCalledWith(
|
||||||
|
'https://claude.ai/oauth/authorize?code=true&client_id=abc',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends no mouse report for the tap it just spent on a link', () => {
|
||||||
|
// The CLI must not also see a click there: that is how a tap on a URL printed inside a
|
||||||
|
// permission dialog would answer the dialog. Desktop already skips the SGR tap for a
|
||||||
|
// hovered link (_handleDesktopTerminalClick).
|
||||||
|
const line = 'see https://example.com/x for more';
|
||||||
|
const { app, sent } = linkHarness([line, '', '❯ ']);
|
||||||
|
|
||||||
|
expect(app._shouldReportMouseToCli()).toBe(true);
|
||||||
|
app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'content');
|
||||||
|
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a tap beside the link as an ordinary tap', () => {
|
||||||
|
// Containment is xterm's own rule (flattened cell index), so tap and click agree on
|
||||||
|
// where a link ends; a tap on the prose around it keeps its mouse report.
|
||||||
|
const line = 'see https://example.com/x for more';
|
||||||
|
const { app, windowRef, sent } = linkHarness([line, '', '❯ ']);
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(line.indexOf('for more') + 3), false, 'content')).toBe('content');
|
||||||
|
expect(windowRef.open).not.toHaveBeenCalled();
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a tapped file path in the preview overlay', () => {
|
||||||
|
const line = 'wrote the chart to /tmp/out/chart.png just now';
|
||||||
|
const { app } = linkHarness([line, '', '❯ ']);
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(line.indexOf('/tmp')), false, 'content')).toBe('link');
|
||||||
|
expect(app.openFilePreview).toHaveBeenCalledWith('/tmp/out/chart.png', 'sess-1');
|
||||||
|
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends a tapped log path to the log viewer', () => {
|
||||||
|
const line = 'tail -f /var/log/app.log';
|
||||||
|
const { app } = linkHarness([line, '', '❯ ']);
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(line.indexOf('/var')), false, 'content')).toBe('link');
|
||||||
|
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/var/log/app.log', 'sess-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates a link in scrollback, where the tap sends no report at all', () => {
|
||||||
|
// A scrolled-up tap deliberately reports nothing (it would land on whatever row now
|
||||||
|
// occupies the cell), but reading old output and tapping a URL in it is the common case.
|
||||||
|
const line = 'docs at https://example.com/guide';
|
||||||
|
const { app, windowRef, sent } = linkHarness([line, '', '']);
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(line.indexOf('https')), false, 'history')).toBe('link');
|
||||||
|
expect(windowRef.open).toHaveBeenCalledOnce();
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never hijacks a TUI-owned choice row that happens to carry a path', () => {
|
||||||
|
// On a phone the dialog is the only interaction that matters, and its rows routinely
|
||||||
|
// name the very file a link would open — answering it must keep winning.
|
||||||
|
// ⚠️ The caret is parked on the QUESTION row, not the choice: with the caret on the
|
||||||
|
// tapped row this would pass through _tapIsOnCaretLine and pin nothing.
|
||||||
|
const line = '❯ 1. Yes, edit /home/user/src/app.ts';
|
||||||
|
const { app, windowRef, sent } = linkHarness(['Do you want to make this edit?', line, ' 2. No, keep it as is'], 0);
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(line.indexOf('/home'), 1), false, 'content')).toBe('content');
|
||||||
|
expect(windowRef.open).not.toHaveBeenCalled();
|
||||||
|
expect(app.openFilePreview).not.toHaveBeenCalled();
|
||||||
|
expect(sent).toHaveLength(1); // the choice still reaches the CLI
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a URL the user typed in the composer editable', () => {
|
||||||
|
// Tapping your own prompt text means "put the caret here". Opening it instead would
|
||||||
|
// punish the phone gesture for fixing a typo in a pasted link.
|
||||||
|
const composer = '❯ summarize https://example.com/guide for me';
|
||||||
|
const { app, windowRef, sent } = linkHarness(['earlier output', '', composer], 2);
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(composer.indexOf('https'), 2), true, 'input')).toBe('input');
|
||||||
|
expect(windowRef.open).not.toHaveBeenCalled();
|
||||||
|
expect(sent).toHaveLength(1); // the tap still positions the caret via the mouse report
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activates a link in a plain shell session, where every tap classifies as input', () => {
|
||||||
|
// A shell has no TUI to own taps, so _classifyMobileTerminalTap short-circuits to
|
||||||
|
// 'input' for the whole screen — gating link taps on the intent would leave every URL
|
||||||
|
// in shell output (curl, npm, git remote) inert. The caret line is the real boundary.
|
||||||
|
const line = 'remote: https://github.com/Ark0N/Codeman.git';
|
||||||
|
const harness = loadTerminalUiHarness();
|
||||||
|
const { app, windowRef } = harness;
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'shell' }]]);
|
||||||
|
app._sendInputAsync = vi.fn();
|
||||||
|
app.terminal = createTerminalGrid([line, '', 'bash-5.3$ '], 2);
|
||||||
|
app.terminal.registerLinkProvider = vi.fn();
|
||||||
|
windowRef.open = vi.fn();
|
||||||
|
app.registerFilePathLinkProvider();
|
||||||
|
|
||||||
|
// No cachedIntent below: real classification runs, and for a shell it answers 'input'.
|
||||||
|
const point = at(line.indexOf('https'));
|
||||||
|
expect(app._classifyMobileTerminalTap(point.clientX, point.clientY)).toBe('input');
|
||||||
|
expect(app._handleMobileTerminalTap(point, false)).toBe('link');
|
||||||
|
expect(windowRef.open).toHaveBeenCalledWith(
|
||||||
|
'https://github.com/Ark0N/Codeman.git',
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps taps working when no provider was ever registered', () => {
|
||||||
|
const harness = loadTerminalUiHarness();
|
||||||
|
const { app } = harness;
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||||
|
app.terminal = createTerminalGrid(['plain output', '', '❯ '], 2);
|
||||||
|
|
||||||
|
expect(app._terminalLinkAtPoint(4, 8)).toBeNull();
|
||||||
|
expect(app._activateTerminalLinkAtPoint(4, 8)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal touch selection', () => {
|
||||||
|
// Copying from a phone was impossible in three layers at once: `user-select: none`
|
||||||
|
// on the whole terminal subtree, a WebGL renderer that draws glyphs as pixels with
|
||||||
|
// only the accessibility tree behind them, and xterm's selection being a mouse DRAG
|
||||||
|
// while the tap path dispatches a zero-movement mousedown/mouseup pair. The gesture
|
||||||
|
// therefore drives xterm's own `select()`, which is renderer-independent.
|
||||||
|
//
|
||||||
|
// Grid geometry (createTerminalGrid): 80 cols, 8×16 cells, screen rect at (0,0),
|
||||||
|
// viewportY 0 — 0-based index i on 0-based row r sits at (i * 8 + 4, r * 16 + 8).
|
||||||
|
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
|
||||||
|
const press = (app: any, index: number, row = 0) =>
|
||||||
|
app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY);
|
||||||
|
const dragTo = (app: any, index: number, row = 0) =>
|
||||||
|
app._extendTouchSelection(at(index, row).clientX, at(index, row).clientY);
|
||||||
|
const COLS = 80;
|
||||||
|
const LINE = 'wrote the chart to /tmp/out/chart.png just now';
|
||||||
|
const PATH_AT = LINE.indexOf('/tmp');
|
||||||
|
|
||||||
|
function selectionHarness(lines = [LINE, '', '❯ '], cursorY = 2, wrapped = new Set<number>()) {
|
||||||
|
const { app, setNow } = loadTerminalUiHarness();
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||||
|
app._sendInputAsync = vi.fn();
|
||||||
|
app.terminal = createTerminalGrid(lines, cursorY, wrapped);
|
||||||
|
return { app, setNow, select: app.terminal.select as ReturnType<typeof vi.fn> };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('selects the whitespace-delimited token under a long press', () => {
|
||||||
|
// Whitespace is the only delimiter on purpose: a punctuation-aware word rule
|
||||||
|
// cuts a path, a URL or a hash in half, which is exactly what you came to copy.
|
||||||
|
const { app, select } = selectionHarness();
|
||||||
|
|
||||||
|
expect(press(app, PATH_AT + 4)).toBe(true);
|
||||||
|
expect(select).toHaveBeenCalledWith(PATH_AT, 0, '/tmp/out/chart.png'.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects nothing when the press lands on blank space', () => {
|
||||||
|
const { app, select } = selectionHarness();
|
||||||
|
|
||||||
|
expect(press(app, LINE.length + 10)).toBe(false);
|
||||||
|
expect(select).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('grows the selection as the finger drags past the anchor word', () => {
|
||||||
|
const { app, select } = selectionHarness();
|
||||||
|
press(app, PATH_AT + 4);
|
||||||
|
select.mockClear();
|
||||||
|
|
||||||
|
dragTo(app, LINE.length - 1);
|
||||||
|
|
||||||
|
// From the word's start through the cell under the finger, inclusive.
|
||||||
|
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the anchor word inside the selection when the drag goes backwards', () => {
|
||||||
|
const { app, select } = selectionHarness();
|
||||||
|
press(app, PATH_AT + 4);
|
||||||
|
select.mockClear();
|
||||||
|
|
||||||
|
dragTo(app, 6);
|
||||||
|
|
||||||
|
const wordEnd = PATH_AT + '/tmp/out/chart.png'.length;
|
||||||
|
expect(select).toHaveBeenCalledWith(6, 0, wordEnd - 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends across rows, where a linear length is what xterm wants', () => {
|
||||||
|
const { app, select } = selectionHarness(['first row text', 'second row text', '❯ '], 2);
|
||||||
|
press(app, 0);
|
||||||
|
select.mockClear();
|
||||||
|
|
||||||
|
dragTo(app, 5, 1);
|
||||||
|
|
||||||
|
// Row 1 cell 5 is absolute cell 85; the selection runs from 0 through it.
|
||||||
|
expect(select).toHaveBeenCalledWith(0, 0, COLS + 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Line takes the whole logical line, wraps included, without the padding', () => {
|
||||||
|
const wrappedTail = 'tail';
|
||||||
|
const { app, select } = selectionHarness(['x'.repeat(COLS), wrappedTail, '❯ '], 2, new Set([1]));
|
||||||
|
press(app, 2, 1);
|
||||||
|
select.mockClear();
|
||||||
|
|
||||||
|
app._selectTouchSelectionLine();
|
||||||
|
|
||||||
|
expect(select).toHaveBeenCalledWith(0, 0, COLS + wrappedTail.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a tap while a selection is up extends it instead of moving the cursor', () => {
|
||||||
|
const { app, select } = selectionHarness();
|
||||||
|
press(app, PATH_AT + 4);
|
||||||
|
select.mockClear();
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(LINE.length - 1), false, 'content')).toBe('select');
|
||||||
|
expect(select).toHaveBeenCalledWith(PATH_AT, 0, LINE.length - PATH_AT);
|
||||||
|
// and the CLI never sees a click it would act on
|
||||||
|
expect(app._sendInputAsync).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies through the shared clipboard path and drops the selection', () => {
|
||||||
|
// copyTerminalSelection is the one that falls back to execCommand, which is the
|
||||||
|
// only route that works on the plain-HTTP LAN install the installer offers.
|
||||||
|
const { app } = selectionHarness();
|
||||||
|
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
|
||||||
|
press(app, PATH_AT + 4);
|
||||||
|
|
||||||
|
return app._copyTouchSelection().then(() => {
|
||||||
|
expect(app.copyTerminalSelection).toHaveBeenCalledOnce();
|
||||||
|
expect(app._touchSelectionActive).toBe(false);
|
||||||
|
expect(app._touchSelectionAnchor).toBeNull();
|
||||||
|
expect(app.terminal.clearSelection).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lifting the finger cannot let a compat mousedown steal focus and drop the selection', () => {
|
||||||
|
// The bug this pins: on lift the browser synthesizes a trusted mousedown, xterm
|
||||||
|
// focuses on it (keyboard up) and SelectionService resets the model (bar gone).
|
||||||
|
// Ending the gesture arms the same guard the tap path uses.
|
||||||
|
const { app } = selectionHarness();
|
||||||
|
const { element, dispatch } = createElementHarness();
|
||||||
|
app.terminal.element = { ...app.terminal.element, addEventListener: element.addEventListener };
|
||||||
|
app._installMobileTapMouseGuard();
|
||||||
|
|
||||||
|
press(app, PATH_AT + 4);
|
||||||
|
app._endTouchSelectionGesture();
|
||||||
|
|
||||||
|
const ev = { isTrusted: true, preventDefault: vi.fn(), stopImmediatePropagation: vi.fn() };
|
||||||
|
dispatch('mousedown', ev);
|
||||||
|
|
||||||
|
expect(ev.preventDefault).toHaveBeenCalledOnce();
|
||||||
|
expect(ev.stopImmediatePropagation).toHaveBeenCalledOnce();
|
||||||
|
expect(app._touchSelecting).toBe(false);
|
||||||
|
expect(app._touchSelectionActive).toBe(true); // the selection outlives the gesture
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copying does not pop the on-screen keyboard back over the text', () => {
|
||||||
|
// copyTerminalSelection hands focus to the terminal, which on a phone means the
|
||||||
|
// keyboard covers what you just copied with nothing waiting to be typed.
|
||||||
|
const { app } = selectionHarness();
|
||||||
|
app.copyTerminalSelection = vi.fn().mockResolvedValue(true);
|
||||||
|
press(app, PATH_AT + 4);
|
||||||
|
app._blurMobileTerminalInput = vi.fn(); // stubbed AFTER the press, which blurs too
|
||||||
|
|
||||||
|
return app._copyTouchSelection().then(() => {
|
||||||
|
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blurs the terminal input if anything focuses it during the gesture', () => {
|
||||||
|
// The one that matters on Android: Chrome runs its own long-press handling at
|
||||||
|
// ~500ms and focuses the helper textarea directly — no mouse event to guard, so
|
||||||
|
// the keyboard shot up over the selection the instant it appeared.
|
||||||
|
const { app, setNow } = selectionHarness();
|
||||||
|
const listeners = new Map<string, () => void>();
|
||||||
|
app.terminal.textarea = {
|
||||||
|
addEventListener: (type: string, fn: () => void) => listeners.set(type, fn),
|
||||||
|
classList: { contains: (n: string) => n === 'xterm-helper-textarea' },
|
||||||
|
blur: vi.fn(),
|
||||||
|
};
|
||||||
|
app._installTouchSelectionFocusGuard();
|
||||||
|
press(app, PATH_AT + 4);
|
||||||
|
app._endTouchSelectionGesture();
|
||||||
|
|
||||||
|
// Whatever focused it, the guard takes the focus straight back off.
|
||||||
|
app._blurMobileTerminalInput = vi.fn();
|
||||||
|
listeners.get('focus')?.();
|
||||||
|
expect(app._blurMobileTerminalInput).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
// …and the guard expires on its own, so a stuck flag can never make the
|
||||||
|
// keyboard permanently unreachable.
|
||||||
|
setNow(1_000 + 5_000);
|
||||||
|
app._blurMobileTerminalInput = vi.fn();
|
||||||
|
listeners.get('focus')?.();
|
||||||
|
expect(app._blurMobileTerminalInput).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('survives a missing bar container instead of throwing mid-gesture', () => {
|
||||||
|
// index.html is read once at server start, so the bar is built in JS — and a
|
||||||
|
// solo popup or an early gesture can run before the container exists.
|
||||||
|
const { app } = selectionHarness();
|
||||||
|
|
||||||
|
expect(() => app._showTouchSelectionBar()).not.toThrow();
|
||||||
|
expect(app._ensureTouchSelectionBar()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('terminal wrapped-line handling', () => {
|
||||||
|
// Reported from a phone against the shipped fix: an agent's numbered list wraps its
|
||||||
|
// URL, and tapping it opened only the part on screen. Ink emits a real newline (so
|
||||||
|
// nothing is flagged isWrapped) and indents the continuation under the list marker,
|
||||||
|
// so joining the rows verbatim put whitespace inside the URL. "Line" broke the same
|
||||||
|
// way, grabbing the one visible row.
|
||||||
|
//
|
||||||
|
// Grid: 80 cols, 8×16 cells, screen rect at (0,0), viewportY 0.
|
||||||
|
const COLS = 80;
|
||||||
|
const at = (index: number, row = 0) => ({ clientX: index * 8 + 4, clientY: row * 16 + 8 });
|
||||||
|
const press = (app: any, index: number, row = 0) =>
|
||||||
|
app._beginTouchSelection(at(index, row).clientX, at(index, row).clientY);
|
||||||
|
const HEAD = '1. https://example.com/';
|
||||||
|
// Row 0 runs to the last column, which is the only trace a hard wrap leaves.
|
||||||
|
const ROW0 = HEAD + 'a'.repeat(COLS - HEAD.length);
|
||||||
|
const ROW1 = ' ackage/thing';
|
||||||
|
const FULL_URL = 'https://example.com/' + 'a'.repeat(COLS - HEAD.length) + 'ackage/thing';
|
||||||
|
|
||||||
|
function wrappedHarness() {
|
||||||
|
const { app, windowRef } = loadTerminalUiHarness();
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||||
|
app._sendInputAsync = vi.fn();
|
||||||
|
app.terminal = createTerminalGrid([ROW0, ROW1, '❯ '], 2);
|
||||||
|
app.terminal.registerLinkProvider = vi.fn();
|
||||||
|
app.openFilePreview = vi.fn();
|
||||||
|
app.openLogViewerWindow = vi.fn();
|
||||||
|
app._isExternalPreviewPath = () => false;
|
||||||
|
windowRef.open = vi.fn();
|
||||||
|
app.registerFilePathLinkProvider();
|
||||||
|
return { app, windowRef, select: app.terminal.select as ReturnType<typeof vi.fn> };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens the WHOLE wrapped URL, not the part on screen', () => {
|
||||||
|
const { app, windowRef } = wrappedHarness();
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(HEAD.length + 5), false, 'content')).toBe('link');
|
||||||
|
expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the whole URL from the continuation row too', () => {
|
||||||
|
// Tapping the second half is the natural gesture when that is what you can see.
|
||||||
|
const { app, windowRef } = wrappedHarness();
|
||||||
|
|
||||||
|
expect(app._handleMobileTerminalTap(at(5, 1), false, 'content')).toBe('link');
|
||||||
|
expect(windowRef.open).toHaveBeenCalledWith(FULL_URL, '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a token that spans the wrap, across both rows', () => {
|
||||||
|
const { app, select } = wrappedHarness();
|
||||||
|
|
||||||
|
press(app, 5, 1); // inside 'ackage/thing' on the continuation row
|
||||||
|
// From the URL's first cell (row 0, col 3) through the token's last cell
|
||||||
|
// (row 1, col 14). The run covers the indent cells between the halves, because
|
||||||
|
// an xterm selection is one contiguous run and a gap cannot be expressed.
|
||||||
|
const index = 3;
|
||||||
|
const end = COLS + ROW1.length - 1;
|
||||||
|
expect(select).toHaveBeenCalledWith(3, 0, end - index + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Line takes every row of a HARD-wrapped line, not just the visible one', () => {
|
||||||
|
const { app, select } = wrappedHarness();
|
||||||
|
press(app, HEAD.length + 5);
|
||||||
|
select.mockClear();
|
||||||
|
|
||||||
|
app._selectTouchSelectionLine();
|
||||||
|
|
||||||
|
// Row 0 col 0 through row 1's last non-blank cell.
|
||||||
|
expect(select).toHaveBeenCalledWith(0, 0, COLS + ROW1.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reach into the next line when a row stops short of the edge', () => {
|
||||||
|
// Over-reaching would glue unrelated output into one link or one "Line".
|
||||||
|
const { app, select } = loadTerminalUiHarness();
|
||||||
|
void select;
|
||||||
|
app.activeSessionId = 'sess-1';
|
||||||
|
app.sessions = new Map([['sess-1', { mode: 'claude' }]]);
|
||||||
|
app.terminal = createTerminalGrid(['short output', 'https://example.com/next', '❯ '], 2);
|
||||||
|
app.terminal.registerLinkProvider = vi.fn();
|
||||||
|
app.registerFilePathLinkProvider();
|
||||||
|
|
||||||
|
press(app, 2); // inside 'short'
|
||||||
|
app._selectTouchSelectionLine();
|
||||||
|
|
||||||
|
expect(app.terminal.select).toHaveBeenCalledWith(0, 0, 'short output'.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user