diff --git a/CLAUDE.md b/CLAUDE.md index a39a8ccb1..576b4f88a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,9 @@ 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 `/.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` - **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 -- **`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` +- **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` - **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) - **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ Look in **`prebuilds/-/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) @@ -223,6 +225,8 @@ 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) +**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`. + **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) **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 +307,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". -**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). diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index e6d4567f4..9489c55e2 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -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 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 - 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. - A scroll is never mistaken for a tap: travel is measured from the start of the gesture, and 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 diff --git a/packages/xterm-zerolag-input/src/overlay-renderer.ts b/packages/xterm-zerolag-input/src/overlay-renderer.ts index 72c62201c..d88c87430 100644 --- a/packages/xterm-zerolag-input/src/overlay-renderer.ts +++ b/packages/xterm-zerolag-input/src/overlay-renderer.ts @@ -65,38 +65,71 @@ export function renderOverlay(container: HTMLDivElement, params: RenderParams): charTop, charHeight, promptRow, + totalRows, font, showCursor, cursorColor, terminal, } = 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.top = promptRow * cellH + 'px'; + container.style.top = topRow * cellH + 'px'; // Clear and rebuild (typically 1-3 line divs, negligible cost) container.innerHTML = ''; const fullWidthPx = totalCols * cellW; - for (let i = 0; i < lines.length; i++) { - const leftPx = i === 0 ? startCol * cellW : 0; - const widthPx = i === 0 ? fullWidthPx - leftPx : fullWidthPx; + for (let i = 0; i < visibleLines.length; i++) { + const indents = i === 0 && keepsPromptLine; + const leftPx = indents ? startCol * cellW : 0; + const widthPx = indents ? fullWidthPx - leftPx : fullWidthPx; 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); } // Block cursor at end of last line (use visual width for CJK support) if (showCursor) { - const lastLine = lines[lines.length - 1]; - const lastLineLeft = lines.length === 1 ? startCol : 0; + const lastLine = visibleLines[visibleLines.length - 1]; + const lastLineLeft = visibleLines.length === 1 && keepsPromptLine ? startCol : 0; const cursorCol = lastLineLeft + stringCellWidth(terminal, lastLine); if (cursorCol < totalCols) { const cursor = document.createElement('span'); cursor.style.cssText = 'position:absolute;display:inline-block'; 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.height = cellH + 'px'; cursor.style.backgroundColor = cursorColor; diff --git a/packages/xterm-zerolag-input/src/types.ts b/packages/xterm-zerolag-input/src/types.ts index dedd7ca52..bb9f7fe19 100644 --- a/packages/xterm-zerolag-input/src/types.ts +++ b/packages/xterm-zerolag-input/src/types.ts @@ -172,6 +172,13 @@ export interface RenderParams { /** Height of the character rendering area (px). */ charHeight: 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; showCursor: boolean; cursorColor: string; diff --git a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts index 5e201ba46..b1e4105b3 100644 --- a/packages/xterm-zerolag-input/src/zerolag-input-addon.ts +++ b/packages/xterm-zerolag-input/src/zerolag-input-addon.ts @@ -565,7 +565,10 @@ export class ZerolagInputAddon implements XtermAddon { // Skip redundant re-renders — include text content to detect // same-length changes (e.g., setFlushed with different text) - const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`; + // `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; this._lastRenderKey = renderKey; @@ -612,6 +615,7 @@ export class ZerolagInputAddon implements XtermAddon { charTop, charHeight, promptRow: activePrompt.row, + totalRows: this._terminal.rows, font: this._font, showCursor: this._options.showCursor, cursorColor, diff --git a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts index c87a02a7d..a95846a85 100644 --- a/packages/xterm-zerolag-input/test/overlay-renderer.test.ts +++ b/packages/xterm-zerolag-input/test/overlay-renderer.test.ts @@ -418,3 +418,88 @@ describe('stringCellWidth', () => { 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'); + }); +}); diff --git a/src/web/public/app.js b/src/web/public/app.js index 7c8297caa..d7cc0d8ad 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2053,6 +2053,28 @@ class CodemanApp { wrap.appendChild(actions); wrap.appendChild(pre); }); + // Links open in a NEW tab. + // + // marked emits a bare `` 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; } catch { /* fall through */ } } diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 20df330ac..e0b7ce2a4 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1035,7 +1035,112 @@ function previewsInFileViewer(filePath) { 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') { window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; + window.CodemanTerminalLines = { terminalLogicalLine }; } diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 4c2b391d8..c4a1c4f19 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -500,6 +500,11 @@ 'Respawn Blocked': '重生已阻止', 'Task Complete': '任务完成', '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': '复制失败', 'Checking…': '正在检查…', 'Starting…': '正在启动…', diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 201bb1038..393180b36 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -573,6 +573,42 @@ const KeyboardHandler = { * 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. */ + /** + * 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() { try { const container = document.getElementById('terminalContainer'); @@ -583,7 +619,8 @@ const KeyboardHandler = { const gap = container.clientHeight - app.terminal.rows * cellH; if (gap > 0 && gap < cellH) { const currentPadding = parseInt(main.style.paddingBottom) || 0; - main.style.paddingBottom = Math.max(0, currentPadding - gap) + 'px'; + const floor = Math.min(currentPadding, this._fixedBottomBarsHeight()); + main.style.paddingBottom = Math.max(floor, currentPadding - gap) + 'px'; if (app.fitAddon) try { app.fitAddon.fit(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index e0ee73085..f40335821 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3395,6 +3395,57 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea { -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 { position: absolute; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 09bc75858..2e6e0d53e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -38,6 +38,19 @@ // 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. 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 // (_installMobileKeyboardDismiss). Two groups: anything that is about to take // focus itself, and the accessory bar, which is built to be used while the @@ -206,6 +219,8 @@ TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, MOBILE_KEYBOARD_DISMISS_TAP_SLOP, + TOUCH_SELECT_LONG_PRESS_MS, + TOUCH_SELECT_FOCUS_GUARD_MS, }; global.CODEMAN_XTERM_THEMES = CODEMAN_XTERM_THEMES; global.codemanCurrentXtermTheme = currentXtermTheme; @@ -270,6 +285,7 @@ Object.assign(CodemanApp.prototype, { const container = document.getElementById('terminalContainer'); this.terminal.open(container); this._installMobileTapMouseGuard(); + this._installTouchSelectionFocusGuard(); // Suppress xterm key handling during CJK IME composition. // Without this, xterm processes raw keyDown events (e.g., "Process" key) @@ -567,6 +583,18 @@ Object.assign(CodemanApp.prototype, { // Register link provider for clickable file paths in Bash tool output 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(); + } + }); + // Mouse wheel: forward to the TUI only for sessions verified to handle SGR // wheel reports (claude 2.1.187+ — see _shouldForwardWheelToApp), local // scrollback otherwise. Claude Code 2.1.187+ scrolls its own @@ -686,6 +714,9 @@ Object.assign(CodemanApp.prototype, { let pixelAccum = 0; 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 tapStartedWithTerminalFocus = false; let tapStartIntentCache = null; @@ -695,6 +726,13 @@ Object.assign(CodemanApp.prototype, { container.addEventListener( 'touchstart', (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) { touchLastX = ev.touches[0].clientX; touchLastY = ev.touches[0].clientY; @@ -725,6 +763,15 @@ Object.assign(CodemanApp.prototype, { ev.preventDefault(); 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; if (scrollFrame) { cancelAnimationFrame(scrollFrame); @@ -738,6 +785,24 @@ Object.assign(CodemanApp.prototype, { container.addEventListener( 'touchmove', (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) { const touchY = ev.touches[0].clientY; if (!didScroll && Math.abs(touchY - touchStartY) >= TAP_THRESHOLD) { @@ -779,7 +844,21 @@ Object.assign(CodemanApp.prototype, { container.addEventListener( 'touchend', (ev) => { + if (ev.target?.closest?.('.term-select-bar')) return; + clearTimeout(longPressTimer); + longPressTimer = null; 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) { scrollFrame = requestAnimationFrame(scrollLoop); } @@ -798,13 +877,28 @@ Object.assign(CodemanApp.prototype, { } 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( 'touchcancel', () => { + clearTimeout(longPressTimer); + longPressTimer = null; isTouching = false; + this._touchSelecting = false; velocity = 0; pixelAccum = 0; tapStartedWithTerminalFocus = false; @@ -1306,7 +1400,7 @@ Object.assign(CodemanApp.prototype, { // Debug: Track if provider is being invoked let lastInvokedLine = -1; - this.terminal.registerLinkProvider({ + const provider = { provideLinks(bufferLineNumber, callback) { // Debug logging - only log if line changed to avoid spam if (bufferLineNumber !== lastInvokedLine) { @@ -1325,63 +1419,32 @@ Object.assign(CodemanApp.prototype, { // Stitch the LOGICAL line back together. // - // xterm invokes this provider per visible ROW, and translateToString returns - // that row alone (the old comment here claimed otherwise). A URL or path - // longer than the terminal is wide therefore matched only as far as the row - // boundary, and the link opened a PREFIX of the real target. Walk out to both - // ends of the continuation, match against the joined text, and map offsets - // back to (x, y) so a link can span rows. - // - // 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; - }; - + // xterm invokes this provider per visible ROW and translateToString returns + // that row alone, so a URL or path longer than the terminal is wide matched + // only as far as the row boundary and the link opened a PREFIX of the real + // target. `terminalLogicalLine` (constants.js) owns the reconstruction — + // both continuation kinds, the indent a hard wrap leaves on its + // continuation, and the offset↔cell mapping — because touch selection + // measures the SAME lines and the two must not disagree. // Bounded so a screenful of full-width output (wide tables, box drawing) // cannot make every hover stitch and re-scan the entire viewport. const MAX_STITCHED_ROWS = 12; - let startRow = bufferLineNumber; - while (startRow > 1 && bufferLineNumber - startRow < MAX_STITCHED_ROWS && continuesPrevious(startRow)) { - startRow--; - } - let endRow = bufferLineNumber; - 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 logical = window.CodemanTerminalLines?.terminalLogicalLine( + buffer, + bufferLineNumber - 1, + self.terminal.cols, + MAX_STITCHED_ROWS + ); + if (!logical) { + callback(undefined); + return; } - const lineText = rowTexts.join(''); + const lineText = logical.text; /** Map an offset in the stitched text back to a 1-based terminal cell. */ const coordAt = (index) => { - let rest = index; - for (let i = 0; i < rowTexts.length - 1; i++) { - 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 }; + const cell = logical.offsetToCell(index); + return { x: cell.col + 1, y: cell.row + 1 }; }; if (!lineText || !lineText.includes('/')) { @@ -1523,11 +1586,424 @@ Object.assign(CodemanApp.prototype, { } 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'); }, + /** + * 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