Repository navigation
Conversation
* 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
📝 WalkthroughWalkthroughThis 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. ChangesTerminal experience
Development setup
Estimated code review effort: 5 (Critical) | ~120 minutes Merge Risk: 🟡 Moderate · up to 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
Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Team
Run ID: 1d592e7d-11dc-4c38-a408-06fd0d9d280b
📒 Files selected for processing (35)
AGENTS.mdplaywright.config.tssrc/backend/auth/auth-service.tssrc/backend/cli.tssrc/backend/config.tssrc/backend/server.tssrc/backend/tmux/cli-executor.tssrc/backend/tmux/types.tssrc/backend/types/protocol.tssrc/frontend/App.tsxsrc/frontend/font-size.tssrc/frontend/history-gesture.tssrc/frontend/immersive-mode.tssrc/frontend/modifiers.tssrc/frontend/mouse-preference.tssrc/frontend/reconnect.tssrc/frontend/styles/app.csssrc/frontend/terminal-gestures.tssrc/frontend/types/protocol.tstests/backend/auth-secrets.test.tstests/e2e/font-size.spec.tstests/e2e/harness/test-server.tstests/e2e/immersive-mode.spec.tstests/e2e/mouse-and-history.spec.tstests/e2e/reconnect.spec.tstests/e2e/toolbar-keyboard.spec.tstests/frontend/font-size.test.tstests/frontend/history-gesture.test.tstests/frontend/immersive-mode.test.tstests/frontend/modifiers.test.tstests/frontend/mouse-preference.test.tstests/frontend/reconnect.test.tstests/harness/fakeTmux.tstests/integration/server.test.tstests/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 & AvailabilityDocument how the preview port reaches the CLI.
src/backend/cli.tsreads the port only from--portand defaults to8767. The preview documentation namesTMUX_MOBILE_PREVIEW_PORTbut 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!
| 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 }); |
There was a problem hiding this comment.
🔒 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.tsRepository: 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.
| 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); | |
| } |
| await deps.tmux.setMouse(attachedSession, message.enabled); | ||
| sendJson(context.socket, { type: "mouse", enabled: message.enabled }); |
There was a problem hiding this comment.
🎯 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.
| 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 }); |
| window.setTimeout(() => { | ||
| fitAddonRef.current?.fit(); | ||
| }, 60); |
There was a problem hiding this comment.
🎯 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.
| window.setTimeout(() => { | |
| fitAddonRef.current?.fit(); | |
| }, 60); | |
| window.setTimeout(() => { | |
| fitAddonRef.current?.fit(); | |
| if (resizeTransitionRef.current) { | |
| sendTerminalResize(); | |
| } | |
| }, 60); |
| return attachTerminalGestures(host, { | ||
| isMouseEnabled: () => mouseEnabledRef.current, | ||
| isBlocked: () => historyVisibleRef.current || scrollbackVisibleRef.current, | ||
| onEnterHistory: () => requestHistory(), |
There was a problem hiding this comment.
🎯 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()) { |
There was a problem hiding this comment.
🎯 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.
| if (!event.isPrimary || options.isBlocked()) { | |
| if (event.pointerType !== "touch" || !event.isPrimary || options.isBlocked()) { |
| await page.addInitScript(() => { | ||
| localStorage.removeItem("tmux-mobile-immersive"); | ||
| localStorage.removeItem("tmux-mobile-immersive-hint-seen"); | ||
| }); |
There was a problem hiding this comment.
🎯 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:
- 1: https://playwright.dev/docs/next/api/class-page
- 2: GitHub issue 22892 in microsoft/playwright (link omitted to avoid creating a cross-reference)
- 3: https://playwright.dev/docs/api/class-page
- 4: GitHub issue 8616 in microsoft/playwright (link omitted to avoid creating a cross-reference)
- 5: https://zenn.dev/kodomo_news/articles/36-addinitscript-cookie-trap?locale=en
- 6: https://playwright.dev/docs/evaluating
- 7: https://github.com/microsoft/playwright/blob/c0cc9802/docs/src/mock-browser-js.md
🏁 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 -240Repository: 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 -240Repository: 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 -240Repository: 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 -80Repository: 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 -80Repository: 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.
| 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"); | |
| } | |
| }); |
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
inputevent 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
applyModifiersandclearStickyModifiersinto a pure modulesrc/frontend/modifiers.tsso the transformation logic is testable without React state.applyModifiers(modifiers, input)now returns{ output, consumedSticky }.App.tsxcallsclearStickyModifiers()only whenconsumedStickyis true.tests/frontend/modifiers.test.tscovering: 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
1, then typeaand still getA. Old behaviour consumed the sticky on any single-character input, even when no transform was applied.Verification
npm test— 96 passed, 1 skipped (real-tmux smoke, unchanged).npm run typecheck— clean.npm run build— clean.Manual verification (needs device)
aon the soft keyboard.\u0001(Ctrl+A). Previously: tmux received rawa.Repeat for Shift + letter (expect uppercase) and Alt + letter (expect ESC + letter).
Linear
Linear: VEL-2009