Skip to content

fix(frontend): only consume sticky modifier on a real transform - #76

Closed
KSiig wants to merge 8 commits into
DagsHub:mainfrom
KSiig:feat/issue-2009-sticky-modifier-soft-keyboard
Closed

KSiig wants to merge 8 commits into
DagsHub:mainfrom
KSiig:feat/issue-2009-sticky-modifier-soft-keyboard

Conversation

@KSiig

@KSiig KSiig commented Sep 6, 2026

Copy link
Copy Markdown

Summary

On touch devices, the modifier toggle buttons (Ctrl / Alt / Cmd / Shift) deselect as soon as the user taps KB to enable the soft keyboard for the xterm textarea — before the user types anything. The modifier is consumed by an input event that the Android WebView dispatches when the IME soft keyboard connects to the previously-blurred xterm helper textarea, not by a real keypress.

This breaks the natural workflow: tap Ctrl → tap KB → type letter → expect Ctrl+letter to reach tmux. Today only raw characters reach the terminal.

What changed

  • Extracted applyModifiers and clearStickyModifiers into a pure module src/frontend/modifiers.ts so the transformation logic is testable without React state.
  • The pure applyModifiers(modifiers, input) now returns { output, consumedSticky }. App.tsx calls clearStickyModifiers() only when consumedSticky is true.
  • 23 new unit tests in tests/frontend/modifiers.test.ts covering: empty input, IME noise bytes (focus / blur), every Ctrl+letter combination, Shift+digit semantics, Alt/Meta prefix rules, multi-modifier interactions, and the new vs old behaviour boundaries.

Behaviour changes

  • Shift+digit no longer consumes the sticky modifier. A user can now toggle Shift, type 1, then type a and still get A. Old behaviour consumed the sticky on any single-character input, even when no transform was applied.
  • Alt/Meta + empty input no longer emits a bare ESC byte. Old code prefixed ESC unconditionally when alt or meta was active, even for empty input. Now the prefix only applies to non-empty input. This also prevents spurious IME connection events from doubling an existing ESC byte.

Verification

  • npm test — 96 passed, 1 skipped (real-tmux smoke, unchanged).
  • npm run typecheck — clean.
  • npm run build — clean.

Manual verification (needs device)

  1. Open the app on a touch device.
  2. Tap Ctrl in the toolbar.
  3. Tap KB to enable the soft keyboard.
  4. Type a on the soft keyboard.
  5. Expected: tmux receives \u0001 (Ctrl+A). Previously: tmux received raw a.

Repeat for Shift + letter (expect uppercase) and Alt + letter (expect ESC + letter).

Linear

Linear: VEL-2009

KSiig and others added 8 commits August 25, 2026 13:38
* feat: reconnect websockets after screen lock and network drops

Phone lock and tab backgrounding close the control/terminal sockets,
and the UI previously stayed disconnected until a manual refresh.

Reconnect with backoff, retry immediately when the page becomes visible
or the network returns, and block input behind a Connecting/Reconnecting
overlay until the session is usable again.

* fix: persist auth failure and keep reconnect refs post-commit

Stop retrying after a 4001 or auth_error even when visibility/online
fires without a close code. Move password and reconnect-callback refs
into effects so render stays pure, and tighten the e2e coverage around
in-place recovery.
Toolbar mouseup was focusing xterm, which opens the Android keyboard.
On coarse pointers, leave the terminal textarea read-only until the
compose field or the new KB button asks for it.
* feat: toggle tmux mouse and swipe in-place history

Add a toolbar Mouse / Mouse off control that sets the attached session's
mouse option (not -g) and remembers the last choice in localStorage.

When mouse is off, a vertical swipe on the terminal captures pane history
into an in-place surface anchored at the latest line. Tap or pull past the
end to return to the live PTY. Mouse-on leaves the gesture path alone so
TUI mouse-scroll still works.

* fix: replace deprecated word-break on history text

