Compare commits

..
Author SHA1 Message Date
Codeman maintainer c00e054e0e chore: version packages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:43:22 +02:00
Codeman maintainer 68ae9a8c5f fix(files): match glob queries without regex so a hostile query cannot stall the server
The Files search compiled the user's query into a backtracking RegExp:
'*a*a*a...' became '^.*a.*a.*a...$', the classic blowup, evaluated
synchronously against every walked path — a pathological query could
freeze the event loop for the whole server (and every user of it in
multi-user mode). /api/search stays regex-free for exactly this reason.

Globs now match through a two-pointer wildcard walk, O(text · pattern)
worst case, with a 256-char query cap bounding the pattern side; an
overlong query compiles to null, the same answer as an empty one.
Semantics are unchanged (anchored, case-insensitive, * spans slashes)
and the existing tests pass untouched; the pathological pattern gets a
test that fails by timeout with the RegExp version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:35:45 +02:00
Ark0N a49c30d173 Merge pull request #324 from aakhter/feat/files-panel-search
feat(files): search the Files panel by name or path
2026-08-19 23:33:08 +02:00
Codeman maintainer d871d1913f docs: restore the bullet PR #321 dropped off the xterm-zerolag-input gotcha
The new local-echo-overlay gotcha landed as a list item but left the
xterm-zerolag-input entry below it without its leading '- ', splitting
the Common Gotchas bullet list in two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 23:25:09 +02:00
Ark0N ede3b05c10 Merge pull request #321 from rounakdatta/fix/mobile-link-taps
feat(mobile): links open from a tap, text can be copied, long prompts stay visible, wrapped links open whole
2026-08-19 23:23:51 +02:00
Ark0N c049de75db Merge pull request #320 from comzine/feat/custom-terminal-font
feat: Nerd Font prompt icons out of the box + configurable terminal font
2026-08-19 23:04:50 +02:00
Rounak DattaandClaude Opus 5 aae90599e5 fix(terminal): stitch a wrapped line through the indent its continuation carries
An agent's numbered list wraps its URL, and the link opened a PREFIX of it:

    1. https://github.com/users/someone/packages/container/p
       ackage/thing

opened `…/container/p`. The provider already stitched hard wraps — Ink emits a real
newline, so nothing is flagged `isWrapped` and a row that fills the last column is
taken as continuing — but it joined the row texts VERBATIM, and the continuation
carries the list's own three-space indent. That whitespace lands in the middle of
the token, which is exactly where the URL pattern stops. Flush-left wrapped URLs
(Claude Code's own `/login`) worked, which is why this survived.

The touch-selection helpers had the shallower version of the same bug: they walked
`isWrapped` only, so `Line` grabbed the single row on screen rather than the
logical line, and a long-press on a wrapped token selected only its visible half.

So the reconstruction now lives in ONE place, `terminalLogicalLine` in
constants.js, and both consumers use it — the link provider matching patterns over
its text and the selection helpers measuring words and lines with it. A link that
spans a wrap and a `Line` that stops at the screen edge were the same bug twice.

The helper drops the leading whitespace of a HARD continuation (the program's
indent) and keeps that of a SOFT one (the emulator inserts nothing, so it is real
content), records the dropped width per segment so the offset↔cell mapping stays
exact in both directions, trims only the final row so earlier offsets stay aligned
to cells, and keeps the 12-row bound that stops a screenful of full-width output
from being re-scanned on every hover.

⚠️ Selection spans are computed in CELLS, not text offsets: an xterm selection is
one contiguous run, so a token spanning a hard wrap also covers the indent cells
between its halves. A run that skipped them cannot be expressed, and would not
match what is highlighted.

Tests: `test/terminal-logical-line.test.ts` (8 cases: the indent drop, resolving
from either row, both mapping directions, soft continuations kept verbatim, no
over-reach past a short row, the row bound, final-row trimming, a missing row) and
5 in `terminal-touch-tap.test.ts` (the whole URL from either row, a token selected
across the wrap, `Line` spanning both rows, no reach into the next line). Removing
either half of the fix reds 5 and 8 of them respectively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:41:43 +00:00
Rounak DattaandClaude Opus 5 2e58da7479 docs(mobile): document the phone gestures, and translate the selection bar
The three fixes in this branch change what a tap and a long-press MEAN on a
phone, and add a UI surface with its own z-index — all of which this repo keeps
written down rather than discoverable only by reading the handlers.

- `docs/wiki/Mobile-Guide.md` (the published user manual): a new "Tapping, links
  and copying" section, and the long-prompt behaviour in the keyboard section
  where the existing scroll/tap rules live.
- `CLAUDE.md`: the touch-gesture invariants next to the scrollback/wheel material
  (why the caret line is the boundary rather than the tap intent; why all three
  selection guards exist), the overlay's new bottom bound alongside the
  single-source note, and the selection bar in the z-index registry — 900, above
  terminal content and the local-echo overlay and deliberately below floating
  agent windows so it can never cover their controls.
- `i18n.js`: zh-CN for the bar's `Copy` / `Line` / `Clear selection`. The bar is a
  SIBLING of `.xterm`, not a descendant, so `SKIP_SELECTOR` does not cover it and
  the entries actually apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:19:36 +00:00
Rounak DattaandClaude Opus 5 ba843bb272 fix(mobile): keep a long prompt visible instead of hiding it behind the keyboard
Typing a prompt long enough to wrap ran the text off the bottom of the screen: the
tail — the part being typed, where the cursor is — sat behind the on-screen
keyboard, so the user was typing blind. Two independent causes.

**The overlay had no bottom bound.** On touch devices keystrokes are buffered in
the local-echo overlay and do not reach the PTY until Enter, so the CLI never
learns the prompt is long and nothing scrolls or reflows to make room. Meanwhile
the renderer lays its wrapped lines out straight DOWNWARD from the prompt row
(`top = promptRow * cellH`, each line at `i * cellH`) with nothing clamping it to
the visible rows — and with the keyboard up there are only a handful of those.

The block now grows UPWARD once it would pass the last visible row: it is lifted
so its final line lands ON that row. Every line div is opaque, so it covers
transcript above rather than vanishing under the keyboard below — the same thing a
real terminal does when a composer expands. A prompt taller than the whole
viewport keeps its TAIL, for the same reason the fix exists: the end is what the
user is looking at. `startCol` indents only the line that starts at the prompt
marker, so it is dropped along with that line when only the tail fits, and the
cursor follows the last VISIBLE line.

`rows` joins the render key: the layout depends on it, so a keyboard opening —
which changes rows without changing the text — must not be skipped as a redundant
render.

**`_shrinkPaddingToFit()` was reclaiming the bars' own space.** On phones the
toolbar and accessory bar are `position: fixed`, so they occupy no layout space
and `main`'s padding-bottom is the ONLY thing reserving room for them. Shrinking
it by the full sub-row slack pulled the terminal's bottom edge down underneath
them, and the row the following re-fit gained was painted behind them — clipping
the last line of a long prompt. The shrink now has a floor: the MEASURED height of
the currently-visible fixed bars, so genuine over-reservation of the hard-coded
84px is still reclaimed while a device that needs those pixels keeps them. The
floor is `Math.min(currentPadding, measured)`, so it can only ever prevent a
shrink, never cause a grow that would resize the terminal as a side effect.

Overlay behaviour lives in `packages/xterm-zerolag-input/` (single-source; the
vendor bundles are generated), so the fix is in the package with the row count
passed in as an optional `totalRows` — absent, the layout is exactly as before.

Tests: 7 cases in the package's `overlay-renderer.test.ts` (upward lift, tail
retention, indent drop, cursor on the last visible line, and the unclamped
fallbacks) and 7 in a new `test/mobile-keyboard-bottom-padding.test.ts` (reclaim,
floor, partial reclaim, no-grow, hidden bars, CJK strip, whole-row slack). 5 and 4
of them respectively fail without the fix. Package suite 238 pass, including the
codex byte-identity and replay tests.