Stylelint flags word-break: break-word. Use overflow-wrap: break-word
so long tokens still wrap.
Grok TUI (and similar apps) only scroll on SGR wheel reports. A phone
swipe never generates wheel events, so mouse-on did nothing. Convert
vertical pans to CSI wheel ticks when mouse is on, and keep mouse-off
swipe as in-place history. Use touch-action: none plus native touch
listeners so Android WebView does not swallow the gesture.
Allow --token, TMUX_MOBILE_TOKEN, and TMUX_MOBILE_PASSWORD so a
systemd unit can keep the same credentials across restarts. --url-file
writes the tunnel URL after cloudflared comes up so a preview instance
can run beside production.
* feat: font size stepper with phone default of 11 (SII-40)

Add A-/A+ stepper controls to the header to let a phone show more tmux rows.
Phone default drops from 12 to 11; desktop default stays at 14.

- New src/frontend/font-size.ts follows the mouse-preference module pattern
- Persists the chosen size to localStorage under tmux-mobile-font-size
- Re-fits xterm and notifies the PTY on each click
- Allow .top-actions to wrap so the new controls don't overflow narrow viewports

* fix: round fractional input in writeFontSize so stored values survive a reload

writeFontSize accepted fractional sizes and stored them verbatim (e.g. 11.5
became "11.5"), but readFontSize rejects non-integers and returns null. On
the next reload the user's selection was lost and the viewport default was
used instead.

Round to the nearest integer before clamping so every persisted value is
accepted by readFontSize. Add a round-trip test covering .5, .4, and .6
boundaries.

* docs: document personal dev preview tunnel in AGENTS.md

Describe the systemd service, wrapper script, env file, cloudflared named
tunnel, and state file that together expose a second tmux-mobile instance
behind a personal dev hostname on this machine. Includes the workflow for
pointing the preview at a feature branch.

The actual hostname, tokens, and passwords are per-machine secrets and
remain in the developer's home directory / state file — none of them are
written here.

* docs: also document the prod service and contrast with preview

Adds the prod side (tmux-mobile.service, prod.env, prod.url, the
globally-installed tmux-mobile binary) and a side-by-side table showing
how the two services differ: port, source tree, token/password policy,
and what a restart risks invalidating. Also mentions the single
cloudflared process that serves both hostnames.

* feat: lower font size floor from 10 to 1

The 10-14 range came from the original SII-40 spec, but the product
decision belongs with the user, not the spec. Drop the floor to 1 so
the stepper reaches all the way down.

- MIN_FONT_SIZE constant: 10 -> 1
- writeFontSize clamps anything < 1 up to 1
- A- button disables at 1 instead of 10
- Unit tests: swap the "9" invalid value for "0"; add explicit
  below-floor (0 and -5) clamp assertions; update the round-trip
  expected value from 10 to 7 since 7 is now a valid in-range value
- E2E clamp test renamed and walks decrease 13 times to land on 1

---------