Verified on Android + Chrome against a live instance: a ~460-character prompt
wrapping ~12 rows stays on screen while typing and arrives at the PTY intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:14:52 +00:00
Rounak DattaandClaude Opus 5 756728e553 feat(mobile): long-press to select terminal text, tap to extend, Copy
There was no way to copy terminal text from a phone at all, and three layers
ruled it out independently: `user-select: none` across the whole terminal subtree
on touch devices (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 touch path
dispatches a zero-movement mousedown/mouseup pair — a click. `copyTerminal()`
exists but is wired to no button and calls `navigator.clipboard` directly, which
is undefined on the plain-HTTP LAN install the installer offers.

So the gesture drives xterm's `select()` directly: public API, 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.

- **Long-press** (350ms, finger still within the shared tap slop) selects the
  run of non-whitespace under the finger. Whitespace is the only delimiter on
  purpose: every punctuation-aware word rule cuts a path, URL or hash in half,
  which is what you came to copy.
- **Drag** while held extends the selection; touchmove diverts from scrolling.
- **Tap** while the bar is up extends it too. That is the ergonomic core:
  picking up a 4px handle with a fingertip is a coin flip, tapping the other end
  is not. Dismissal stays explicit (✕ or Copy), so no tap is spent leaving a mode
  the user is still using.
- **Copy** goes through the existing `copyTerminalSelection()`, so it inherits
  the execCommand fallback that is the only route that works on plain HTTP.
- **Line** takes the whole logical line, wraps included, trailing pad trimmed.

Three guards are what make the gesture survive contact with a real phone, and
each fixes a symptom measured on Android Chrome:

1. **The compat mouse pair after touchend.** xterm focuses from its screen-element
   mousedown and SelectionService resets the model there, so lifting your finger
   popped the keyboard and dissolved the selection in one go. The tap path already
   had a guard for those events; the selection path simply never armed it. Armed
   now, and the touchend is `preventDefault`ed so the synthesis is stopped at the
   source (that listener is no longer passive).
2. **The platform's own long-press.** Android Chrome runs its handling at ~500ms
   and focuses the nearest editable element — xterm's helper textarea, parked at
   the cursor — which no touch handler can preventDefault because it never sees an
   event. A focus guard blurs the terminal input for the duration of the gesture,
   whatever focused it, bounded by a self-expiring deadline so a stuck flag can
   never leave the keyboard unreachable. `contextmenu` is suppressed for the same
   window, and the threshold sits at 350ms so it lands clear of the platform's.
3. **Copy re-focusing the terminal.** `copyTerminalSelection()` ends with
   `terminal.focus()`, which is right on a desktop and wrong on a phone: the
   keyboard covers what was just copied with nothing waiting to be typed.

The bar is built in JS because index.html is read once at server start, and its
styles live in styles.css rather than mobile.css because the gesture is
touch-driven, not width-driven — a touch tablet in landscape gets the gesture and
would otherwise have no bar to copy from.

12 tests in `terminal-touch-tap.test.ts` cover the word rule, forward and
backward extension, cross-row selection, Line, tap-to-extend, the copy path, and
each of the three guards including the focus guard's expiry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:14:52 +00:00
Rounak DattaandClaude Opus 5 f2d3a7e3c1 fix(mobile): links open in a new tab from a tap, in the terminal and the chat
On a phone no link was openable, on either surface, for two unrelated reasons.

**Terminal.** xterm resolves the link under the pointer on `mousemove` and
activates it on `mouseup` over its SCREEN element. A touch tap delivers neither:
`touch-action: none` on the terminal subtree plus touchstart's preventDefault for
a 'content' tap suppress the browser's compatibility mouse events,
`_installMobileTapMouseGuard` drops the trusted ones that still arrive inside the
450ms tap window, and the synthetic mousedown/mouseup 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.

The tap path now activates the link itself, through the SAME provider that feeds
the hover linkifier (`_terminalLinkAtPoint`), 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`. It runs synchronously inside the touchend handler,
which is what keeps the user gesture that lets `window.open` past the popup
blocker, and before any mouse report, exactly as `_handleDesktopTerminalClick`
already skips the SGR tap for a hovered link.

Two kinds of row keep their existing meaning: the caret's logical line, where a
tap places the cursor and a URL the user typed must stay editable, 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. The caret line is
the boundary rather than the tap intent, because a plain shell classifies EVERY
tap as 'input' and gating on that would leave every URL in shell output inert.

**Chat.** `marked` emits a bare `<a href>` and the markdown sanitizer's allowlist
carries no `target`, so a tap in the response viewer navigated the current tab
away: on a phone that unloads the whole dashboard — SSE, terminal buffers, unsent
composer text — and there is no middle-click or open-in-new-tab affordance to
work around it. `_renderMarkdown` now decorates anchors in the template pass it
already makes for code blocks. That pass runs AFTER sanitizing, so it is the only
source of both attributes: an agent-authored `target`/`rel` is already stripped,
and `rel="noopener noreferrer"` is set on the same element in the same breath, so
no page Codeman opens gets a `window.opener` handle back. Fragment links stay
in-page; mailto:/tel: are left to the OS rather than stranding an empty tab.

Tests: 10 cases in `terminal-touch-tap.test.ts` (URL, file path, log path,
scrollback, no-double-report, composer, shell mode, dialog row, no provider) and
a new `response-viewer-external-links.test.ts` driving the shipped marked +
DOMPurify + app.js. 7 of them fail without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 19:14:52 +00:00
Aamer Akhter 5cc78669bd feat(files): search the Files panel by name or path
GET /api/sessions/:id/files gains an optional `q`. With one, the endpoint
answers a FLAT match list instead of a nested tree; without one, the response is
exactly what it was, so every existing caller is untouched.

compileFileQuery() (src/utils/file-query.ts) turns the query string into a
reusable predicate, so the walk prunes as it goes rather than streaming the
whole tree to the client to be filtered there. An empty or whitespace-only
query compiles to null, which is what makes "no query" and "blank query" the
same thing.

The search walk deliberately recurses past directories that do not match — a
file whose ancestors don't match is exactly what people are searching for — so
it carries its own maxMatches cap on top of the existing maxFiles and maxDepth
ones, and reports `truncated` when it stops early. Hidden-file and
excluded-directory rules are the same ones tree mode already applies.

Tests: file-query.test.ts covers the matcher; routes/file-search-mode.test.ts
drives the endpoint against a real temp tree and pins the two properties worth
having — that the walk reaches a match under non-matching parents, and that an
absent or whitespace query leaves the tree response alone. Gating the recursion
on a match turns those red.
2026-08-19 09:17:20 -04:00
Ark0N d4ccff07ca Merge pull request #319 from Ark0N/fix/dep-advisories
fix(deps): clear production npm advisories, fix sw.js caching regression
2026-08-19 14:54:21 +02:00
Tobias WeberandClaude Fable 5 108c00e78d feat: bundled Nerd Font symbols fallback + per-device terminal font setting
Shell prompts using Nerd Font glyphs (powerline, p10k/starship folder and
git icons) rendered as missing-glyph boxes: the built-in xterm stack has no
private-use-area symbols, and phones have no Nerd Fonts installed at all.

- Bundle Symbols Nerd Font Mono (icons-only, MIT, 1.2MB woff2) served from
  fonts/ and appended to the terminal stack before monospace — browsers fall
  back per glyph, so icons render everywhere while text stays in the text
  fonts. font-display: block + preload keep tofu out of xterm's glyph atlas.
- New per-device terminalFontFamily setting (App Settings > Terminal &
  Input > Font): prepended to the built-in stack, never a replacement, so
  the symbols fallback and final monospace always survive. Applied live on
  save (refit + echo-overlay refreshFont, mirroring setFontSize).
- Single source for both xterm surfaces: TERMINAL_FONT_DEFAULT_STACK +
  resolveTerminalFontFamily() in constants.js, unit-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjnbbZRBuvR5E3SDouwXr9
2026-08-19 00:45:19 +02:00
Codeman maintainer 8a54b331e3 fix(deps): clear production npm advisories, fix sw.js caching regression
Resolves the four advisories that reach the production dependency tree. The
other 16 npm audit reports are devDependencies-only (Remotion, Puppeteer,
postcss, the eslint/tsx toolchain) and never ship to users.

- @fastify/static 9.1.3 -> 10.1.3  GHSA-8pvw-jcv7-9cmj (authz bypass via
  non-canonical URL paths). Covers <=10.1.1, so all of 9.x is affected and
  the fix exists only on the 10.x line.
- find-my-way 9.6.0 -> 9.8.0       GHSA-c96f-x56v-gq3h (HTTP/2 DDoS)
- fast-uri 3.1.2 -> 3.1.5          GHSA-v2hh-gcrm-f6hx (host confusion)
- brace-expansion -> 5.0.9/1.1.18  GHSA-3jxr-9vmj-r5cp (expansion DoS)

The last three are transitive and needed only a lockfile re-resolve, so no
overrides were introduced.

The @fastify/static major changes setHeaders' first argument from a Node
ServerResponse to a FastifyReply. Two consequences:

1. res.setHeader() -> reply.header(). The v9 body throws TypeError from
   inside the plugin on every static request.
2. Precedence flips, silently. The callback used to write to the raw
   response and lose to the route's staged reply headers; it now writes to
   the reply and wins. That gave /sw.js a year of immutable in place of the
   no-cache, no-store its route sets, pinning a service worker on every
   client with no server-side recovery. A route that already set
   Cache-Control now keeps it.

Verified against v9 to confirm the sw.js behaviour is a regression and not
a pre-existing bug.

ws appears in npm audit but production is on 8.21.0, outside the vulnerable
range; the only affected copy is bundled under @remotion/renderer (dev-only,
and remotion is pinned at 4.0.473 because the compositor refuses to start on
a version mismatch).

Adds test/static-cache-headers.test.ts, which drives a real server and covers
a caching contract that had no test at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 23:24:22 +02:00
35 changed files with 2677 additions and 115 deletions
+33
View File
@@ -1,5 +1,38 @@
# 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
### Patch Changes
+5 -2
View File
@@ -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.
**Version**: 1.19.6 (must match `package.json`)
**Version**: 1.19.7 (must match `package.json`)
## 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
- **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
- **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)
@@ -223,6 +224,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 +306,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).
+20
View File
@@ -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
+46 -29
View File
@@ -1,12 +1,12 @@
{
"name": "aicodeman",
"version": "1.19.6",
"version": "1.19.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aicodeman",
"version": "1.19.6",
"version": "1.19.7",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -17,7 +17,7 @@
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@fastify/static": "^10.1.3",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
@@ -1454,9 +1454,9 @@
}
},
"node_modules/@fastify/static": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.3.tgz",
"integrity": "sha512-aXrYtsiryLhRxRNaxNqsn7FUISeb7rB9q4eHUPIot5aeQBLNahnz1m6thzm7JWC1poSGXS9XrX8DvuMivp2hkQ==",
"version": "10.1.3",
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.3.tgz",
"integrity": "sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==",
"funding": [
{
"type": "github",
@@ -1470,13 +1470,30 @@
"license": "MIT",
"dependencies": {
"@fastify/accept-negotiator": "^2.0.0",
"@fastify/error": "^4.0.0",
"@fastify/send": "^4.0.0",
"content-disposition": "^1.0.1",
"fastify-plugin": "^5.0.0",
"content-disposition": "^2.0.1",
"fastify-plugin": "^6.0.0",
"fastq": "^1.17.1",
"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": {
"version": "11.2.0",
"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": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -5078,9 +5095,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5431,9 +5448,9 @@
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
"integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -6552,9 +6569,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"funding": [
{
"type": "github",
@@ -6661,9 +6678,9 @@
}
},
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"version": "9.8.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.8.0.tgz",
"integrity": "sha512-JtyUgATO7qxRp2zKhrmWof74Mqxc1ikbwpwMY97p8ipuTj2QtreA4gK2JNAF6SOqqHnYYkwMUvsgQVi2AJxIyw==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -6905,15 +6922,15 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
@@ -12344,7 +12361,7 @@
}
},
"packages/xterm-zerolag-input": {
"version": "0.3.0",
"version": "0.3.1",
"license": "MIT",
"devDependencies": {
"@xterm/headless": "^6.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"main": "dist/index.js",
@@ -85,7 +85,7 @@
"@fastify/compress": "^8.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
"@fastify/static": "^10.1.3",
"@fastify/websocket": "^11.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-serialize": "^0.14.0",
+15
View File
@@ -1,5 +1,20 @@
# 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
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"main": "dist/index.cjs",
@@ -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;
@@ -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;
@@ -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,
@@ -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');
});
});
+93
View File
@@ -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;
}
+2
View File
@@ -35,3 +35,5 @@ export { resolveCodexDir, isCodexAvailable } from './codex-cli-resolver.js';
export { resolveGeminiDir, isGeminiAvailable } from './gemini-cli-resolver.js';
export { resolveAntigravityDir, isAntigravityAvailable } from './antigravity-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';
+22
View File
@@ -2053,6 +2053,28 @@ class CodemanApp {
wrap.appendChild(actions);
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;
} catch { /* fall through */ }
}
+144
View File
@@ -527,6 +527,41 @@ function sortSessionsByActivity(rows) {
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}`;
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -560,6 +595,10 @@ if (typeof window !== 'undefined') {
compare: compareSessionActivity,
sort: sortSessionsByActivity,
};
window.CodemanTerminalFont = {
DEFAULT_STACK: TERMINAL_FONT_DEFAULT_STACK,
resolve: resolveTerminalFontFamily,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
@@ -1035,7 +1074,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 };
}
@@ -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.
+9
View File
@@ -326,6 +326,10 @@
// Input settings
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': '本地回显',
'CJK Input': '中日韩输入',
'Extended Keyboard Bar': '扩展键盘栏',
@@ -500,6 +504,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…': '正在启动…',
+16
View File
@@ -24,6 +24,9 @@
<!-- Preload critical resources — lets browser discover these during HTML parse
instead of waiting until <script> tags at bottom-of-body are reached. -->
<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="app.js" as="script">
<!-- Self-hosted xterm.js — eliminates CDN DNS/TLS latency (~100ms).
@@ -1624,6 +1627,19 @@
</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-head"><h4>Scrolling &amp; rendering</h4></div>
<div class="set-group-body">
+38 -1
View File
@@ -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();
+1 -1
View File
@@ -2279,7 +2279,7 @@ Object.assign(CodemanApp.prototype, {
const terminal = new Terminal({
theme: { ...window.codemanCurrentXtermTheme() },
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,
lineHeight: 1.2,
cursorBlink: true,
+7
View File
@@ -381,6 +381,7 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false;
this.loadTunnelStatus();
document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice();
document.getElementById('appSettingsTerminalFont').value = settings.terminalFontFamily || '';
document.getElementById('appSettingsTerminalWheelLocal').checked =
settings.terminalWheelLocalScrollback ?? defaults.terminalWheelLocalScrollback ?? false;
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
@@ -2006,6 +2007,7 @@ Object.assign(CodemanApp.prototype, {
imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked,
tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked,
localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked,
terminalFontFamily: document.getElementById('appSettingsTerminalFont').value.trim(),
terminalWheelLocalScrollback: document.getElementById('appSettingsTerminalWheelLocal').checked,
cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked,
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
@@ -2052,6 +2054,7 @@ Object.assign(CodemanApp.prototype, {
// Save to localStorage
this.saveAppSettingsToStorage(settings);
this._updateLocalEchoState();
this.applyTerminalFontFamily?.(settings.terminalFontFamily);
// 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
@@ -2200,6 +2203,9 @@ Object.assign(CodemanApp.prototype, {
showFileViewerButton: _fvb,
webglRendererEnabled: _wgl,
terminalWheelLocalScrollback: _twls,
// 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
// SettingsUpdateSchema (.strict()), so sending them would 400 the PUT.
showSessionButton: _ssb,
@@ -2874,6 +2880,7 @@ Object.assign(CodemanApp.prototype, {
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
'subagentActiveTabOnly', 'tabTwoRows', 'sessionListLayout', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
'terminalFontFamily',
'language',
'terminalWheelLocalScrollback',
'showSessionButton', 'showAwayDigestButton', 'showCronButton',
+64
View File
@@ -16,6 +16,19 @@
font-weight: 400 700;
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 {
/* 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;
}
/* 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;
+582 -55
View File
@@ -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;
@@ -225,7 +240,7 @@ Object.assign(CodemanApp.prototype, {
this.terminal = new Terminal({
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)
fontSize: MobileDetection.getDeviceType() === 'mobile' ? 10 : 14,
lineHeight: 1.2,
@@ -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--;
const logical = window.CodemanTerminalLines?.terminalLogicalLine(
buffer,
bufferLineNumber - 1,
self.terminal.cols,
MAX_STITCHED_ROWS
);
if (!logical) {
callback(undefined);
return;
}
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 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 <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();
},
/** 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();
},
/** 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() {
// 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
@@ -3704,7 +4180,42 @@ Object.assign(CodemanApp.prototype, {
if (!touch || !this.terminal) return null;
// touchstart already classified this exact point; reuse it rather than paying
// 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);
// 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') {
// 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.
@@ -3728,7 +4239,7 @@ Object.assign(CodemanApp.prototype, {
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
// report so collapsing a readback never opens or retains the keyboard.
this._blurMobileTerminalInput();
@@ -4122,6 +4633,22 @@ Object.assign(CodemanApp.prototype, {
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() {
const saved = localStorage.getItem('codeman-font-size');
if (saved) {
+91 -1
View File
@@ -21,6 +21,7 @@ import type {
FileWriteData,
} 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 {
AUDIO_ATTACHMENT_EXTENSIONS,
@@ -937,12 +938,15 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
// File tree listing
app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string };
const session = findSessionOrFail(ctx, id, req);
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
const includeHidden = showHidden === 'true';
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
const excludeDirs = new Set([
@@ -976,6 +980,92 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
let truncated = false;
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[]> => {
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
truncated = true;
+16 -4
View File
@@ -781,19 +781,31 @@ export class WebServer extends EventEmitter {
// 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.
// 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
await this.app.register(fastifyStatic, {
root: join(__dirname, 'public'),
prefix: '/',
cacheControl: false,
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
if (path.includes('.html')) {
res.setHeader('Cache-Control', 'no-cache');
reply.header('Cache-Control', 'no-cache');
} else {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
}
},
});
+18 -6
View File
@@ -90,10 +90,12 @@ function expectNoVulnerableBraceExpansion(lock: PackageLock): void {
expect(versions, 'brace-expansion should be present in package-lock.json').not.toHaveLength(0);
for (const version of versions) {
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) {
expect(
compareVersions(version, '1.1.13'),
`brace-expansion@${version} should be >= 1.1.13`
compareVersions(version, '1.1.18'),
`brace-expansion@${version} should be >= 1.1.18`
).toBeGreaterThanOrEqual(0);
} else if (major === 4) {
expect(
@@ -101,7 +103,7 @@ function expectNoVulnerableBraceExpansion(lock: PackageLock): void {
`brace-expansion@${version} should not remain on vulnerable 4.x`
).toBeGreaterThanOrEqual(0);
} 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
);
}
@@ -113,7 +115,7 @@ describe('dependency security policy', () => {
const rootPackage = readJson<PackageLockPackage>('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?.uuid).toBe('^14.0.0');
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/coverage-v8', '4.1.0');
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, '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, '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, 'flatted', '3.4.2');
expectNoVulnerableBraceExpansion(lock);
+98
View File
@@ -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);
});
});
+178
View File
@@ -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);
});
});
+177
View File
@@ -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;');
});
});
+94
View File
@@ -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' });
});
});
+97
View File
@@ -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');
});
});
+58
View File
@@ -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}`);
});
});
+137
View File
@@ -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();
});
});
+453 -3
View File
@@ -8,8 +8,11 @@ function loadTerminalUiHarness() {
let now = 1_000;
let keyboardVisible = false;
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({
window: {},
window: windowRef,
document: {
body: { classList: { contains: () => false } },
get activeElement() {
@@ -18,7 +21,7 @@ function loadTerminalUiHarness() {
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => now },
requestAnimationFrame: (_fn: () => void) => 1,
@@ -43,12 +46,18 @@ function loadTerminalUiHarness() {
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');
vm.runInContext(constants, context, { filename: 'constants.js' });
vm.runInContext(code, context, { filename: 'terminal-ui.js' });
const app = new (CodemanApp as any)();
return {
app,
windowRef,
setNow: (value: number) => {
now = value;
},
@@ -88,13 +97,23 @@ function createTerminalGrid(lines: string[], cursorY: number, wrappedRows = new
active: {
viewportY: 0,
baseY: 0,
length: lines.length,
cursorY,
getLine: (row: number) =>
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,
},
},
select: vi.fn(),
clearSelection: vi.fn(),
hasSelection: () => false,
getSelectionPosition: () => undefined,
element: {
querySelector: (selector: string) =>
selector === '.xterm-screen' ? { getBoundingClientRect: () => ({ left: 0, top: 0 }) } : null,
@@ -732,3 +751,434 @@ describe('terminal touch tap mouse guard', () => {
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' }]]);
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._sessionUsesServerMouseStrip()).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);
});
});