Co-authored-by: Kasper Siig <kasper@siig.work>
…II-38) (#7)

* feat: immersive mode — hide chrome so the terminal fills the phone (SII-38)

- New src/frontend/immersive-mode.ts mirrors the mouse-preference module
- Persists choice to localStorage under tmux-mobile-immersive
- Tap terminal to hide chrome, swipe up / handle tap / Escape / Space to restore
- ResizeObserver transition flag guarantees one PTY resize per transition
- First-run hint teaches the gesture (4s auto-dismiss + × button)

deviated from SII-38 spec at step 3 because the actual DOM classes are
.topbar and .compose-bar (single-word, BEM-less) and the spec's literal
.top-bar / .compose selectors would not match anything. The CSS ruleset
targets the real class names; e2e assertions use the same selectors.

deviated from SII-38 spec at step 4a because event.target ===
event.currentTarget cannot fire when the user taps inside xterm
(.xterm-screen is a child of .terminal-host). Replaced with a guard that
excludes only the synthetic input textarea (.xterm-helper-textarea).

deviated from SII-38 spec at step 4 because requestAnimationFrame()'s
fitAddon.fit() reports transient dimensions during the chrome-driven
layout transition. Added a resizeTransitionRef so the existing
ResizeObserver does not race with the rAF refit and emit a duplicate
resize message; transitions land at exactly one PTY resize.

deviated from SII-38 spec at step 6 because display:none on chrome
elements does not re-balance the grid-template-rows on .app-shell on
this browser. Switched to position:absolute + visibility:hidden (with a
sibling flex layout for .terminal-wrap) so the freed vertical space is
absorbed by the terminal row rather than left as dead chrome rows.

Co-authored-by: Kasper Siig <kasper@siig.work>

* feat(immersive): add top-left close × button, drop swipe-up gesture

The spec's swipe-up-to-restore gesture (SII-38 §4c) created a discoverability
problem: an upward swipe near the bottom handle accidentally exited immersive
mode. Replace the gesture with an explicit top-left × pill — a 36×36 rounded
button with the same visual language as the bottom handle. Focus auto-grabs
the × when entering immersive so keyboard users land on the primary close
control. The bottom handle is preserved but visually demoted (smaller, lower
contrast) and remains keyboard-activatable for users who tab through.

deviated from SII-38 spec at step 4c because the swipe-up gesture conflicted
with the bottom handle and was removed. Close affordance moved to an explicit
top-left × button + the auto-focused handle. The handle keeps the section 7
keyboard contract (Space/Enter restore) so open question 1's discoverability
concern is no longer relevant for v1.

deviated from SII-38 spec at step 7 because focus on enter-immersive is now
the close × (the primary affordance) instead of the handle. The handle is
still focusable and keyboard-activatable, so the keyboard contract is
preserved.

Tests: 13 e2e (was 11): replaced 'swipe-up restores chrome' with two tests
- 'tapping the top-left close button restores chrome' (positive)
- 'no swipe-up gesture: swiping from the terminal does not exit immersive' (regression)
- 'bottom handle is still keyboard-activatable when explicitly focused' (preserves keyboard contract)
- 'pressing Space on the focused close button restores chrome' (retargeted from handle to close)

Co-authored-by: Kasper Siig <kasper@siig.work>

* fix(immersive): address CodeRabbit review

Two findings from CodeRabbit:

1. Major — auto-focus on enter-immersive was stealing keyboard input.
   f9bc544 (the close-× addition) had moved auto-focus from the bottom
   handle to the × button, but the underlying problem remained: focus
   left the terminal helper textarea so the user's next keystroke could
   exit immersive instead of typing. Removed the auto-focus entirely.
   The × and the handle remain Tab-reachable for keyboard users; the
   keyboard restore contract from SII-38 §7 is preserved by explicit
   Tab + Space/Enter. New test 'entering immersive does not move focus
   to the close button' locks the fix in.

deviated from SII-38 spec at step 7 (auto-focus on enter-immersive is
now opt-in via Tab order, not automatic). The spec author didn't trace
through the focus-steal interaction; the principle 'the user's last
active context is where their next keystrokes should land' overrides.

2. Minor — bottom-handle touch target was 4px tall, below every published
   minimum (Material 48dp, Apple HIG 44pt, WCAG 44px). Kept the visible
   4px pill (visual demotion argument from the previous round) but
   expanded the invisible hit area to 24px via a transparent button +
   ::after pseudo-element. Mirrors iOS home-indicator pattern.

Co-authored-by: Kasper Siig <kasper@siig.work>

---------

Co-authored-by: Kasper Siig <kasper@siig.work>
On touch devices, the Ctrl/Alt/Shift modifier buttons (e.g. tap Ctrl,
then tap KB to enable the soft keyboard, then type a letter) used to
silently deselect themselves before the user typed anything. The
sticky modifier was being consumed by an xterm.js `input` event that
the Android WebView dispatched when the IME soft keyboard connected to
the previously-blurred xterm helper textarea — not by a real keypress.

The IME connection event reaches `applyModifiers` with input that does
not match any of the three modifier branches (empty or a multi-byte
focus/noise sequence), but the old `applyModifiers` called
`clearStickyModifiers` unconditionally after the branches. The fix is
to only consume the sticky modifier when a branch actually transformed
the input.

Extracted `applyModifiers` and `clearStickyModifiers` into a pure
`src/frontend/modifiers.ts` module so the transformation and
consumption logic can be unit-tested without React state. The pure
function returns `{ output, consumedSticky }`; `App.tsx` now calls
`setModifiers` only when `consumedSticky` is true.

Behaviour changes worth noting in the PR description:
- Shift+digit now keeps the sticky modifier active (was: consumed).
  A user can toggle Shift, type `1`, then type `a` and still get `A`.
- Alt/Meta + empty input no longer emits a bare ESC byte (was: ESC
  was unconditionally prefixed when alt or meta was active). This
  also prevents spurious IME connection events from doubling an
  existing ESC byte.

Linear: VEL-2009
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change adds mobile terminal controls for mouse mode, history gestures, immersive mode, font sizing, keyboard control, and reconnect handling. It also centralizes authentication secret resolution, adds launch URL files, updates tmux control messages, and documents a per-machine preview tunnel setup.

Changes

Terminal experience

Layer / File(s) Summary
Authentication and launch URL handling
src/backend/auth/auth-service.ts, src/backend/cli.ts, src/backend/config.ts, tests/backend/auth-secrets.test.ts
Authentication values now resolve from CLI input, environment variables, or generated values. The CLI supports --token and --url-file, and writes launch details with restrictive permissions.
Mouse protocol and tmux state
src/backend/tmux/*, src/backend/types/protocol.ts, src/backend/server.ts, tests/harness/fakeTmux.ts, tests/integration/server.test.ts, tests/smoke/real-tmux.test.ts
The protocol and tmux gateway support reading and updating session mouse mode. Scrollback messages preserve overlay or history intent.
Frontend behavior modules
src/frontend/font-size.ts, src/frontend/history-gesture.ts, src/frontend/immersive-mode.ts, src/frontend/modifiers.ts, src/frontend/mouse-preference.ts, src/frontend/reconnect.ts, src/frontend/terminal-gestures.ts, tests/frontend/*
New modules implement persisted preferences, gesture rules, modifier transformations, SGR wheel encoding, and reconnect decisions with unit coverage.
Application integration and mobile UI
src/frontend/App.tsx, src/frontend/styles/app.css, tests/e2e/*
The application integrates mouse toggling, history overlays, immersive controls, font-size controls, keyboard handling, socket reconnection, terminal resizing, and mobile gesture behavior. End-to-end tests cover these flows.

Development setup

Layer / File(s) Summary
Per-machine preview tunnel documentation
AGENTS.md
The documentation describes stable production and swappable preview services behind one named tunnel.
Playwright browser channel selection
playwright.config.ts
Playwright now accepts an optional browser channel from PLAYWRIGHT_CHANNEL.

Estimated code review effort: 5 (Critical) | ~120 minutes

Merge Risk: 🟡 Moderate · up to be43f

Credential-file permissions and several terminal interaction regressions should be corrected before merge to avoid local secret exposure and incorrect mouse, history, and terminal sizing behavior.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant App
  participant ControlServer
  participant TmuxGateway
  User->>App: Toggle mouse mode
  App->>ControlServer: set_mouse enabled
  ControlServer->>TmuxGateway: setMouse session enabled
  TmuxGateway-->>ControlServer: Updated mouse state
  ControlServer-->>App: mouse enabled
  User->>App: Swipe terminal
  App->>ControlServer: capture_scrollback intent history
  ControlServer->>TmuxGateway: Capture session scrollback
  TmuxGateway-->>ControlServer: Scrollback text
  ControlServer-->>App: scrollback intent history
Loading

Poem

A rabbit taps the terminal bright
Mouse and history hop in sight
Font steps climb and sockets mend
Immersive screens now smoothly bend
Tiny tunnels guide preview flight
Carrots compile by moonlight

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary sticky-modifier behavior fix in the pull request.
Description check ✅ Passed The description directly explains the sticky-modifier bug, implementation changes, behavior changes, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@KSiig KSiig closed this Sep 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 1d592e7d-11dc-4c38-a408-06fd0d9d280b

📥 Commits

Reviewing files that changed from the base of the PR and between 493c404 and be43ffb.

📒 Files selected for processing (35)
  • AGENTS.md
  • playwright.config.ts
  • src/backend/auth/auth-service.ts
  • src/backend/cli.ts
  • src/backend/config.ts
  • src/backend/server.ts
  • src/backend/tmux/cli-executor.ts
  • src/backend/tmux/types.ts
  • src/backend/types/protocol.ts
  • src/frontend/App.tsx
  • src/frontend/font-size.ts
  • src/frontend/history-gesture.ts
  • src/frontend/immersive-mode.ts
  • src/frontend/modifiers.ts
  • src/frontend/mouse-preference.ts
  • src/frontend/reconnect.ts
  • src/frontend/styles/app.css
  • src/frontend/terminal-gestures.ts
  • src/frontend/types/protocol.ts
  • tests/backend/auth-secrets.test.ts
  • tests/e2e/font-size.spec.ts
  • tests/e2e/harness/test-server.ts
  • tests/e2e/immersive-mode.spec.ts
  • tests/e2e/mouse-and-history.spec.ts
  • tests/e2e/reconnect.spec.ts
  • tests/e2e/toolbar-keyboard.spec.ts
  • tests/frontend/font-size.test.ts
  • tests/frontend/history-gesture.test.ts
  • tests/frontend/immersive-mode.test.ts
  • tests/frontend/modifiers.test.ts
  • tests/frontend/mouse-preference.test.ts
  • tests/frontend/reconnect.test.ts
  • tests/harness/fakeTmux.ts
  • tests/integration/server.test.ts
  • tests/smoke/real-tmux.test.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: CR
Repo: DagsHub/tmux-mobile

Timestamp: 2026-09-06T12:26:32.731Z
Learning: NON TRIVIAL CHANGES SHOULD BE DONE WITH TDD - first write tests, then see then red, then implement, then green.
🪛 ast-grep (0.45.2)
tests/smoke/real-tmux.test.ts

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile, spawnSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

src/backend/tmux/cli-executor.ts

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFile } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

src/backend/cli.ts

[warning] 127-127: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.writeFileSync(filePath, ${lines.join("\n")}\n, { mode: 0o600 })
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename-typescript)

🪛 Betterleaks (1.8.1)
tests/backend/auth-secrets.test.ts

[high] 8-8: Detected a potential hardcoded password literal, which may expose account credentials.

(generic-password)


[high] 12-12: Detected a potential hardcoded password literal, which may expose account credentials.

(generic-password)


[high] 15-15: Detected a potential hardcoded password literal, which may expose account credentials.

(generic-password)


[high] 23-23: Detected a potential hardcoded password literal, which may expose account credentials.

(generic-password)


[high] 26-26: Detected a potential hardcoded password literal, which may expose account credentials.

(generic-password)


[high] 31-31: Detected a potential hardcoded password literal, which may expose account credentials.

(generic-password)


[high] 34-34: Detected a potential hardcoded password literal, which may expose account credentials.

(generic-password)

🪛 React Doctor (0.9.12)
src/frontend/App.tsx

[error] 572-572: Storing an auth token in localStorage/sessionStorage exposes it to any XSS on the page: JavaScript can read web storage and exfiltrate the token. Keep tokens in an HttpOnly, Secure, SameSite cookie instead.

Don't persist auth tokens (JWTs, access/refresh tokens, secrets) in localStorage/sessionStorage; they're readable by any XSS. Use an HttpOnly cookie set by the server.

(auth-token-in-web-storage)

🔇 Additional comments (15)
src/backend/auth/auth-service.ts (1)

8-25: LGTM!

src/backend/config.ts (1)

16-16: LGTM!

Also applies to: 22-22

src/backend/cli.ts (1)

9-9: LGTM!

Also applies to: 30-37, 69-75, 133-138, 190-198

tests/backend/auth-secrets.test.ts (1)

1-47: LGTM!

playwright.config.ts (1)

11-11: LGTM!

AGENTS.md (1)

104-105: 🩺 Stability & Availability

Document how the preview port reaches the CLI.

src/backend/cli.ts reads the port only from --port and defaults to 8767. The preview documentation names TMUX_MOBILE_PREVIEW_PORT but does not show the wrapper or systemd unit passing --port 8768. Add that mapping or document the existing mapping. Without it, the preview can bind to the production port.

tests/harness/fakeTmux.ts (1)

36-36: LGTM!

Also applies to: 65-66, 72-72, 249-253, 255-262

tests/e2e/harness/test-server.ts (1)

15-15: LGTM!

Also applies to: 54-55

tests/integration/server.test.ts (1)

369-402: LGTM!

src/frontend/styles/app.css (1)

384-405: LGTM!

Also applies to: 463-463, 488-529, 625-625, 799-929

tests/e2e/mouse-and-history.spec.ts (1)

1-150: LGTM!

tests/e2e/font-size.spec.ts (1)

1-75: LGTM!

tests/e2e/immersive-mode.spec.ts (1)

1-21: LGTM!

Also applies to: 26-317

tests/e2e/reconnect.spec.ts (1)

1-60: LGTM!

tests/e2e/toolbar-keyboard.spec.ts (1)

1-45: LGTM!

Comment thread src/backend/cli.ts
password ? `password=${password}` : undefined
].filter((line): line is string => Boolean(line));
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${lines.join("\n")}\n`, { mode: 0o600 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Identify the repository-declared Node.js runtime before checking fs mode behavior.
fd -H -t f -a '^(package\.json|\.nvmrc|\.node-version|\.tool-versions)$' . \
  -x sh -c 'echo "----- $1"; sed -n "1,160p" "$1"' sh {}

# Inspect all URL-file permission handling in the changed implementation.
rg -n -C 3 'writeUrlFile|writeFileSync|openSync|chmodSync|fchmodSync' src/backend/cli.ts

Repository: DagsHub/tmux-mobile

Length of output: 3341


Force restrictive permissions for an existing URL file.

mode: 0o600 applies only when Node creates the file. An existing args.urlFile can retain permissive permissions and expose the token URL and password to other local users.

Set mode 0o600 on the opened file before writing. Add a regression test that pre-creates the file with mode 0644.

Proposed fix
-  fs.writeFileSync(filePath, `${lines.join("\n")}\n`, { mode: 0o600 });
+  const fileDescriptor = fs.openSync(filePath, "w", 0o600);
+  try {
+    fs.fchmodSync(fileDescriptor, 0o600);
+    fs.writeFileSync(fileDescriptor, `${lines.join("\n")}\n`);
+  } finally {
+    fs.closeSync(fileDescriptor);
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fs.writeFileSync(filePath, `${lines.join("\n")}\n`, { mode: 0o600 });
const fileDescriptor = fs.openSync(filePath, "w", 0o600);
try {
fs.fchmodSync(fileDescriptor, 0o600);
fs.writeFileSync(fileDescriptor, `${lines.join("\n")}\n`);
} finally {
fs.closeSync(fileDescriptor);
}

Comment thread src/backend/server.ts
Comment on lines +333 to +334
await deps.tmux.setMouse(attachedSession, message.enabled);
sendJson(context.socket, { type: "mouse", enabled: message.enabled });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Validate set_mouse.enabled at runtime.

parseClientMessage validates only type, so {"type":"set_mouse","enabled":"false"} reaches tmux.setMouse. TmuxCliExecutor.setMouse uses JavaScript truthiness and sends mouse on for the non-empty string. Reject non-boolean values before calling setMouse, and add a control-WebSocket test.

Proposed fix
 case "set_mouse": {
   if (!attachedSession) {
     throw new Error("no attached session");
   }
+  if (typeof message.enabled !== "boolean") {
+    throw new Error("set_mouse.enabled must be a boolean");
+  }
   await deps.tmux.setMouse(attachedSession, message.enabled);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await deps.tmux.setMouse(attachedSession, message.enabled);
sendJson(context.socket, { type: "mouse", enabled: message.enabled });
if (typeof message.enabled !== "boolean") {
throw new Error("set_mouse.enabled must be a boolean");
}
await deps.tmux.setMouse(attachedSession, message.enabled);
sendJson(context.socket, { type: "mouse", enabled: message.enabled });

Comment thread src/frontend/App.tsx
Comment on lines +834 to +836
window.setTimeout(() => {
fitAddonRef.current?.fit();
}, 60);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Notify the PTY after the fallback fit.

If the rAF runs before the layout settles, the 60ms fallback can change xterm dimensions without sending them. Any ResizeObserver notification during the transition is suppressed, so the PTY can remain out of sync until another resize. Send after the fallback fit while the transition is active. sendTerminalResize deduplicates unchanged dimensions.

Proposed fix
     window.setTimeout(() => {
       fitAddonRef.current?.fit();
+      if (resizeTransitionRef.current) {
+        sendTerminalResize();
+      }
     }, 60);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
window.setTimeout(() => {
fitAddonRef.current?.fit();
}, 60);
window.setTimeout(() => {
fitAddonRef.current?.fit();
if (resizeTransitionRef.current) {
sendTerminalResize();
}
}, 60);

Comment thread src/frontend/App.tsx
return attachTerminalGestures(host, {
isMouseEnabled: () => mouseEnabledRef.current,
isBlocked: () => historyVisibleRef.current || scrollbackVisibleRef.current,
onEnterHistory: () => requestHistory(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the current configured scrollback length for gesture history requests.

The empty-dependency effect retains the initial requestHistory, where serverConfig is null. After /api/config sets serverConfig, gesture requests still use the fallback 1000 lines. Read serverConfig through a synchronized ref or reattach the callback when the configuration changes.

};

const onPointerDown = (event: PointerEvent): void => {
if (!event.isPrimary || options.isBlocked()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Restrict pointer gesture tracking to touch input.

onPointerDown accepts all primary pointer types, and onPointerMove sends matching mouse pointers to onMove. With mouse mode off, an upward drag can meet shouldEnterHistory and request history. With mouse mode on, the drag can prevent default behavior and emit SGR wheel input. Filter the pointer path without changing the separate touch-event fallback.

-    if (!event.isPrimary || options.isBlocked()) {
+    if (event.pointerType !== "touch" || !event.isPrimary || options.isBlocked()) {
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!event.isPrimary || options.isBlocked()) {
if (event.pointerType !== "touch" || !event.isPrimary || options.isBlocked()) {

Comment on lines +22 to +25
await page.addInitScript(() => {
localStorage.removeItem("tmux-mobile-immersive");
localStorage.removeItem("tmux-mobile-immersive-hint-seen");
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🌐 Web query:

For Playwright Test version 1.51.1, does page.addInitScript() run before application scripts on every navigation, including page.reload()?

💡 Result:

Yes, in Playwright Test version 1.51.1 (and all versions where this method is supported), page.addInitScript executes before any application scripts on every navigation, including page.reload [1][2][3]. The script is evaluated immediately after a new document is created but before any of the page's own scripts (or the document parser) run [4][1][3]. Because it is registered to the page or context, it is automatically re-injected and executed whenever a new document is loaded—which occurs during initial page loads, subsequent navigations, and page reloads [1][2][3][5]. It also executes in the context of any child frames that are attached or navigated [1][3]. This behavior is specifically designed to allow developers to amend the JavaScript environment (such as mocking APIs or seeding data) before the application code starts executing [6][3][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- tests/e2e/immersive-mode.spec.ts ---'
cat -n tests/e2e/immersive-mode.spec.ts | sed -n '1,80p;150,190p'
printf '%s\n' '--- relevant identifiers ---'
rg -n -S 'tmux-mobile-immersive-hint-seen|immersive-hint|hint.*seen|addInitScript|page\.reload' tests src app . --glob '!node_modules' --glob '!dist' --glob '!build' 2>/dev/null | head -240

Repository: DagsHub/tmux-mobile

Length of output: 14732


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- tests/e2e/immersive-mode.spec.ts ---'
cat -n tests/e2e/immersive-mode.spec.ts | sed -n '1,80p;150,190p'
printf '%s\n' '--- relevant identifiers ---'
rg -n -S 'tmux-mobile-immersive-hint-seen|immersive-hint|hint.*seen|addInitScript|page\.reload' . --glob '!node_modules' --glob '!dist' --glob '!build' 2>/dev/null | head -240

Repository: DagsHub/tmux-mobile

Length of output: 10374


🏁 Script executed:

#!/bin/bash
set -e
cat -n tests/e2e/immersive-mode.spec.ts | sed -n '1,80p;150,190p'
rg -n -S 'tmux-mobile-immersive-hint-seen|immersive-hint|hint.*seen|addInitScript|page\.reload' . --glob '!node_modules' --glob '!dist' --glob '!build' 2>/dev/null | head -240

Repository: DagsHub/tmux-mobile

Length of output: 10304


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- src/frontend/immersive-mode.ts ---'
cat -n src/frontend/immersive-mode.ts | sed -n '1,180p'
printf '%s\n' '--- App.tsx hint state and rendering ---'
rg -n -C 12 'HINT_SEEN_KEY|immersiveHint|immersive-hint|immersiveHintSeen' src/frontend/App.tsx src/frontend/immersive-mode.ts
printf '%s\n' '--- Playwright version ---'
rg -n -C 2 '"`@playwright/test`"|"playwright"' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null | head -80

Repository: DagsHub/tmux-mobile

Length of output: 14322


🏁 Script executed:

#!/bin/bash
set -e
cat -n src/frontend/immersive-mode.ts | sed -n '1,180p'
rg -n -C 12 'HINT_SEEN_KEY|immersiveHint|immersive-hint|hintSeen' src/frontend/App.tsx src/frontend/immersive-mode.ts
rg -n -C 2 '"`@playwright/test`"|"playwright"' package.json package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null | head -80

Repository: DagsHub/tmux-mobile

Length of output: 14215


🌐 Web query:

site:playwright.dev/docs/api/class-page addInitScript order multiple scripts page.addInitScript

💡 Result:

When using Playwright's page.addInitScript, the order of evaluation for multiple scripts—including those added via browserContext.addInitScript and page.addInitScript—is explicitly stated as undefined [1]. Because Playwright does not guarantee the order in which these scripts are executed, you should not rely on one script to prepare the environment for another if they are added independently [1]. If you need to guarantee the execution order of multiple pieces of code, you should combine them into a single addInitScript call [1]. By including all necessary logic within one script, you ensure the code runs in the specific sequence you define [1].

Citations:


Keep the dismissal flag during reload without relying on init-script order.

The init script in shows the first-run hint once and hides it after dismissal removes "tmux-mobile-immersive-hint-seen" on every navigation. A second page.addInitScript is not a reliable fix because Playwright does not define the evaluation order of multiple init scripts. The reset script can run after the restoring script, leaving the hint visible.

Combine the reset and preservation logic in one script, and run the reset only for the first document:

Proposed fix
     await page.addInitScript(() => {
-      localStorage.removeItem("tmux-mobile-immersive");
-      localStorage.removeItem("tmux-mobile-immersive-hint-seen");
+      if (sessionStorage.getItem("immersive-e2e-initialized") !== "true") {
+        localStorage.removeItem("tmux-mobile-immersive");
+        localStorage.removeItem("tmux-mobile-immersive-hint-seen");
+        sessionStorage.setItem("immersive-e2e-initialized", "true");
+      }
     });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await page.addInitScript(() => {
localStorage.removeItem("tmux-mobile-immersive");
localStorage.removeItem("tmux-mobile-immersive-hint-seen");
});
await page.addInitScript(() => {
if (sessionStorage.getItem("immersive-e2e-initialized") !== "true") {
localStorage.removeItem("tmux-mobile-immersive");
localStorage.removeItem("tmux-mobile-immersive-hint-seen");
sessionStorage.setItem("immersive-e2e-initialized", "true");
}
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant