Compare commits

...

41 Commits

Author SHA1 Message Date
Ken 46c6199087 perf: optimize muxplex performance across frontend and backend
CI / test (3.13) (push) Failing after 12m18s
CI / test (3.12) (push) Failing after 12m20s
CI / test (3.11) (push) Failing after 12m22s
- Optimize app.js rendering and event handling for faster UI responsiveness
- Optimize style.css with improved selectors and reduced re-layouts
- Optimize terminal.js WebSocket handling and message processing
- Optimize main.py with faster request handling and caching
- Optimize muxterm.py with improved state management and pooling
- Optimize pool.go with better goroutine and connection pooling
- Add performance benchmarks for frontend CSS selectors
- Add performance tests for main module routines

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-29 04:04:24 +00:00
Ken 71f371fe85 fix: route terminal WS through Python proxy to work behind reverse proxy
The browser was connecting directly to muxterm at ws://host:7682/ws which
fails when accessed through a reverse proxy (Caddy/Tailscale at
muxplex.ampbox.io) because:
  1. Port 7682 is never exposed externally — only port 8088 is proxied
  2. ws:// is blocked by browsers on https:// pages (mixed-content policy)

Fix (two parts):

main.py — Add /terminal/ws WebSocket route that:
  - Verifies the session cookie (same pattern as federation WS proxy)
  - Opens a connection to ws://127.0.0.1:MUXTERM_PORT/ws?token=...
  - Relays frames bidirectionally (text + binary) between browser and muxterm

terminal.js — Change _openMuxtermSocket() from:
  ws://location.hostname:port/ws?token=...   ← direct port, ws:// hardcoded
to:
  wss://location.host/terminal/ws?token=...  ← same host/port as app, wss:// on HTTPS

Auth remains two-layered: session cookie at Python level, HMAC token at
muxterm level. The proxy is thin — just auth + frame relay, zero session logic.

test_ws_proxy.py — New tests for auth rejection (4001), proxy URL construction
(token passed through), and frame relay.
test_frontend_js.py — Update the terminal.js route assertion to match new path.
2026-05-28 08:23:22 +00:00
Ken c3b0502297 fix: muxterm binary discovery — find local build before PATH search
_find_muxterm_binary() now resolves in this order:
  1. Explicit binary_path argument (unchanged)
  2. MUXTERM_BINARY env var (new)
  3. <repo_root>/muxterm/muxterm — the go build output for dev installs (new)
  4. shutil.which('muxterm') — PATH fallback (unchanged)

Before this fix the service logged 'muxterm binary not found; terminal
feature disabled' on every restart because the binary is not on PATH
and no explicit path was passed from main.py.

Also fix stop_muxterm() to catch ProcessLookupError from terminate()
when the process has already exited (was only caught in the wait_for
block, not the terminate() call itself), which caused a noisy
'Application shutdown failed' traceback in the service journal.
2026-05-28 08:16:06 +00:00
Ken 469acd7e3f fix: align terminal.js token response field with API contract and remove stale ttyd refs
- terminal.js:55: read data.port instead of data.muxtermPort to match
  the /api/terminal-token response shape ({token, port})
- test_terminal.mjs:37: update mock to return {port: 9999} matching API
- app.js: replace two stale ttyd references in comments with muxterm
2026-05-28 07:38:04 +00:00
Ken de6968e85e fix: close ghostty-web .then() callback in terminal.js and fix stale xterm.js references
Critical fix for JavaScript syntax error:
- terminal.js line 282/415: Missing closing }); for .then(function() { callback
  caused SyntaxError: missing ) after argument list, breaking all terminal functionality

Important fixes for stale xterm.js references in terminal.js:
- Line 98: Stale comment 'write directly to xterm' → 'write directly to terminal'
- Line 164: Stale docstring 'xterm.js Terminal' → 'ghostty-web Terminal'
- Line 470: Stale CSS selector '.xterm-helper-textarea' → 'textarea' for ghostty-web

Test improvements:
- Added test_terminal_js_valid_javascript_syntax: validates terminal.js via node -c
  (catches JavaScript syntax errors like this one in CI/CD)
- Added test_terminal_js_no_stale_xterm_comments: prevents future stale xterm references
- Updated test_app.mjs Node.js tests to validate ghostty-web.js instead of stale xterm

Verification:
- node -c terminal.js passes (exit 0)
- 1307/1307 Python tests pass
- Node.js test_app.mjs tests pass
2026-05-28 07:28:22 +00:00
Ken 526c07d4fd feat: Phase 2 complete — muxterm Go binary replaces ttyd
Summary of Phase 2 changes:

Go (muxterm):
- muxterm Go binary with PTY pool, WebSocket server, HMAC token auth
- JSON control protocol (attach/detach/resize/input/output/exited)
- /health endpoint, graceful shutdown on SIGTERM/SIGINT

Python (muxplex):
- GET /api/terminal-token endpoint for muxterm HMAC auth
- Process supervision: start muxterm on boot, restart on crash, stop on shutdown
- Deleted ttyd.py, WS proxy, /connect endpoint, terminal cache

Frontend:
- Single WebSocket connection with JSON control protocol
- ghostty-web replaces xterm.js as terminal renderer
- No terminal cache — muxterm owns the terminal lifecycle
- Deleted xterm.js vendor files

Build verification:
- muxterm binary compiles (go build)
- 1305 Python tests pass
- 18 Go tests pass (17 pass, 1 integration skipped in short mode)
- /health endpoint responds {"status":"ok"}
2026-05-28 07:19:59 +00:00
Ken 316297338b feat: Phase 1 complete — ghostty-web replaces xterm.js as terminal frontend 2026-05-28 07:17:53 +00:00
Ken ca5f34be99 fix: update tests and terminal.js for ghostty-web terminal swap 2026-05-28 07:16:00 +00:00
Ken e42e7fa793 test: update frontend tests for muxterm protocol, remove cache/ttyd tests 2026-05-28 07:11:39 +00:00
Ken 28b64c9117 feat: update openSession/closeSession — no /connect POST for local sessions 2026-05-28 07:08:28 +00:00
Ken c1fed4c206 feat: rewrite terminal.js for muxterm — single WS, control protocol, no cache 2026-05-28 06:54:27 +00:00
Ken 61d2d442c0 fix: remove remaining ttyd references from Python codebase 2026-05-28 06:48:15 +00:00
Ken 266430d1c0 refactor: delete ttyd.py, WS proxy, connect endpoint — muxterm owns terminal path 2026-05-28 06:41:23 +00:00
Ken 8c167453de feat: wire muxterm into FastAPI lifespan (start on boot, stop on shutdown) 2026-05-28 06:32:16 +00:00
Ken 10fe734a30 feat: muxterm process supervision (start, restart on crash, stop) 2026-05-28 06:24:04 +00:00
Ken ee999b0513 chore: remove replaced xterm.js vendor files
Delete xterm.js, xterm.css, and xterm-addon-fit.js from vendor directory.
These are replaced by ghostty-web.js and ghostty-vt.wasm.

Kept: xterm-addon-web-links.js, xterm-addon-search.js, addon-image.js
(still loaded with try/catch guards for ghostty-web's loadAddon() interface).
2026-05-28 06:23:55 +00:00
Ken 27b631c89e feat: update terminal.js for ghostty-web API compatibility 2026-05-28 06:22:02 +00:00
Ken 1b807fc24a feat: GET /api/terminal-token endpoint for muxterm HMAC auth 2026-05-28 06:21:26 +00:00
Ken 11794c4e24 test(muxterm): unit tests for protocol, token validation, and pool 2026-05-28 06:17:50 +00:00
Ken f2d999973a feat: replace xterm.js script tags with ghostty-web in index.html 2026-05-28 06:16:06 +00:00
Ken 10b57c8f60 feat(muxterm): WebSocket server with HMAC token auth and PTY relay 2026-05-28 06:15:05 +00:00
Ken 29aabcfd4f feat: vendor ghostty-web UMD build and WASM binary
- Copy ghostty-web UMD build (638 KB) to muxplex/frontend/vendor/ghostty-web.js
- Copy ghostty-vt WASM binary (413 KB) to muxplex/frontend/vendor/ghostty-vt.wasm
- Add .wasm to _STATIC_EXTENSIONS in auth.py to bypass auth for WASM files
- Add test_vendor_ghostty.py with 5 tests for file existence, size, MIME type, and HTTP serving

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-28 06:12:49 +00:00
Ken b72d82624d feat(muxterm): wire protocol types and parser 2026-05-28 06:11:35 +00:00
Ken 3f0e31007f feat(muxterm): PTY pool with attach, resize, cleanup 2026-05-28 06:09:39 +00:00
Ken ecc6c6979c docs: ghostty-web addon compatibility research
Research findings from inspecting ghostty-web@0.4.0 npm package:
- UMD build exports 16 symbols (Terminal, FitAddon, Ghostty, init, etc.)
- FitAddon: native in ghostty-web, API-compatible, use directly
- WebLinksAddon: not needed, ghostty-web has built-in LinkDetector
- SearchAddon: incompatible (needs _core internals), reimplement via buffer API
- ImageAddon: incompatible (different render pipeline), drop for now
- init()/Ghostty.load(wasmPath): supports explicit WASM path for vendoring
- No CSS required (canvas-only rendering)
- Core Terminal API is highly compatible with xterm.js
2026-05-28 06:06:42 +00:00
Ken 595d08ba5a feat(muxterm): scaffold Go module with main.go entry point 2026-05-28 06:06:36 +00:00
Ken 9db70593c2 docs: resolve auth/handoff design - token-based auth, no Python relay 2026-05-28 05:28:39 +00:00
Ken 1a34ce75b3 docs: terminal architecture redesign spec (muxterm + ghostty-web) 2026-05-28 05:10:41 +00:00
Ken f7ba0a29e5 fix: blank terminal when switching cached sessions
Two bugs introduced by the terminal instance cache that caused blank terminals:

Bug 1 (primary — every second+ visit to a cached session):
  switchTerminal()'s section 4 calls createTerminal(), which disposes the
  existing _term before creating a new one.  At the time section 4 runs,
  _term still points to the PREVIOUSLY-ACTIVE session's terminal — the one
  sitting live in its own cache entry.  After disposal the cache entry holds
  a dead xterm instance; the next visit to that session via the fast-path
  calls fit()/focus() on the disposed terminal, both silently no-op, and the
  terminal renders blank.

  Fix: null out _term and _fitAddon in section 4's 'Reset module-level state'
  block BEFORE createTerminal(), so the previously-active session's terminal
  is left untouched in its cache entry.

Bug 2 (secondary — WS dies while session is in background):
  When a cached session's WebSocket closes while hidden, the close handler
  bails early via the stale-guard (ws !== _ws), because _ws now points to the
  active session's socket.  No reconnect is scheduled.  On revisit:
  isTerminalCached() returns false → openSession() POSTs /connect → but
  switchTerminal() takes the cache-HIT branch (_terminalCache.has() is still
  true), shows the container, and returns — without creating a new WebSocket.
  Terminal container appears but has no live connection → blank.

  Fix: in the cache-HIT branch, check WS readyState before taking the fast
  path.  If the socket is CLOSED or CLOSING, call destroyCachedEntry() and
  fall through to section 4 so a fresh terminal + connection is created.
  (openSession() has already POST'd /connect, so ttyd is ready.)
2026-05-28 01:42:55 +00:00
Ken e032d540b0 feat: ttyd process pool + terminal instance cache for instant session switching
Two independent optimizations that eliminate the ~700ms-1.2s lag when switching sessions:

### Backend: ttyd process pool (ttyd.py + main.py)
Instead of killing and respawning a single ttyd process on every session switch, maintain a pool of ttyd processes - one per connected session, each on its own port (7682-7701). Revisiting a session reuses the existing process instantly.

- New pool API: get_or_spawn(), pool_port(), kill_session(), kill_all(), kill_orphans()
- Backward-compat aliases preserved: kill_ttyd(), spawn_ttyd(), kill_orphan_ttyd(), TTYD_PORT
- /connect endpoint now returns in ~0ms for already-connected sessions vs 700ms+ before
- WS proxy reads ?session= query param to route to correct ttyd port
- Per-port PID files for orphan recovery across restarts

### Frontend: terminal instance cache (terminal.js + app.js)
Keep up to 5 xterm.js Terminal instances alive in hidden container divs. Switching to an already-visited session is a CSS display swap + fit() call - no xterm.js teardown/recreation, no WebSocket reconnection, no addon reloading.

- New switchTerminal() entry point with LRU cache (max 5)
- Each cached entry has its own container div, term, WebSocket, addons
- Cache-aware /connect skip: if terminal is cached with live WS, bypass backend entirely
- Animation gate fixed: 0ms for session-to-session switches (was always 260ms)
- Cache cleanup on session delete

### Test changes (minimal)
- test_ttyd.py: Added pool cleanup in fixture for isolation
- test_api.py: Updated 4 tests to mock new pool API
- test_ws_proxy.py: Updated auto-spawn test for pool API

All 1,306 tests pass.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-28 00:48:21 +00:00
Ken 967b30f8e0 fix: prevent hover preview popover from intercepting sidebar tile clicks
Added pointer-events: none to .preview-popover CSS so clicks fall through to tiles below.
Removed e.stopPropagation() from capture-phase document click handler to prevent event
interception. Changed dispatched event from preview-click to preview-close (cleanup only).
Updated app.js event listener to match.

The hover preview overlay (position: fixed, z-index: 300) was intercepting clicks meant
for sidebar tiles. Its capture-phase click handler called stopPropagation(), killing the
event before the sidebar tile's click handler could fire, then dispatched openSession()
with the hovered session name instead of the clicked one.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-28 00:15:59 +00:00
Ken f9d37984cb fix: xterm.js link handling - OSC 8 hyperlinks and plain-text URL click detection
CI / test (3.13) (push) Failing after 10m42s
CI / test (3.12) (push) Failing after 10m44s
CI / test (3.11) (push) Failing after 10m46s
- OSC 8 hyperlinks (ESC]8;;URL sequences from ls --hyperlink, gcc, grep) now open directly in new tabs instead of showing confirm() dialog
- WebLinksAddon plain-text URL detection now opens on plain click instead of requiring Ctrl/Cmd+Click
- Matches Ghostty/iTerm2 behavior for terminal link handling in browser-based terminal

Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 22:36:06 +00:00
Ken 2a082ecd8d fix: view dropdown menu appears off-screen - add position: relative
CI / test (3.13) (push) Failing after 13m10s
CI / test (3.12) (push) Failing after 13m12s
CI / test (3.11) (push) Failing after 13m14s
The .view-dropdown__menu uses position: absolute; top: calc(100% + 4px)
which needs a positioned ancestor. The .view-dropdown CSS class provided
position: relative, but the <view-dropdown> custom element had no class —
so the menu resolved against the viewport and rendered 581px off-screen.
Fixed by adding position: relative to the .view-dropdown element selector.
2026-05-27 22:23:39 +00:00
Ken 767e7fce79 feat: hide session pill when sidebar is open (redundant switcher)
CI / test (3.13) (push) Failing after 12m56s
CI / test (3.12) (push) Failing after 12m58s
CI / test (3.11) (push) Failing after 13m0s
Hide the floating session pill when the sidebar is visible, since the
sidebar already shows the complete session list. The pill reappears when
the sidebar is collapsed (via toggle or click-away on mobile).

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 21:08:53 +00:00
Ken 815c806da4 fix: stabilize frontend after Lit migration, fix new-session layout, graceful reconnect UX
CI / test (3.13) (push) Failing after 12m40s
CI / test (3.12) (push) Failing after 12m42s
CI / test (3.11) (push) Failing after 12m44s
**Frontend stabilization:**
- Added CSS display: contents rules for custom element wrappers (session-grid, session-tile,
  sidebar-item, view-dropdown) that were defaulting to display: inline and breaking grid/flex
  layouts
- Restored 20-line snapshot previews in session-tile and sidebar-item (were silently reduced
  to 12)
- Fixed view-dropdown New View button missing data-action attribute, breaking create-view flow
- Fixed sidebar dropdown close referencing non-existent #sidebar-view-dropdown-trigger element
- Fixed sidebar renderSidebar() clear+rebuild pattern that ran every 2s poll — now diffs
  existing elements to avoid layout jank and scroll loss
- Removed dead store.js import from index.html
- Removed dead document-level .tile-options-btn delegation handler (superseded by Lit
  custom event chain)
- Updated test for removed handler

**Bug fix: new session from sidebar covers entire screen:**
- Scoped zoom animation selector from unscoped [data-session] to .session-tile[data-session]
  within #session-grid
- Added guard to skip zoom when overview grid not visible (creating from sidebar already
  in fullscreen, no grid tile to zoom from)

**UX: graceful reconnect overlay:**
- Added 1.5s grace period before showing Reconnecting... overlay
- Brief disconnections (like service restarts) now invisible to user
- Timer properly cleaned up in onopen, closeTerminal, and self-clearing callback

**New documentation:**
- Created RECOVERY_PLAN.md documenting COE, state diagrams, storyboards, data model

Generated with Amplifier <https://github.com/microsoft/amplifier>
2026-05-27 18:14:07 +00:00
Ken b1f4569608 feat: implement all P0 UX improvements - visual hierarchy, readable previews, search filter, default session
CI / test (3.13) (push) Failing after 11m36s
CI / test (3.12) (push) Failing after 11m38s
CI / test (3.11) (push) Failing after 11m40s
P0.1: Visual Hierarchy for Tiles
- Backend: Added last_activity_at to GET /api/sessions by querying tmux list-sessions -F '#{session_activity}'
  Updated sessions.py with tuple return from enumerate_sessions and activity cache
  Updated main.py poll loop to populate both session endpoints with activity data
- Frontend: sessionPriority() now returns 'active' (< 5 min) in addition to 'bell'/'idle'
  Added .session-tile--active (accent left border) and .session-tile--stale (dimmed styling) CSS classes
  Updated session-tile.js and sidebar-item.js to apply priority-based classes
- Fixed P4 hover fade bug: removed opacity:0 rule on .session-tile:hover .tile-meta

P0.2: Readable Tile Previews
- Wired --preview-font-size CSS variable (was set by JS but never consumed by CSS)
  Changed default from 11px to 12px with line-height 1.2 (was 1.0)
- Reduced preview line count from 20 to 12 lines for better readability at larger size

P0.3: Search/Filter Bar
- New <search-filter> Lit component with live input, match count, and clear button
  Replaces empty filter-bar div in index.html with functional search component
  Integrated into renderGrid() for live filtering and bindStaticEventListeners() for re-render on input

P0.4: Default Session / Quick-Resume
- Updated restoreState() to fall back to default_session setting when no active_session in server state
  Added existence check before opening to prevent errors on non-existent sessions

Files changed: sessions.py, main.py, app.js, style.css, session-tile.js, sidebar-item.js, utils.js, index.html
New files: search-filter.js (Lit component)
Test updates: test_api.py, test_frontend_css.py, test_integration.py, test_sessions.py

All 1306 tests pass.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 07:30:13 +00:00
Ken 9c86cf096d feat(frontend): phase 3 lit web component migration - view-dropdown & store components
CI / test (3.13) (push) Failing after 10m48s
CI / test (3.12) (push) Failing after 10m50s
CI / test (3.11) (push) Failing after 15m52s
New files:
- muxplex/frontend/components/store.js: Reactive AppStore (EventTarget-based
  state management). Provides get/set/batch/on methods with automatic change
  event dispatch. Convenience accessors for sessions, activeView, serverSettings.
- muxplex/frontend/components/view-dropdown.js: Unified <view-dropdown> component.
  Replaces TWO parallel dropdown implementations (header + sidebar) with a single
  component taking a variant prop. Eliminates ~300 lines of duplicated
  HTML-string-building code.

app.js changes:
- renderViewDropdown/renderSidebarViewDropdown: 100+ lines of HTML builders
  → 6-line property setters
- toggleViewDropdown/toggleSidebarViewDropdown: simplified to toggle .open property
- closeViewDropdown: simplified to set .open = false
- switchView: removed manual sidebar label update (component handles it)
- bindStaticEventListeners: replaced 80+ lines of header/sidebar dropdown event
  wiring + two click-outside handlers with component event listeners
- showNewViewInput/showSidebarNewViewInput: updated to find menu inside component
- Exposed filterVisible() globally for component use
- Removed dead global _previewPopover

index.html changes:
- Replaced 14-line header dropdown structure with single <view-dropdown>
- Replaced 8-line sidebar dropdown structure with single <view-dropdown variant="sidebar">
- Added store.js and view-dropdown.js imports

All 1306 tests pass.
2026-05-27 07:01:01 +00:00
Ken e0c540b367 feat(frontend): phase 2 lit web component migration
CI / test (3.13) (push) Failing after 14m34s
CI / test (3.12) (push) Failing after 14m36s
CI / test (3.11) (push) Failing after 14m38s
Introduce 4 new Lit components for dashboard, sidebar, previews, and mobile UX:
- sidebar-item: <sidebar-item> for expanded-view sidebar entries
- hover-preview: <hover-preview> full-window snapshot popover
- bottom-sheet-switcher: <bottom-sheet-switcher> mobile session switcher
- session-grid: <session-grid> wrapper owning sort/group/repeat loop with keyed rendering

Refactor app.js to leverage components:
- renderSidebar() now uses <sidebar-item> elements with event delegation
- renderGrid() simplified to property-setting on <session-grid>, which owns tile loop
- showPreview/hidePreview simplified to property-setting on <hover-preview>
- openBottomSheet/closeBottomSheet/renderSheetList simplified to property-setting on <bottom-sheet-switcher>
- Removed dead code (renderGroupedGrid, renderFilterBar bodies)
- Preview click and sheet events wired via component event listeners

Update index.html:
- Replace bottom sheet div with <bottom-sheet-switcher> custom element
- Replace session grid div with <session-grid> custom element
- Add <hover-preview> element
- Add new component imports

All 1306 tests pass.

Generated with Amplifier
2026-05-27 06:12:15 +00:00
Ken 8687d72d0b feat: introduce Lit web components framework for frontend (Phase 1)
CI / test (3.13) (push) Failing after 11m50s
CI / test (3.12) (push) Failing after 11m52s
CI / test (3.11) (push) Failing after 11m54s
- Vendored Lit 3 (21KB ESM bundle, no build step required)
- New components: toast-notification, connection-status, session-pill, session-tile
- Extracted utilities: escapeHtml, formatTimestamp, sessionPriority, ansiToHtml
- SVG icons registry for reuse
- Updated app.js and index.html to use new components
- All 1306 tests pass (incremental migration, vanilla JS still supported)
2026-05-27 05:39:57 +00:00
Ken b9fe3d7131 docs: add AGENTS.md with project guide and UX improvement roadmap
CI / test (3.13) (push) Failing after 12m58s
CI / test (3.12) (push) Failing after 13m0s
CI / test (3.11) (push) Failing after 13m2s
2026-05-27 01:14:23 +00:00
Ken 2ec6f73843 feat: mobile UX polish, shell injection fix, and performance optimizations
This commit combines multiple improvements for mobile support, reliability, and performance:

Mobile & Accessibility Improvements:
- Fix touch scrolling on mobile by removing CSS overflow-y:hidden that blocked xterm.js native scroll
- Add beforeinput handler to prevent Android IME double-space-to-period duplication
- Implement mobile control character toolbar (Esc, Tab, Ctrl/Alt toggles, arrows, special chars)
- One-shot modifier toggles bring soft keyboard on demand without showing it for other keys
- Replace 25+ unicode/HTML entity icons with inline SVGs across app for better rendering

Security & Reliability:
- Fix shell injection vulnerability: session names with spaces/special chars now properly quoted
  - Use shlex.quote() in create_session and delete_session endpoints
  - URL-encode session names in bell hook to prevent malformed curl URLs
  - Also hardens against command injection through crafted session names

Performance Optimizations:
- Eliminate 2.4-5.6 second session creation delay (was 3 compounding bottlenecks)
  - Frontend: check for new session immediately (was waiting 2s for interval tick)
  - Backend: eagerly refresh session cache after tmux creation (was waiting for next poll)
  - Connection: poll for ttyd readiness instead of blind 0.8s sleep
- Typical new session now appears in UI in 200-400ms vs previous 4-5 seconds

Infrastructure:
- Improve ttyd port detection with multi-tool fallback (lsof → fuser → ss)
- Add Cloudflare tunnel setup documentation

Test Updates:
- Update frontend tests to match new setTimeout recursion pattern (replaces setInterval)

All 1306 tests pass.

Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-27 01:01:55 +00:00
65 changed files with 9868 additions and 3950 deletions
+3
View File
@@ -28,3 +28,6 @@ Thumbs.db
.vscode/ .vscode/
*.swp *.swp
*.swo *.swo
# Verification screenshots
*.png
+123
View File
@@ -0,0 +1,123 @@
# muxplex
Web-based tmux session dashboard and terminal multiplexer. Pure vanilla JS frontend (no frameworks, no build step), FastAPI backend, xterm.js terminals via ttyd WebSocket proxy.
## Repository
- **Upstream**: https://github.com/bkrabach/muxplex
- **Fork (primary working repo)**: git@git.ampbox.io:ken/muxplex.git
- **Branch strategy**: Work on `main` directly in the fork. PR upstream when ready.
## Architecture
- `muxplex/frontend/app.js` (~4,600 lines) -- dashboard, sidebar, settings, views, mobile UX
- `muxplex/frontend/terminal.js` (~700 lines) -- xterm.js terminal, WebSocket, mobile toolbar
- `muxplex/frontend/style.css` (~2,600 lines) -- dark theme, responsive breakpoints
- `muxplex/frontend/index.html` -- SPA shell
- `muxplex/frontend/vendor/` -- vendored xterm.js + addons (fit, search, web-links, image)
- `muxplex/main.py` -- FastAPI app, API endpoints, WebSocket proxy, poll loop
- `muxplex/sessions.py` -- tmux session enumeration, caching, snapshots
- `muxplex/ttyd.py` -- ttyd process lifecycle management
- `muxplex/tests/` -- 1300+ tests (Python + frontend structure/behavior tests)
## Key patterns
- No build step. JS/CSS served as-is with version-busting query suffixes.
- Icons are inline SVGs (replaced unicode entities), stored in `_icons` object in app.js.
- Mobile detection: `MOBILE_THRESHOLD = 600`, `isMobile()` function.
- Touch devices get a bottom toolbar with Ctrl/Alt modifier toggles.
- xterm.js handles touch scrolling natively -- do NOT add custom touch scroll handlers.
- Session names can contain spaces -- always use `shlex.quote()` when interpolating into shell commands.
- The running service is an editable install from this directory: `systemctl --user restart muxplex` to apply changes.
## Testing
```bash
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
```
The CSS test suite enforces that `@media (max-width: 959px)` is the last `@media` block in style.css. Place new `@media` blocks above it.
---
# UX Improvement Roadmap
Prioritized improvements for a power user on desktop + Android phone + Android tablet.
## P0 -- High Impact (Grid "catches you off guard")
### Search/filter bar on dashboard grid
With 10+ sessions, there's no way to quickly find one. Add a sticky search input at the top of the grid that filters tiles by name as you type. Critical on mobile where the list view requires scrolling through all sessions linearly.
### Visual hierarchy for tiles
All tiles look identical -- no distinction between a session used 30 seconds ago and one idle for 3 days. Add subtle visual cues: accent border on recently active sessions, dimmed styling for idle ones. Give the user's eyes an anchor point.
### Readable tile previews
Terminal snapshot text is 11px in a 300px tile -- it's decoration, not information. Either make it large enough to read (fewer lines, bigger font), or add a readable "last command" / "working directory" summary line beneath the session name.
### Default session / quick-resume
Auto-open the last-used or default session on page load. The `default_session` setting exists but may not be fully wired. At minimum, put a prominent "Resume" action for the most recently used session.
## P1 -- High Impact (Mobile Phone)
### Tap-to-open too easy to trigger accidentally
On mobile list view, any tap anywhere on the tile opens the terminal. Need to distinguish scroll gestures from taps, and make the tap target the header area rather than the entire tile body.
### Session pill is too subtle
In expanded terminal view on mobile, the floating pill (75% opacity, 13px, bottom-right) is the only session switcher. Make it more prominent -- persistent thin bar at bottom, or a swipe-to-switch gesture.
### Swipe gestures
- Swipe from left edge to open sidebar (Android convention)
- Swipe down on bottom sheet to dismiss
- Swipe left/right on session pill to switch sessions
### Scroll-to-bottom indicator
When scrolled up in the terminal buffer, there's no visual cue and no quick way to jump back to live output. Add a floating "Jump to bottom" button that appears when scrolled up.
## P2 -- Medium Impact (Tablet)
### Tablet grid wastes space
At 600-899px (tablet portrait), grid collapses to a single column with 200px tiles. Too conservative. Use a two-column grid with shorter tiles to show more sessions.
### Compact sidebar for tablet
Tablets have room for a mini session list alongside the terminal. Add a compact sidebar mode (just session names, no preview tiles) that works as a side panel rather than a full overlay.
## P3 -- Medium Impact (General UX)
### Keyboard shortcut cheat sheet
Shortcuts exist (`,` settings, `` ` `` view dropdown, `1-9` views, `Escape` close) but are undiscoverable. Add `?` shortcut to show a cheat sheet overlay.
### Quick session creation
The current flow requires typing a name. Add "Quick new" that auto-generates a name (e.g. `session-4`) and opens immediately. Name prompt becomes optional.
### Inline session rename
No rename exists today -- typo means kill and recreate. Add long-press (mobile) / double-click (desktop) on tile header to rename. Backend: `tmux rename-session`.
### Pull-to-refresh on mobile dashboard
The 2s poll handles updates, but users expect pull-to-refresh on mobile. Even a triggered immediate poll gives a sense of control.
### Confirmation before killing sessions
Verify the delete confirmation step is present in the mobile bottom sheet (not just desktop flyout). Accidentally killing a session with running work is catastrophic.
## P4 -- Polish
### Fix tile metadata fade on hover
Timestamp and device badge fade to transparent on hover -- exactly when you're looking at it. Invert: show more detail on hover, not less.
### Reduce grid flicker on poll
The 2s poll rebuilds DOM for all tiles. Diff the DOM instead (only update changed tiles) to eliminate subtle flicker.
### Long-press context menu on mobile tiles
The kebab button is small on mobile. Long-press on the tile itself should open the same action menu -- standard Android convention.
### Better toast positioning on mobile
Toast at `bottom: 80px` may be occluded by the mobile toolbar or FAB. Position above any floating elements.
## Future Ideas
- **Session pinning** -- pin 1-3 sessions to always show at top of grid
- **Session tags/colors** -- color-code sessions by project
- **Command palette** -- `Ctrl+K` / `Cmd+K` to search sessions, switch views, open settings
- **Notification badges on tiles** -- "3 new lines" counter on idle tiles
- **Multi-select on grid** -- select multiple sessions for bulk kill or bulk-add to view
- **Dim vs deep dark toggle** -- for outdoor tablet use in bright light
+217
View File
@@ -0,0 +1,217 @@
# Cloudflare Tunnel Setup for Incus Host
## Overview
Single routing point for all services across Incus instances.
Services self-declare by dropping a file into a shared directory.
No port forwarding, no OPNsense reverse proxy config, no DDNS.
## Prerequisites
- Domain: ampbox.io (registrar: Namecheap)
- Cloudflare account (free tier) with ampbox.io DNS delegated
- At Namecheap: set nameservers to the ones Cloudflare assigns
- Verify domain is active in Cloudflare dashboard
## 1. Install cloudflared on the Incus host
```bash
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
| sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared
```
## 2. Authenticate and create the tunnel
```bash
cloudflared tunnel login # opens browser, authorize ampbox.io
cloudflared tunnel create ampbox # creates tunnel, saves credentials JSON
cloudflared tunnel route dns ampbox "*.ampbox.io" # wildcard DNS route
```
Note the credentials file path printed (typically
`~/.cloudflared/<TUNNEL_ID>.json`). You need it for step 4.
## 3. Create the service declaration directory
```bash
sudo mkdir -p /etc/cloudflared/services.d
```
## 4. Write the main tunnel config
```bash
# Replace <TUNNEL_ID> with your actual tunnel ID from step 2
sudo tee /etc/cloudflared/config.yml << 'EOF'
tunnel: ampbox
credentials-file: /root/.cloudflared/<TUNNEL_ID>.json
ingress:
- service: http_status:404
EOF
```
## 5. Share the declaration directory into all instances
```bash
incus profile device add default svc-declare disk \
source=/etc/cloudflared/services.d \
path=/mnt/services
```
## 6. Helper scripts (on the host)
### /usr/local/bin/svc-rebuild
Reads all service declarations and rebuilds the cloudflared ingress config.
```bash
#!/usr/bin/env bash
set -euo pipefail
CONFIG="/etc/cloudflared/config.yml"
SERVICES_DIR="/etc/cloudflared/services.d"
TUNNEL_CREDS="/root/.cloudflared/<TUNNEL_ID>.json" # fix this
cat > "$CONFIG" <<HEADER
tunnel: ampbox
credentials-file: $TUNNEL_CREDS
ingress:
HEADER
for f in "$SERVICES_DIR"/*.yml; do
[ -f "$f" ] || continue
while IFS= read -r line; do
echo " $line" >> "$CONFIG"
done < "$f"
done
echo " - service: http_status:404" >> "$CONFIG"
sudo systemctl restart cloudflared
echo "Rebuilt with $(ls "$SERVICES_DIR"/*.yml 2>/dev/null | wc -l) service(s)"
```
```bash
sudo install -m 755 /dev/stdin /usr/local/bin/svc-rebuild < svc-rebuild.sh
```
### /usr/local/bin/svc-watch
Auto-rebuilds on file changes (optional but recommended).
```bash
#!/usr/bin/env bash
set -euo pipefail
SERVICES_DIR="/etc/cloudflared/services.d"
echo "Watching $SERVICES_DIR for changes..."
while inotifywait -q -e create,delete,modify "$SERVICES_DIR"; do
sleep 1 # debounce
/usr/local/bin/svc-rebuild
done
```
```bash
sudo apt install -y inotify-tools
sudo install -m 755 /dev/stdin /usr/local/bin/svc-watch < svc-watch.sh
```
## 7. Systemd services (on the host)
### /etc/systemd/system/cloudflared.service
```ini
[Unit]
Description=Cloudflare Tunnel
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/cloudflared tunnel run ampbox
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### /etc/systemd/system/svc-watch.service
```ini
[Unit]
Description=Service declaration watcher
After=cloudflared.service
[Service]
ExecStart=/usr/local/bin/svc-watch
Restart=always
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared svc-watch
```
## 8. Declaring services FROM INSIDE an instance
### Expose an HTTP service
```bash
cat > /mnt/services/myapp.yml << EOF
- hostname: myapp.ampbox.io
service: http://$(hostname -I | awk '{print $1}'):8080
EOF
```
### Expose SSH
```bash
cat > /mnt/services/ssh-$(hostname).yml << EOF
- hostname: ssh-$(hostname).ampbox.io
service: ssh://$(hostname -I | awk '{print $1}'):22
EOF
```
### Remove a service
```bash
rm /mnt/services/myapp.yml
# svc-watch picks it up and rebuilds automatically
```
## 9. Client-side SSH config (on laptops / remote machines)
```
# ~/.ssh/config
Host *.ampbox.io
ProxyCommand cloudflared access ssh --hostname %h
```
Then: `ssh user@ssh-myinstance.ampbox.io` just works.
## 10. OPNsense cleanup
Once the tunnel is verified working, remove from OPNsense:
- All per-service reverse proxy rules in os-caddy
- Port forward / NAT rules for 80 and 443
- DDNS configuration (no longer needed)
OPNsense goes back to just being your firewall.
## Quick Reference
| Action | From inside instance |
|-------------------------|----------------------------------------------------------|
| Expose HTTP | `echo "- hostname: X.ampbox.io\n service: http://IP:PORT" > /mnt/services/X.yml` |
| Expose SSH | Same pattern with `service: ssh://IP:22` |
| Expose raw TCP | Same pattern with `service: tcp://IP:PORT` |
| Remove service | `rm /mnt/services/X.yml` |
| List registered | `ls /mnt/services/` |
| Check tunnel status | `sudo systemctl status cloudflared` (from host) |
@@ -0,0 +1,596 @@
# Phase 1: ghostty-web Swap Implementation Plan
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
**Goal:** Replace xterm.js with ghostty-web as the terminal emulator frontend — same backend, better VT parsing.
**Architecture:** ghostty-web is a WASM-compiled port of Ghostty's VT100 parser with xterm.js API compatibility. It ships a UMD build (`ghostty-web.umd.cjs`) that exposes globals via `<script>` tag — matching the project's no-build-step vendoring pattern. The WASM file (`ghostty-vt.wasm`) must be served alongside it. ghostty-web bundles FitAddon natively; SearchAddon, WebLinksAddon, and ImageAddon have no ghostty-web equivalents yet and need compatibility investigation.
**Tech Stack:** ghostty-web (npm), WASM, vanilla JS (no build step), Python pytest for frontend structural tests
**Spec:** `docs/superpowers/specs/2026-05-28-terminal-architecture-redesign.md` (Phase 1 section)
---
### Task 1: Research ghostty-web addon compatibility
**Files:**
- Create: `docs/superpowers/plans/ghostty-web-addon-research.md`
**Context:** ghostty-web claims xterm.js API compatibility and bundles FitAddon natively. But we use four addons: FitAddon, SearchAddon, WebLinksAddon, and ImageAddon. This task determines which work, which don't, and what the fallback plan is.
**Step 1: Download and inspect the ghostty-web npm package**
Run:
```bash
cd /tmp && mkdir ghostty-web-research && cd ghostty-web-research
npm pack ghostty-web
tar xzf ghostty-web-*.tgz
ls -la package/
ls -la package/dist/
```
Examine the UMD build to understand what globals it exposes:
```bash
head -100 package/dist/ghostty-web.umd.cjs
# Look for: what global name it uses (GhosttyWeb? Terminal? etc.)
# Look for: FitAddon export
# Look for: init() function export
```
Check the WASM file size:
```bash
ls -la package/ghostty-vt.wasm
ls -la package/dist/ghostty-vt.wasm
```
**Step 2: Check if xterm.js addons are loadable via ghostty-web's `loadAddon()` API**
Read the UMD build to verify the `loadAddon()` method signature. The xterm.js addon interface is:
```javascript
// Addon must implement: activate(terminal), dispose()
terminal.loadAddon(addonInstance);
```
If ghostty-web implements the same `loadAddon()` contract, existing xterm.js addons (SearchAddon, WebLinksAddon) may work as-is since they only depend on the Terminal API surface.
**Step 3: Check FitAddon**
ghostty-web bundles FitAddon natively (confirmed in its AGENTS.md: "Addons (FitAddon)"). Determine:
- Is it exported from the UMD build as `FitAddon`?
- Does it have the same API: `new FitAddon()`, `.fit()`, `.proposeDimensions()`?
**Step 4: Check ghostty-web's `init()` requirement**
ghostty-web requires `await init()` before creating Terminal instances (loads WASM). The UMD build needs:
- Does `init()` accept a WASM URL parameter? (needed since we serve from `/vendor/`)
- Or does it auto-detect the WASM location relative to the JS file?
**Step 5: Document findings**
Write `docs/superpowers/plans/ghostty-web-addon-research.md` with:
- What globals the UMD build exposes
- FitAddon: works/doesn't, API differences
- SearchAddon: can xterm.js addon load via `loadAddon()`? If not, what's the fallback?
- WebLinksAddon: same question. Note: ghostty-web may handle URL detection natively.
- ImageAddon: same question. Note: this is the least critical — Sixel/iTerm2 image support is a nice-to-have.
- `init()` behavior: WASM URL configuration
- CSS: does ghostty-web need `xterm.css` or has its own styles?
- Any API surface differences found (e.g., `term.write()` accepts Uint8Array? string only?)
**Step 6: Commit**
```bash
git add docs/superpowers/plans/ghostty-web-addon-research.md
git commit -m "docs: ghostty-web addon compatibility research"
```
---
### Task 2: Vendor ghostty-web files
**Files:**
- Create: `muxplex/frontend/vendor/ghostty-web.umd.cjs`
- Create: `muxplex/frontend/vendor/ghostty-vt.wasm`
- Create: `muxplex/frontend/vendor/ghostty-web.css` (if ghostty-web has its own CSS)
**Depends on:** Task 1 (need to know exact file names and what to vendor)
**Step 1: Copy the UMD build and WASM into vendor directory**
Run:
```bash
# If not already unpacked from Task 1:
cd /tmp/ghostty-web-research || (mkdir -p /tmp/ghostty-web-research && cd /tmp/ghostty-web-research && npm pack ghostty-web && tar xzf ghostty-web-*.tgz)
# Copy UMD build
cp /tmp/ghostty-web-research/package/dist/ghostty-web.umd.cjs muxplex/frontend/vendor/ghostty-web.js
# Copy WASM file (check both locations)
cp /tmp/ghostty-web-research/package/dist/ghostty-vt.wasm muxplex/frontend/vendor/ghostty-vt.wasm \
|| cp /tmp/ghostty-web-research/package/ghostty-vt.wasm muxplex/frontend/vendor/ghostty-vt.wasm
```
Note: we rename `ghostty-web.umd.cjs` to `ghostty-web.js` to match the project's `.js` extension convention for vendor files.
**Step 2: If ghostty-web has CSS, copy it**
Check if there's a CSS file in the package:
```bash
find /tmp/ghostty-web-research/package -name "*.css" -type f
```
If found, copy to `muxplex/frontend/vendor/ghostty-web.css`.
**Step 3: Verify the WASM file is served correctly**
The Python FastAPI app serves `muxplex/frontend/` as static files. WASM files need the correct MIME type (`application/wasm`). Check if FastAPI/Starlette handles this:
```bash
grep -r "wasm\|mime\|StaticFiles\|static" muxplex/main.py | head -20
```
If the static file server doesn't serve `.wasm` with the correct MIME type, we may need to add a MIME type mapping. Most modern versions of Starlette handle this automatically.
**Step 4: Verify vendor files are present**
Run:
```bash
ls -la muxplex/frontend/vendor/ghostty-web.js
ls -la muxplex/frontend/vendor/ghostty-vt.wasm
wc -c muxplex/frontend/vendor/ghostty-web.js muxplex/frontend/vendor/ghostty-vt.wasm
```
Expected: `ghostty-web.js` exists, `ghostty-vt.wasm` exists (~404KB).
**Step 5: Commit**
```bash
git add muxplex/frontend/vendor/ghostty-web.js muxplex/frontend/vendor/ghostty-vt.wasm
# Add CSS too if it exists:
# git add muxplex/frontend/vendor/ghostty-web.css
git commit -m "vendor: add ghostty-web UMD build and WASM"
```
---
### Task 3: Update index.html script tags
**Files:**
- Modify: `muxplex/frontend/index.html`
**Depends on:** Task 2 (vendor files must exist), Task 1 (know the CSS situation)
**Step 1: Write the failing test**
Add a test to `muxplex/tests/test_frontend_js.py` that verifies the new script tags:
```python
# At the top of the file, add:
INDEX_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "index.html"
_INDEX_HTML: str = INDEX_HTML_PATH.read_text()
# Add these tests at the end of the file:
# -- ghostty-web migration (Phase 1) --
def test_index_html_loads_ghostty_web_js() -> None:
"""index.html must load ghostty-web.js vendor script."""
assert "ghostty-web.js" in _INDEX_HTML, (
"index.html must include a script tag for vendor/ghostty-web.js"
)
def test_index_html_no_xterm_js_script() -> None:
"""index.html must not load xterm.js (replaced by ghostty-web)."""
assert 'src="/vendor/xterm.js"' not in _INDEX_HTML, (
"index.html must not load xterm.js — replaced by ghostty-web"
)
def test_index_html_no_xterm_addon_fit_script() -> None:
"""index.html must not load xterm-addon-fit.js (FitAddon is built into ghostty-web)."""
assert "xterm-addon-fit.js" not in _INDEX_HTML, (
"index.html must not load xterm-addon-fit.js — FitAddon is built into ghostty-web"
)
```
**Step 2: Run tests to verify they fail**
Run:
```bash
.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py::test_index_html_loads_ghostty_web_js -xvs
```
Expected: FAIL — ghostty-web.js not found in index.html yet.
**Step 3: Update index.html**
In `muxplex/frontend/index.html`, replace the xterm.js script tags (lines 272-276):
**Before:**
```html
<script src="/vendor/xterm.js"></script>
<script src="/vendor/xterm-addon-fit.js"></script>
<script src="/vendor/xterm-addon-web-links.js"></script>
<script src="/vendor/xterm-addon-search.js"></script>
<script src="/vendor/addon-image.js"></script>
```
**After:**
```html
<script src="/vendor/ghostty-web.js"></script>
<script src="/vendor/xterm-addon-web-links.js"></script>
<script src="/vendor/xterm-addon-search.js"></script>
<script src="/vendor/addon-image.js"></script>
```
Note: We keep WebLinksAddon, SearchAddon, and ImageAddon for now. They use the xterm.js addon interface (`loadAddon`). Task 1 research will determine if they're compatible. If not, Task 4 will handle fallbacks. FitAddon is dropped because ghostty-web bundles it natively.
Also update the CSS link if ghostty-web has its own CSS. If ghostty-web does NOT have a CSS file (it uses canvas rendering, not DOM), remove the xterm.css link:
**Before:**
```html
<link rel="stylesheet" href="/vendor/xterm.css" />
```
**After (if ghostty-web has CSS):**
```html
<link rel="stylesheet" href="/vendor/ghostty-web.css" />
```
**After (if ghostty-web has NO CSS — canvas renderer needs no CSS):**
```html
<!-- ghostty-web uses canvas rendering, no CSS needed -->
```
**Step 4: Run tests to verify they pass**
Run:
```bash
.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -k "ghostty" -xvs
```
Expected: PASS
**Step 5: Commit**
```bash
git add muxplex/frontend/index.html muxplex/tests/test_frontend_js.py
git commit -m "feat: update index.html to load ghostty-web instead of xterm.js"
```
---
### Task 4: Update terminal.js for ghostty-web API
**Files:**
- Modify: `muxplex/frontend/terminal.js`
**Depends on:** Task 1 (research results), Task 3 (script tags updated)
**Context:** ghostty-web is API-compatible with xterm.js, but has two key differences:
1. Requires `await init()` before creating Terminal — loads WASM asynchronously
2. FitAddon is built-in (import path differs)
3. The global name exposed by the UMD build may differ from `window.Terminal`
The existing code uses:
- `new window.Terminal({...})` (line 349)
- `new window.FitAddon.FitAddon()` (line 369)
- `_term.loadAddon(...)` for WebLinksAddon, SearchAddon, ImageAddon (lines 387-406)
- `_term.write(string)` for terminal output
- `_term.onData(callback)` for terminal input
- `_term.onResize(callback)` for resize events
- `_term.dispose()` for cleanup
- `_term.parser.registerOscHandler()` for OSC 52 clipboard
- `_term.attachCustomKeyEventHandler()` for key interception
- `_term.getSelection()` and `_term.onSelectionChange()` for clipboard
- `_term.focus()`, `_term.cols`, `_term.rows`
**Step 1: Add WASM initialization at module load**
Add a WASM initialization block near the top of `terminal.js` (after the module-level state declarations, around line 38). ghostty-web's UMD build exposes an `init()` function that must be called once before creating any Terminal instance:
```javascript
// --- ghostty-web WASM initialization ---
// ghostty-web requires WASM to be loaded before creating Terminal instances.
// The UMD build exposes init() on the global (e.g., window.GhosttyWeb.init()).
// Call init() once at module load; createTerminal() awaits the result.
var _ghosttyReady = null; // Promise that resolves when WASM is loaded
(function _initGhosttyWasm() {
// The exact global depends on the UMD build — Task 1 research identifies this.
// Common patterns: window.GhosttyWeb.init(), window.ghosttyWeb.init()
// The init() call may accept a WASM URL: init('/vendor/ghostty-vt.wasm')
if (typeof window !== 'undefined' && window.GhosttyWeb && window.GhosttyWeb.init) {
_ghosttyReady = window.GhosttyWeb.init('/vendor/ghostty-vt.wasm');
} else if (typeof window !== 'undefined' && window.init) {
_ghosttyReady = window.init('/vendor/ghostty-vt.wasm');
} else {
_ghosttyReady = Promise.resolve(); // Fallback for test environments
}
})();
```
**Important:** The exact global name and init() signature depend on Task 1 research findings. Adjust the code above based on what the UMD build actually exposes.
**Step 2: Update `createTerminal()` — Terminal constructor**
In the `createTerminal()` function (line 335), update the Terminal constructor reference. ghostty-web's UMD build may expose the Terminal class under a different global:
**Before (line 349):**
```javascript
_term = new window.Terminal({
```
**After (adjust global name based on Task 1 research):**
```javascript
// ghostty-web UMD exposes Terminal on the same global or on GhosttyWeb
var TerminalClass = window.GhosttyWeb ? window.GhosttyWeb.Terminal : window.Terminal;
_term = new TerminalClass({
```
**Step 3: Update FitAddon instantiation**
ghostty-web bundles FitAddon. The import path changes:
**Before (line 369):**
```javascript
_fitAddon = new window.FitAddon.FitAddon();
```
**After (adjust based on Task 1 research — FitAddon may be on GhosttyWeb or still on window.FitAddon):**
```javascript
var FitAddonClass = (window.GhosttyWeb && window.GhosttyWeb.FitAddon)
? window.GhosttyWeb.FitAddon
: (window.FitAddon && window.FitAddon.FitAddon);
_fitAddon = new FitAddonClass();
```
**Step 4: Guard addon loading for compatibility**
The WebLinksAddon, SearchAddon, and ImageAddon use the xterm.js addon interface. If they fail to load with ghostty-web, wrap them in try/catch so the terminal still works:
**Before (lines 387-406):**
```javascript
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
if (WebLinksAddon) {
_term.loadAddon(new WebLinksAddon(function(event, uri) {
window.open(uri, '_blank');
}));
}
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
if (SearchAddon) {
_searchAddon = new SearchAddon();
_term.loadAddon(_searchAddon);
}
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
if (ImageAddon) {
_term.loadAddon(new ImageAddon());
}
```
**After:**
```javascript
// WebLinksAddon — try loading xterm.js addon; ghostty-web may or may not support it
try {
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
if (WebLinksAddon) {
_term.loadAddon(new WebLinksAddon(function(event, uri) {
window.open(uri, '_blank');
}));
}
} catch (e) {
console.warn('WebLinksAddon not compatible with ghostty-web:', e.message);
}
// SearchAddon — try loading xterm.js addon
try {
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
if (SearchAddon) {
_searchAddon = new SearchAddon();
_term.loadAddon(_searchAddon);
}
} catch (e) {
console.warn('SearchAddon not compatible with ghostty-web:', e.message);
_searchAddon = null;
}
// ImageAddon — try loading xterm.js addon (Sixel, iTerm2 IIP, Kitty graphics)
try {
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
if (ImageAddon) {
_term.loadAddon(new ImageAddon());
}
} catch (e) {
console.warn('ImageAddon not compatible with ghostty-web:', e.message);
}
```
**Step 5: Handle the async init() in openTerminal()**
The `openTerminal()` function (line 453) and `switchTerminal()` function (line 642) call `createTerminal()` synchronously. Since ghostty-web requires WASM to be loaded first, ensure `_ghosttyReady` is resolved before terminal creation. The simplest approach: `init()` fires at page load (module execution), so by the time the user clicks a session tile, WASM is loaded. But add a guard:
In `openTerminal()`, before `createTerminal(fontSize)` (line 479), add:
```javascript
// Ensure ghostty-web WASM is loaded before creating terminal
if (_ghosttyReady && typeof _ghosttyReady.then === 'function') {
_ghosttyReady.then(function() {
_ghosttyReady = null; // Only wait once
});
}
```
Note: Since `init()` is called at module load and terminal opening happens after user interaction (seconds later), the WASM will already be loaded. This guard is defensive only.
**Step 6: Verify the terminal.js changes don't break the existing test suite**
Run:
```bash
.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -xvs
```
Expected: All existing tests PASS (they test app.js structure, not terminal.js internals).
**Step 7: Commit**
```bash
git add muxplex/frontend/terminal.js
git commit -m "feat: update terminal.js for ghostty-web API (FitAddon built-in, addon guards)"
```
---
### Task 5: Remove old xterm.js vendor files
**Files:**
- Delete: `muxplex/frontend/vendor/xterm.js`
- Delete: `muxplex/frontend/vendor/xterm.css`
- Delete: `muxplex/frontend/vendor/xterm-addon-fit.js`
**Depends on:** Task 3, Task 4 (new vendor files are loaded, terminal.js updated)
**Important:** Do NOT delete `xterm-addon-web-links.js`, `xterm-addon-search.js`, or `addon-image.js` yet. These addons are still loaded (with try/catch guards) and may work with ghostty-web's `loadAddon()` interface. They'll be removed later once ghostty-web has native equivalents or Phase 2 eliminates the need.
**Step 1: Delete the replaced files**
```bash
rm muxplex/frontend/vendor/xterm.js
rm muxplex/frontend/vendor/xterm.css
rm muxplex/frontend/vendor/xterm-addon-fit.js
```
**Step 2: Verify no remaining references to deleted files**
```bash
grep -r "xterm\.js" muxplex/frontend/ --include="*.html" --include="*.js"
grep -r "xterm\.css" muxplex/frontend/ --include="*.html"
grep -r "xterm-addon-fit" muxplex/frontend/ --include="*.html" --include="*.js"
```
Expected: No references to `xterm.js`, `xterm.css`, or `xterm-addon-fit.js` in HTML or JS files. (Comments referencing "xterm.js" in code are fine — they're documentation, not imports.)
**Step 3: Verify remaining vendor files**
```bash
ls -la muxplex/frontend/vendor/
```
Expected contents:
- `ghostty-web.js` (new)
- `ghostty-vt.wasm` (new)
- `ghostty-web.css` (new, if applicable)
- `xterm-addon-web-links.js` (kept)
- `xterm-addon-search.js` (kept)
- `addon-image.js` (kept)
- `lit/` directory (unrelated, kept)
**Step 4: Commit**
```bash
git add -u muxplex/frontend/vendor/
git commit -m "chore: remove xterm.js, xterm.css, xterm-addon-fit.js (replaced by ghostty-web)"
```
---
### Task 6: Manual smoke test
**Files:** None (testing only)
**Depends on:** Tasks 2-5 complete
**Step 1: Restart the service**
```bash
systemctl --user restart muxplex
```
**Step 2: Test terminal rendering**
Open the muxplex web UI in a browser. Click a session tile to open a terminal. Verify:
- [ ] Terminal renders and shows a shell prompt
- [ ] Typing works (keystrokes reach the shell)
- [ ] Colors render correctly (run `ls --color` or `htop`)
- [ ] Terminal resizes when the browser window resizes (FitAddon working)
- [ ] Scrollback works (scroll up to see history)
**Step 3: Test search (Ctrl+F)**
Press Ctrl+F in the terminal. If the search bar appears and search works, SearchAddon loaded successfully. If not, note this as a known limitation.
**Step 4: Test links**
Run `echo "https://example.com"` in the terminal. Hover over the URL. If it becomes clickable, WebLinksAddon loaded successfully. Note: ghostty-web may handle OSC 8 hyperlinks natively even without WebLinksAddon.
**Step 5: Test session switching**
Open one session, then click a different session in the sidebar. Verify the terminal switches and renders the new session content.
**Step 6: Document any issues**
If anything doesn't work, note it. Common issues to watch for:
- WASM not loading: check browser console for 404 on `ghostty-vt.wasm` or MIME type errors
- Terminal blank: check if `init()` resolved before Terminal creation
- Addons failing: check console for the try/catch warning messages
- CSS issues: ghostty-web uses canvas rendering so xterm.css classes like `.xterm` may not exist
---
### Task 7: Run the full test suite
**Files:** None (testing only)
**Depends on:** Task 6 (smoke test passed or issues documented)
**Step 1: Run all tests**
Run:
```bash
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
```
Expected: All 1306 tests pass. The frontend structural tests (`test_frontend_js.py`) verify app.js structure — they don't execute JavaScript, so they're unaffected by the terminal swap. The new tests from Task 3 verify index.html loads ghostty-web.
**Step 2: If tests fail, fix before proceeding**
Most likely failure: a test that greps for "xterm" in terminal.js comments. These are documentation tests (the test file checks for patterns in JS source). If a test assumes `xterm.js` is present in terminal.js comments, update the comment to reference ghostty-web instead.
Check specifically:
```bash
grep -n "xterm" muxplex/tests/test_frontend_js.py | head -20
```
If any tests reference xterm.js presence in terminal.js, update them.
---
### Task 8: Final commit
**Files:** Any remaining uncommitted changes
**Depends on:** Task 7 (all tests pass)
**Step 1: Check for uncommitted changes**
```bash
git status
git diff --stat
```
**Step 2: Commit any remaining changes**
```bash
git add -A
git commit -m "feat: Phase 1 complete — ghostty-web replaces xterm.js as terminal frontend"
```
**Step 3: Verify the commit history**
```bash
git log --oneline -10
```
Expected: Clean commit history showing the incremental swap:
1. Research doc
2. Vendor ghostty-web files
3. Update index.html script tags
4. Update terminal.js for API differences
5. Remove old xterm.js files
6. Final commit (if any remaining changes)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,275 @@
# ghostty-web Addon Compatibility Research
> **Package:** ghostty-web@0.4.0
> **Date:** 2026-05-28
> **Source:** npm pack + manual inspection of UMD build, ESM build, and TypeScript definitions
---
## 1. UMD Build Globals
The UMD build (`dist/ghostty-web.umd.cjs`, 639KB) uses standard `(function(e){})(this)` wrapping. When loaded via `<script>` tag, the module assigns to `exports` (CommonJS) or `this`. The following symbols are exported:
| Export Name | Type | Description |
|---|---|---|
| `Terminal` | class | Main terminal class (xterm.js-compatible API) |
| `FitAddon` | class | Bundled FitAddon (activate/dispose/fit/proposeDimensions) |
| `Ghostty` | class | WASM wrapper — `Ghostty.load(wasmPath?)` creates instances |
| `init` | async function | Convenience initializer — calls `Ghostty.load()` internally |
| `getGhostty` | function | Returns the singleton Ghostty instance (internal) |
| `CanvasRenderer` | class | Canvas-based terminal renderer |
| `EventEmitter` | class | Generic event emitter |
| `GhosttyTerminal` | class | Low-level WASM terminal wrapper |
| `InputHandler` | class | Keyboard/input event handler |
| `KeyEncoder` | class | Key encoding for WASM |
| `KeyEncoderOption` | enum | Key encoder options |
| `LinkDetector` | class | URL and link detection |
| `OSC8LinkProvider` | class | OSC8 hyperlink provider |
| `UrlRegexProvider` | class | Regex-based URL provider |
| `SelectionManager` | class | Text selection management |
| `CellFlags` | enum | Cell style flags (bold, italic, etc.) |
**Key difference from xterm.js:** The UMD build does NOT expose a single global like `window.Terminal`. It needs a module loader or manual extraction from the exports object. For `<script>` tag loading, the script would need to be wrapped or the page would use `require()` / a UMD shim to access the exports.
**Recommended approach for vendoring:** Use the UMD file with a small wrapper that extracts `Terminal`, `FitAddon`, and `init` onto `window`:
```javascript
// In a wrapper or after loading ghostty-web.umd.cjs
window.GhosttyWeb = require('ghostty-web'); // or the UMD exports object
```
---
## 2. FitAddon Status: COMPATIBLE (Native)
ghostty-web bundles FitAddon natively. It is exported directly from the package as `FitAddon`.
**API surface:**
| Method | Signature | xterm.js FitAddon Match |
|---|---|---|
| `activate(terminal)` | `activate(terminal: ITerminalCore): void` | Yes |
| `dispose()` | `dispose(): void` | Yes |
| `fit()` | `fit(): void` | Yes |
| `proposeDimensions()` | `proposeDimensions(): {cols, rows} \| undefined` | Yes |
| `observeResize()` | `observeResize(): void` | **Extra** — not in xterm.js FitAddon |
**Implementation notes:**
- Uses `terminal.renderer.getMetrics()` instead of xterm.js's internal `_core._renderService.dimensions` — this means xterm.js's FitAddon won't work with ghostty-web (different internal API), but ghostty-web's native FitAddon works perfectly.
- `proposeDimensions()` reads `element.clientWidth/clientHeight` and computes `{cols, rows}` from font metrics — same pattern as xterm.js FitAddon.
- Includes a debounced `ResizeObserver` via `observeResize()` — bonus feature not in xterm.js FitAddon.
- Minimum dimensions enforced: 2 cols, 1 row.
**Verdict:** Use ghostty-web's native FitAddon. Drop xterm-addon-fit vendor file.
---
## 3. loadAddon() Compatibility
ghostty-web's `Terminal.loadAddon()` implementation:
```javascript
loadAddon(addon) {
addon.activate(this);
this.addons.push(addon);
}
```
**Interface contract (from TypeScript definitions):**
```typescript
interface ITerminalAddon {
activate(terminal: ITerminalCore): void;
dispose(): void;
}
interface ITerminalCore {
cols: number;
rows: number;
element?: HTMLElement;
textarea?: HTMLTextAreaElement;
}
```
This is a minimal interface. xterm.js addons that only use `cols`, `rows`, `element`, and public API methods (write, onData, etc.) will work. Addons that reach into xterm.js internals (`_core`, `_renderService`, etc.) will break.
---
## 4. SearchAddon Compatibility: WILL NOT WORK
xterm.js SearchAddon (`@xterm/addon-search`) reaches deep into xterm.js internals:
- Accesses `terminal._core._bufferService` for buffer traversal
- Uses `terminal._core._decorationService` for match highlighting
- Relies on xterm.js's internal `IBuffer` / `IBufferLine` with `getCell()` API
ghostty-web exposes a `buffer` property with `IBufferNamespace` (active, normal, alternate buffers with `getLine()` and `getCell()`), but the internal structure differs from xterm.js — the SearchAddon accesses `_core` which does not exist.
**Fallback plan:** Implement search natively using ghostty-web's public `buffer` API:
- `terminal.buffer.active.getLine(y)` returns `IBufferLine` with `getCell(x).getChars()`
- Iterate lines, build text, find matches, use `terminal.select(col, row, length)` to highlight
- Our current search usage (Ctrl+F bar with `findNext`/`findPrevious`) can be reimplemented in ~50-80 lines using this public API
---
## 5. WebLinksAddon Compatibility: NOT NEEDED
ghostty-web has **built-in link detection** that replaces xterm.js WebLinksAddon:
- `LinkDetector` class handles URL detection internally
- `UrlRegexProvider` provides regex-based URL detection (same as WebLinksAddon)
- `OSC8LinkProvider` handles OSC8 hyperlinks (explicit terminal hyperlinks)
- Both providers are registered automatically during `terminal.open()`
- Links are underlined on hover and clickable (Ctrl/Cmd+click)
Additionally, `terminal.registerLinkProvider(provider)` accepts custom link providers with the interface:
```typescript
interface ILinkProvider {
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void;
dispose?(): void;
}
```
**Verdict:** Drop xterm-addon-web-links vendor file. ghostty-web handles this natively.
---
## 6. ImageAddon Compatibility: WILL NOT WORK
xterm.js ImageAddon (`@xterm/addon-image`) provides inline image rendering (Sixel, iTerm2 IIP, Kitty graphics protocol). It hooks into xterm.js's render layer system (`_core._renderService`) to overlay images on the terminal canvas.
ghostty-web uses its own `CanvasRenderer` with a completely different rendering pipeline (two-pass cell rendering via WASM). There is no render layer plugin system.
**Current usage assessment:** ImageAddon is loaded optionally in our terminal.js (`if (ImageAddon) { ... }`). It's used for Sixel/iTerm2 inline image rendering. This is a nice-to-have feature, not critical for core terminal functionality.
**Fallback plan:** Drop ImageAddon for now. Ghostty's native terminal supports Kitty graphics protocol — this may be exposed in future ghostty-web versions. Monitor the ghostty-web repo for graphics protocol support.
---
## 7. init() and WASM URL Configuration
### init() function
```javascript
// Simplified from source
let ghosttyInstance = null;
async function init() {
if (!ghosttyInstance) {
ghosttyInstance = await Ghostty.load();
}
}
```
`init()` takes **no parameters**. It calls `Ghostty.load()` with no arguments.
### Ghostty.load(wasmPath?) — the key function
```typescript
static load(wasmPath?: string): Promise<Ghostty>;
```
**If `wasmPath` is provided:** Loads WASM directly from that path. This is the mechanism for `/vendor/` serving.
**If `wasmPath` is omitted (default `init()`):** Tries multiple fallback locations in order:
1. Data URL with embedded tiny WASM stub (for probing — this is just the WASM header)
2. `file://` protocol path (for Node/Bun environments)
3. URL relative to the JS file (via `import.meta.url` in ESM)
4. `./ghostty-vt.wasm` (relative to page)
5. `/ghostty-vt.wasm` (root-relative)
**Recommended approach for our vendoring pattern:**
Don't use `init()` — use `Ghostty.load()` directly with an explicit WASM path:
```javascript
const ghostty = await GhosttyWeb.Ghostty.load('/vendor/ghostty-vt.wasm');
const term = new GhosttyWeb.Terminal({ ghostty: ghostty });
```
This gives full control over WASM location and avoids the fallback probe chain.
### WASM file details
- **File:** `ghostty-vt.wasm`
- **Size:** 423KB (413KB gzipped estimate: ~180-200KB)
- **Duplicated:** Same file at both `package/ghostty-vt.wasm` and `package/dist/ghostty-vt.wasm`
- **Content:** Ghostty's VT100 parser compiled to WebAssembly
---
## 8. CSS Requirements
**ghostty-web does NOT ship any CSS files.** No `xterm.css` equivalent exists.
The terminal renders entirely via `<canvas>` element. All styling (colors, fonts, cursor) is handled through:
- Constructor options: `theme`, `fontSize`, `fontFamily`, `cursorStyle`, `cursorBlink`
- Canvas rendering in `CanvasRenderer`
- Inline styles applied programmatically to the container element
**Implication:** When swapping from xterm.js, remove the `xterm.css` stylesheet link. No replacement CSS is needed. The terminal container just needs basic CSS for sizing (width/height).
---
## 9. API Surface Differences
### Compatible APIs (same as xterm.js)
| API | Notes |
|---|---|
| `new Terminal(options)` | Same options: cols, rows, theme, fontSize, fontFamily, cursorBlink, cursorStyle, scrollback, convertEol, disableStdin, allowTransparency |
| `term.open(element)` | Same |
| `term.write(data, callback?)` | Accepts both `string` and `Uint8Array` |
| `term.writeln(data, callback?)` | Same |
| `term.resize(cols, rows)` | Same |
| `term.clear()` | Same |
| `term.reset()` | Same |
| `term.focus()` / `term.blur()` | Same |
| `term.dispose()` | Same — also cleans up addons |
| `term.loadAddon(addon)` | Same interface |
| `term.onData` / `term.onResize` / `term.onTitleChange` / `term.onBell` / `term.onSelectionChange` / `term.onKey` / `term.onScroll` / `term.onRender` / `term.onCursorMove` | Same event API |
| `term.cols` / `term.rows` | Same |
| `term.element` / `term.textarea` | Same |
| `term.buffer` | IBufferNamespace with active/normal/alternate — similar to xterm.js |
| `term.getSelection()` / `term.hasSelection()` / `term.clearSelection()` / `term.selectAll()` / `term.select()` | Same |
| `term.paste(data)` | Same — handles bracketed paste |
| `term.attachCustomKeyEventHandler(handler)` | Same |
| `term.registerLinkProvider(provider)` | Same interface |
| `term.scrollLines()` / `term.scrollPages()` / `term.scrollToTop()` / `term.scrollToBottom()` | Same |
| `term.options` | Proxy object — runtime changes trigger updates (fontSize, fontFamily, cursorBlink, etc.) |
| `term.unicode.activeVersion` | Returns "15.1" |
### Different / Additional APIs
| API | Difference |
|---|---|
| `init()` | **New** — must be called before creating Terminal (or pass `ghostty` option) |
| `Ghostty.load(wasmPath?)` | **New** — explicit WASM loading with path control |
| `new Terminal({ ghostty })` | **New option** — pass pre-loaded Ghostty instance |
| `term.input(data, wasUserInput?)` | **New** — input as if typed by user |
| `term.renderer` | **Exposed** — CanvasRenderer is public (xterm.js hides this) |
| `term.wasmTerm` | **Exposed** — direct access to WASM terminal |
| `term.attachCustomWheelEventHandler()` | **New** — custom scroll handling |
| `term.smoothScrollTo()` | **New** — animated scrolling |
| `FitAddon.observeResize()` | **New** — auto-fit on container resize via ResizeObserver |
| `term.getMode()` / `term.hasBracketedPaste()` / `term.hasFocusEvents()` / `term.hasMouseTracking()` | **New** — terminal mode queries |
### Missing APIs (present in xterm.js, absent in ghostty-web)
| API | Impact |
|---|---|
| `term.registerMarker()` | Not available — used by some addons internally |
| `term.registerDecoration()` | Not available — used by SearchAddon for highlighting |
| `term._core` | Not available — internal access used by many addons |
---
## 10. Summary & Recommendations
| Addon | Status | Action |
|---|---|---|
| **FitAddon** | Native in ghostty-web | Use `FitAddon` from ghostty-web. Drop `xterm-addon-fit.js`. |
| **WebLinksAddon** | Native in ghostty-web | Built-in `LinkDetector` + `UrlRegexProvider`. Drop `xterm-addon-web-links.js`. |
| **SearchAddon** | Incompatible | Reimplement using `terminal.buffer` public API + `terminal.select()`. ~50-80 lines. |
| **ImageAddon** | Incompatible | Drop for now. Monitor ghostty-web for future graphics protocol support. |
**Migration blockers:** None. All four addons have a path forward.
**Critical init change:** Must call `await Ghostty.load('/vendor/ghostty-vt.wasm')` before creating Terminal instances. This is the only breaking change vs xterm.js's synchronous `new Terminal()`.
@@ -0,0 +1,282 @@
# Terminal Architecture Redesign: muxterm + ghostty-web
## Goal
Replace the multi-hop terminal data path (xterm.js → Python WS proxy → ttyd → tmux) with a direct architecture (ghostty-web → muxterm Go binary → tmux) that eliminates the reliability failures and latency inherent in managing ttyd as an external process through a Python relay.
## Background
The current terminal data path has fundamental architectural problems causing reliability failures (blank terminals, race conditions, process lifecycle hell) and performance issues (~700ms-1.2s session switch latency, up to 2.5s worst case):
```
Browser (xterm.js) ←WS→ Python FastAPI (dumb byte relay) ←WS→ ttyd ←PTY→ tmux attach
```
Every keystroke and terminal output byte traverses two WebSocket hops through a Python async relay that adds zero value during active use. The relay is a 100% dumb byte pipe — zero inspection, zero logging, zero transformation of frames. It exists solely because Caddy was removed to make muxplex distributable as a single-port Python tool (`uv tool install`), and the Python proxy replaced Caddy's WS forwarding role. Auth became a load-bearing side benefit.
ttyd is locked to one command per process, forcing a kill/spawn cycle on every session switch. This spawned increasing layers of complexity — process pools, port range allocation (7682-7701), PID file management, terminal instance caches, sleep delays, lsof fallbacks — all patching symptoms of the wrong architecture.
The "must be pure Python" constraint that drove this design doesn't actually exist. Python packages can include platform-specific compiled binaries via wheels — ruff (Rust), cmake (C++), and the Zig compiler all ship this way via `uv tool install`.
## Approach
**Approach A (chosen): Go terminal multiplexer binary, Python keeps the dashboard.**
A small, purpose-built Go binary (`muxterm`) owns the entire terminal data path. One process, one WebSocket hop, direct PTY ownership via `creack/pty`. Session switching is server-side: muxterm holds a PTY per attached session and routes the active one to the WebSocket. No ttyd. No proxy.
Python keeps what it's good at: the dashboard API, session enumeration, settings, federation orchestration, poll loop.
**Rejected alternatives:**
- **Approach B (Python owns PTYs directly):** Single language, simplest distribution. But Python is still in every byte's path. `asyncio` PTY I/O works but isn't great under heavy output. Signal handling and PTY lifecycle in Python requires careful work. Fighting the language for a systems task.
- **Approach C (expose ttyd directly, bypass Python):** Eliminates the relay, but ttyd is still one-command-per-process. Session switching still requires process lifecycle management. Moves the complexity rather than eliminating it.
## Architecture
Replace the terminal data path only. The Python dashboard/API stays.
**What changes:** ttyd + Python WS proxy → muxterm (Go binary), xterm.js → ghostty-web
**What doesn't change:** Python FastAPI (dashboard, API, settings, federation, session enumeration, poll loop), frontend JS (app.js, sidebar, grid, views, settings, style.css), sessions.py, index.html (SPA shell)
```
UNCHANGED: Browser ←HTTP→ Python FastAPI (dashboard, API, settings, federation)
CHANGED: Browser ←WS→ muxterm (Go) ←PTY→ tmux attach
```
Full stack:
```
Browser Server
┌──────────────────┐ ┌─────────────────────────────┐
│ ghostty-web │ │ muxterm (Go) │
│ (WASM parser) │◄──── WS ──► PTY pool (creack/pty) │
│ + Canvas render │ │ session routing │
│ │ │ control protocol │
└──────────────────┘ └─────────────────────────────┘
┌──────────────────┐ │ process mgmt only
│ app.js │ ┌────────┴────────────────────┐
│ (dashboard, │◄── HTTP ──►│ Python FastAPI │
│ sidebar, grid, │ │ dashboard API, settings, │
│ settings) │ │ session enum, federation, │
└──────────────────┘ │ auth, poll loop │
└─────────────────────────────┘
```
Python starts muxterm as a managed subprocess on startup (one process, started once). muxterm listens on a localhost TCP port (e.g., 7682 — recycling the old ttyd port). The browser connects directly to muxterm's WebSocket endpoint after obtaining a short-lived auth token from Python. Python is never in the terminal data path — not even for the initial WebSocket handshake.
## Components
### muxterm (Go binary)
muxterm manages a map of sessions → PTYs. Each entry:
```
session "dev-server" → {
pty_fd: file descriptor (master side of PTY pair)
process: child process handle (tmux attach -t dev-server)
size: {cols, rows}
}
```
**Lifecycle:**
- **Attach:** Client requests session → muxterm checks the map. If no entry, calls `pty.Start(exec.Command("tmux", "attach", "-t", session_name))` → stores fd + process. If entry exists and process is alive, reuses it. ~1ms cold start.
- **Detach:** When the last WebSocket disconnects from a session, the PTY stays alive (tmux is still attached). Next connection reuses it instantly.
- **Cleanup:** On tmux session death (process exits), muxterm removes the map entry and closes the fd. Detected via process wait.
**Session switching over one WebSocket:** Binary frames for terminal I/O (hot path), text frames for control messages (cold path). When the browser sends an attach command, muxterm swaps which PTY fd the relay goroutines read/write. The old PTY stays alive in the map. Switch time: a pointer swap — microseconds.
**Size:** ~500-1000 lines of Go. Dependencies: `creack/pty` for PTY management, a WebSocket library (gorilla or nhooyr) for the browser-facing endpoint.
### ghostty-web (frontend terminal)
Drop-in replacement for xterm.js. Same `new Terminal()` API. Uses libghostty-vt compiled to WebAssembly — the same VT parser / terminal state machine that runs the native Ghostty desktop terminal. Battle-tested, fuzz-tested, 3+ years of production use.
**What it provides:** Terminal state management, VT/ANSI/XTERM escape sequence parsing, cursor position tracking, styles, text reflow, scrollback, Unicode/grapheme handling, Kitty keyboard protocol, mouse tracking.
**What it doesn't provide:** PTY management, networking, WebSocket — those are muxterm's job.
**Bundle size:** ~400KB WASM.
### Python FastAPI (unchanged role)
Keeps all non-terminal responsibilities:
- Dashboard API (session list, delete, rename, state)
- Settings management
- Federation orchestration
- Session enumeration and snapshot polling
- Auth cookie/bearer/localhost management
- Static file serving (SPA)
- muxterm process supervision (start, restart on crash)
## Data Flow
### Terminal I/O (hot path)
```
Keystroke → ghostty-web → WS binary frame → muxterm → write(pty_fd) → tmux
tmux output → read(pty_fd) → muxterm → WS binary frame → ghostty-web → render
```
One hop. Raw bytes. No relay, no framing overhead, no Python in the path.
### Session switch
1. User clicks sidebar tile → `app.js` sends `ws.send(JSON.stringify({attach: "dev-server"}))`
2. muxterm receives text frame, parses JSON
3. muxterm checks PTY map — spawns `tmux attach -t dev-server` if needed (~1ms), reuses existing PTY if present (0ms)
4. muxterm sends SIGWINCH to the new PTY → tmux repaints the full screen
5. muxterm swaps relay goroutines to the new PTY fd
6. muxterm sends `{"attached": "dev-server"}` text frame
7. Browser receives confirmation, then full screen repaint arrives as normal binary terminal data
8. ghostty-web renders it — user sees current state of the session immediately
No explicit "screen dump" mechanism. Lean on tmux's native redraw behavior (same as resizing a real terminal).
### Auth flow
1. Browser requests `GET /api/terminal-token` from Python FastAPI
2. Python runs auth check (cookie/bearer/localhost — same as today)
3. If authorized, returns a short-lived HMAC token (30-second TTL, shared secret with muxterm)
4. Browser opens WebSocket directly to muxterm's port, including the token
5. muxterm validates the HMAC locally (no callback to Python) and accepts the connection
6. Python is never in the terminal data path — not even for the initial handshake
muxterm validates tokens via HMAC with a shared secret configured at startup. No network calls to Python during connection.
## Wire Protocol
Two frame types over one WebSocket. WebSocket natively distinguishes text and binary frame types — no additional framing needed.
### Binary frames — terminal I/O (hot path)
- **Client → server:** Raw bytes from keyboard, sent directly to active PTY
- **Server → client:** Raw bytes from PTY, sent directly to ghostty-web
- No type prefix byte, no envelope. Raw bytes in, raw bytes out. Zero overhead per frame.
### Text frames — control messages (cold path)
**Client → Server:**
| Message | Purpose |
|---------|---------|
| `{"attach": "session-name"}` | Switch active PTY |
| `{"resize": {"cols": 120, "rows": 40}}` | Resize active PTY |
| `{"detach": true}` | Detach (keep PTY alive) |
**Server → Client:**
| Message | Purpose |
|---------|---------|
| `{"attached": "session-name"}` | Confirm switch complete |
| `{"error": "session not found"}` | Attach failed |
| `{"exited": "session-name"}` | tmux session died, PTY closed |
### Design rationale (informed by Zellij)
Zellij uses two separate WebSocket connections for terminal data and control. They noted this separation "ended up not being necessary" because their application layer already batches messages efficiently. Our single-channel approach with binary/text frame types achieves the same logical separation without managing two connections.
The SIGWINCH-based screen sync on session switch also comes from studying Zellij's approach — instead of building a screen dump mechanism, leverage tmux's native redraw behavior.
## Error Handling
| Failure | Response |
|---------|----------|
| `tmux attach` fails (session doesn't exist) | Send `{"error": "session not found"}` over control channel. Browser shows toast. |
| PTY process exits (tmux session killed) | Send `{"exited": "session-name"}`. Browser returns to dashboard grid. Clean up map entry. |
| muxterm crashes | Python detects child exit, restarts muxterm. Browser WS reconnect fires, reattaches. PTYs die with the process, but tmux sessions survive — new PTYs created on reattach. |
| WebSocket drops (network) | Browser reconnects, sends `{"attach": "last-session"}`. muxterm reuses existing PTY. Seamless. |
| Python crashes | muxterm keeps running (independent process). Terminal stays live. Dashboard API down until Python restarts, but terminal is uninterrupted. |
**Key resilience property:** tmux sessions are the source of truth. Everything else (PTYs, WebSockets, muxterm, Python) is ephemeral and reconstructable. Nothing persists that can get stale or corrupt.
## Distribution & Lifecycle
### muxterm binary
Built as a Go binary, cross-compiled for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64. Bundled into the Python package as platform-specific wheels (same pattern as ruff). `uv tool install muxplex` gets you everything — Python dashboard + Go terminal server.
### ghostty-web
Vendored into `muxplex/frontend/vendor/` alongside existing vendor pattern. WASM bundle is ~400KB. xterm.js files get removed.
### Process lifecycle
- Python FastAPI starts muxterm as a managed subprocess on startup (one process, started once)
- muxterm listens on a localhost TCP port (e.g., 7682)
- If muxterm dies, Python detects it and restarts it (simple process supervision)
- On shutdown, Python sends SIGTERM to muxterm, which gracefully closes all PTYs
- PTYs are just file descriptors — tmux sessions survive muxterm restarts (tmux is independent)
### What this replaces
- No `kill_orphan_ttyd()` — single known process, Python holds the handle
- No port range allocation — single fixed port, not a dynamic pool
- No PID file management — Python holds the process handle directly
## What Gets Deleted
- `ttyd.py` — all of it (process pool, PID files, port allocation, kill/spawn)
- Python WS proxy in `main.py` (`terminal_ws_proxy`, `_ttyd_is_listening`, auto-spawn logic)
- `POST /api/sessions/{name}/connect` endpoint — muxterm handles attach directly
- Terminal cache in `terminal.js` (no need for multiple xterm instances)
- ttyd as a system dependency
- xterm.js vendor files (replaced by ghostty-web)
## What Gets Added
- `muxterm/` — Go module, small binary (~500-1000 lines)
- ghostty-web npm package vendored into `frontend/vendor/`
## Testing Strategy
### muxterm (Go)
- Unit tests for PTY map operations (attach, detach, cleanup on process exit)
- Unit tests for wire protocol parsing (control message JSON, binary frame routing)
- Integration tests: spawn real tmux sessions, attach via WebSocket, verify I/O round-trip
- Stress test: rapid session switching, concurrent connections
### Frontend
- Verify ghostty-web drop-in compatibility (same Terminal API as xterm.js)
- Test session switch flow: send attach control message, verify screen repaint
- Test error paths: attach to non-existent session, handle exited notification
### Integration
- End-to-end: browser → Python auth → muxterm → tmux, verify terminal interactivity
- Failure scenarios: kill muxterm mid-session (verify Python restarts it, browser reconnects)
- Verify WebSocket drop recovery (disconnect network, reconnect, verify session resumes)
## Migration Path
Migration is incremental, not a big bang. Two independent phases:
**Phase 1 — ghostty-web swap:** Replace xterm.js with ghostty-web in `frontend/vendor/`. Update script tags in `index.html`. API-compatible drop-in. Existing ttyd+proxy architecture stays. Immediate benefit: better VT parsing, fewer rendering bugs.
**Phase 2 — muxterm Go binary:** Build the Go binary, add it to the Python package, update Python startup to launch it, wire the WS endpoint. Delete `ttyd.py`, the Python WS proxy, the terminal cache, the process pool.
Either phase can ship and be tested independently.
## Design Decisions & Rationale
1. **Go for the terminal binary, not Rust:** Go's goroutines are purpose-built for the "copy bytes between two fds" pattern. `creack/pty` is mature. Cross-compilation is trivial. The binary is ~500-1000 lines — Go's simplicity is a feature at this scale.
2. **Single WebSocket with binary/text frame separation, not dual channels (Zellij pattern):** Zellij uses two separate WebSocket connections for terminal data and control. They noted the separation "ended up not being necessary." Our frame-type approach achieves the same logical separation without managing two connections.
3. **SIGWINCH for screen sync on session switch:** Instead of building a screen dump mechanism, leverage tmux's native redraw (triggered by SIGWINCH). This is exactly what happens when you resize a terminal — tmux repaints everything.
4. **Token-based auth, not socket relay:** Python issues short-lived HMAC tokens; the browser connects directly to muxterm. Eliminates any Python relay (even at the socket level). muxterm validates tokens locally with a shared secret — no callback to Python during connection. This is the cleanest "Python out of the data path" design.
5. **ghostty-web over xterm.js:** API-compatible drop-in with better VT100 parsing via WASM. The terminal industry is moving this direction (Coder's ghostty-web has 2.5K stars). Reduces parser bugs that are outside our control.
## Open Questions
- ~~**Socket handoff mechanism (RESOLVED):**~~ muxterm owns its own WebSocket listener on a localhost TCP port. Python is never in the terminal data path. Auth works via short-lived HMAC tokens: Python's `/api/terminal-token` endpoint authenticates the user (cookie/bearer/localhost) and returns a 30-second HMAC token. The browser includes this token when connecting to muxterm's WebSocket. muxterm validates the HMAC locally (shared secret with Python, no callback). This eliminates any form of Python relay or socket splicing.
- **ghostty-web maturity:** How stable is the API surface? Are there xterm.js addon equivalents (search, web links, fit) or do those need custom implementation?
- **WASM bundle caching:** Does the ~400KB ghostty-web WASM bundle cache well across page loads, or does it need explicit Cache-Control headers?
- **muxterm port:** Should the localhost port be configurable (env var / settings), or is a fixed default (e.g., 7682) sufficient?
- **Federation implications:** Remote muxplex instances currently proxy through the Python WS relay. Does muxterm need its own federation-aware mode, or does Python remain the federation proxy (now forwarding to remote muxterm instances)?
+1
View File
@@ -155,6 +155,7 @@ _STATIC_EXTENSIONS = {
".woff2", ".woff2",
".ttf", ".ttf",
".map", ".map",
".wasm",
} }
# Socket-level localhost addresses — cannot be forged via HTTP headers # Socket-level localhost addresses — cannot be forged via HTTP headers
+1 -21
View File
@@ -436,24 +436,6 @@ def doctor() -> None:
else: else:
print(" Install: sudo apt install tmux") print(" Install: sudo apt install tmux")
# ttyd
ttyd_path = shutil.which("ttyd")
if ttyd_path:
try:
result = subprocess.run(
["ttyd", "--version"], capture_output=True, text=True, timeout=5
)
ttyd_version = result.stdout.strip() or result.stderr.strip()
print(f" {ok_mark} ttyd {ttyd_version}")
except Exception:
print(f" {ok_mark} ttyd (version unknown)")
else:
print(f" {fail_mark} ttyd — not found")
if sys.platform == "darwin":
print(" Install: brew install ttyd")
else:
print(" Install: sudo apt install ttyd")
# muxplex version + install source + update check # muxplex version + install source + update check
try: try:
from importlib.metadata import version as pkg_version # noqa: PLC0415 from importlib.metadata import version as pkg_version # noqa: PLC0415
@@ -636,14 +618,12 @@ def doctor() -> None:
def _check_dependencies() -> None: def _check_dependencies() -> None:
"""Verify required external programs are installed. """Verify required external programs are installed.
Checks for tmux and ttyd. Prints a helpful error message and exits with Checks for tmux. Prints a helpful error message and exits with
code 1 if any are missing. code 1 if any are missing.
""" """
missing = [] missing = []
if shutil.which("tmux") is None: if shutil.which("tmux") is None:
missing.append(("tmux", "sudo apt install tmux / brew install tmux")) missing.append(("tmux", "sudo apt install tmux / brew install tmux"))
if shutil.which("ttyd") is None:
missing.append(("ttyd", "sudo apt install ttyd / brew install ttyd"))
if missing: if missing:
print("\n ERROR: Required dependencies not found:\n", file=sys.stderr) print("\n ERROR: Required dependencies not found:\n", file=sys.stderr)
+566
View File
@@ -0,0 +1,566 @@
# muxplex Frontend Recovery Plan
**Date:** 2026-05-27
**Scope:** Stabilize the broken Lit migration; restore all core user workflows
---
## 1. Correction of Errors (COE)
### What happened
Five commits were applied in sequence without live-testing the app between phases:
```
b9fe3d7 docs: add AGENTS.md
8687d72 Phase 1 Lit migration (session-tile, session-grid, sidebar-item, toast, pill, status)
e0c540b Phase 2 (hover-preview, bottom-sheet-switcher, search-filter)
9c86cf0 Phase 3 (view-dropdown, store.js)
b1f4569 P0 UX improvements (visual hierarchy, readable previews, search filter, default session)
```
Each phase introduced Lit web components to replace innerHTML-based rendering, but:
1. **No manual browser testing between phases.** The test suite (1306 tests, all passing)
validates JavaScript function contracts in a Node.js harness -- it cannot catch DOM
rendering regressions, CSS layout breaks, or WebSocket lifecycle issues that only
manifest in a real browser.
2. **Dual data paths were left alive.** Old `buildTileHTML()` and `buildSidebarHTML()`
remain in app.js for test compatibility, but the browser runs Lit components. Tests
validate the dead code path. The live path has zero automated coverage.
3. **Incomplete component wiring.** `view-dropdown.js` fires custom events
(`view-new-input`) but `app.js` still queries for `[data-action="new-view"]` which
doesn't exist in the Lit-rendered DOM. The sidebar dropdown tries to find
`#sidebar-view-dropdown-trigger` which was replaced by an anonymous button inside
the Lit component.
4. **Dead store.** `store.js` (`AppStore extends EventTarget`) was imported but never
wired to any component or to app.js. It sits in memory doing nothing, creating the
false impression of a reactive data layer.
5. **Behavioral drift in tile rendering.** Snapshot line count changed from 20 to 12
with no rationale or CSS compensation. Preview tiles show less terminal context.
### Root cause
**The migration was done by a code-generation agent that could run tests but never
opened the app in a browser.** All breakages are invisible to the test harness because
the test harness doesn't render DOM, doesn't connect WebSockets, and doesn't layout CSS.
### Lessons
- Test suite passing != app working. For UI work, browser verification is mandatory.
- Lit migrations must be atomic per component: migrate, test in browser, commit.
Not "4 phases batch-committed."
- Dead code paths that tests validate against must be removed or tests updated.
They create a false sense of safety.
---
## 2. Application State Machine
### Top-Level View States
```
┌──────────────────────────┐
│ │
│ DASHBOARD (grid) │
│ _viewMode = 'grid' │
│ │
│ ┌──────────────────┐ │
│ │ session-grid │ │
│ │ ├ session-tile │ │
│ │ ├ session-tile │ │
│ │ └ session-tile │ │
│ └──────────────────┘ │
└────────────┬─────────────┘
click tile / restore state
openSession(name, opts)
┌──────────────────────────┐
│ │
│ TERMINAL (fullscreen) │
│ _viewMode = 'fullscreen' │
│ │
│ ┌────────┬──────────┐ │
│ │sidebar │ terminal │ │
│ │ │ container │ │
│ │ item │ │ │
│ │ item │ xterm.js │ │
│ │ item │ │ │
│ └────────┴──────────┘ │
└────────────┬─────────────┘
back btn / Escape / closeSession()
DASHBOARD (grid)
```
### Navigation State Transitions
```
┌─────────────┐ openSession() ┌──────────────┐ sidebar-select ┌──────────────┐
│ DASHBOARD │ ───────────────► │ TERMINAL │ ──────────────► │ TERMINAL │
│ (grid) │ │ (session A) │ │ (session B) │
└─────────────┘ ◄─────────────── └──────────────┘ └──────┬───────┘
closeSession() ▲ │
│ sidebar-select │
└──────────────────────────────────┘
```
### WebSocket Connection Lifecycle
```
openSession(name)
POST /api/sessions/{name}/connect ← spawns ttyd
window._openTerminal(name, remoteId, fontSize)
createTerminal(fontSize)
connectWebSocket(name, remoteId)
new WebSocket(url, ['tty'])
┌────────┴────────┐
│ │
onopen onerror
│ │
▼ ▼
send auth + dims console.warn
hide reconnect (falls through
overlay to onclose)
focus terminal
onmessage ◄──── continuous data ────┐
│ │
▼ │
_term.write(data) │
reset reconnect counter on first msg │
│ │
└────────────────────────────────┘
onclose
┌─ _currentSession null? ─── yes ──► DONE (intentional close)
no
show reconnect overlay
_reconnectAttempts++
delay = min(1s * 2^(n-1), 15s) + jitter
├── attempts < 2 ──► direct reconnect (new WebSocket)
└── attempts >= 2 ──► POST /connect to respawn ttyd
wait 800ms
then new WebSocket
```
### Poll Loop Data Flow
```
every 2 seconds
GET /api/sessions (or /api/federation/sessions)
_currentSessions = response
┌────────┼─────────┬──────────────┐
│ │ │ │
▼ ▼ ▼ ▼
renderGrid renderSidebar updatePill updateTitle
│ │ updateFavicon
│ │
▼ ▼
set props create/update
on <sidebar-item>
<session-grid> elements
Lit re-renders
<session-tile>
via repeat()
```
---
## 3. User Storyboards
### Storyboard 1: Dashboard Load (cold start)
```
USER APP SERVER
│ │ │
│ navigate to / │ │
│ ──────────────────────────────► │ │
│ │ GET /api/settings │
│ │ ─────────────────────────────►
│ │ ◄───── settings JSON ───────│
│ │ │
│ │ GET /api/state │
│ │ ─────────────────────────────►
│ │ ◄── { active_session } ─────│
│ │ │
│ │ GET /api/sessions │
│ ◄── grid renders with tiles ── │ ◄── [session objects] ──────│
│ (snapshot previews, │ │
│ activity badges, │ start 2s poll loop │
│ timestamps) │ start 5s heartbeat │
```
### Storyboard 2: Open Terminal from Dashboard
```
USER APP SERVER
│ │ │
│ click session tile "dev" │ │
│ ──────────────────────────────► │ │
│ │ tile-click event bubbles │
│ │ openSession("dev") │
│ │ │
│ ◄── tile zoom animation ───── │ POST /sessions/dev/connect │
│ tile expands to viewport │ ─────────────────────────────►
│ │ ◄── 200 (ttyd spawned) ────│
│ │ │
│ ◄── view swap: grid hidden ── │ _openTerminal("dev") │
│ terminal view visible │ new WebSocket → ttyd │
│ │ │
│ ◄── terminal ready, │ ◄── ws data stream ─────────│
│ cursor blinking │ │
│ │ sidebar renders with │
│ ◄── sidebar shows sessions ── │ current session highlighted │
```
### Storyboard 3: Switch Sessions via Sidebar
```
USER APP SERVER
│ │ │
│ click sidebar item "prod" │ │
│ ──────────────────────────────► │ │
│ │ sidebar-select event │
│ │ openSession("prod") │
│ │ │
│ │ _closeTerminal() on "dev" │
│ │ (sets _currentSession=null) │
│ │ (ws.close, no reconnect) │
│ │ │
│ │ POST /sessions/prod/connect │
│ │ ─────────────────────────────►
│ ◄── terminal switches to │ _openTerminal("prod") │
│ "prod" session │ new WebSocket → ttyd │
│ │ │
│ ◄── sidebar updates, │ renderSidebar marks "prod" │
│ "prod" highlighted │ as active │
```
### Storyboard 4: Return to Dashboard
```
USER APP SERVER
│ │ │
│ click back button / Escape │ │
│ ──────────────────────────────► │ │
│ │ closeSession() │
│ │ _closeTerminal() │
│ │ (ws closed, no reconnect) │
│ │ │
│ │ PATCH /api/state │
│ │ { active_session: null } │
│ │ ─────────────────────────────►
│ │ │
│ ◄── grid view restored ────── │ view-expanded hidden │
│ with fresh poll data │ view-overview visible │
```
### Storyboard 5: Connection Lost / Reconnect
```
USER APP SERVER
│ │ │
│ (network hiccup or ttyd dies) │ │
│ │ ws.onclose fires │
│ │ _currentSession != null │
│ ◄── "Reconnecting..." overlay │ show #reconnect-overlay │
│ │ attempt++ = 1 │
│ │ wait 1s + jitter │
│ │ new WebSocket (attempt 1) │
│ │ │
│ (if fails again) │ attempt++ = 2 │
│ │ POST /sessions/x/connect │
│ │ ────────────────────────────►│
│ │ (respawn ttyd) │
│ │ wait 800ms │
│ │ new WebSocket (attempt 2) │
│ │ │
│ ◄── overlay disappears ────── │ ws.onopen → hide overlay │
│ terminal resumes │ first data → reset counter │
```
---
## 4. Lit Data Model: What It Should Be
### Current Architecture (broken hybrid)
```
app.js (module-level vars) components/ (Lit)
┌──────────────────────┐ ┌──────────────────┐
│ _currentSessions │───set───► │ session-grid │
│ _viewingSession │ props │ .sessions │
│ _viewMode │ │ .visibleSessions│
│ _serverSettings │ │ │
│ _activeView │───set───► │ sidebar-item (N) │
│ _gridViewMode │ props │ .session │
│ _pollFailCount │ │ .active │
│ │ │ │
│ startPolling() │ │ view-dropdown │
│ openSession() │ │ .views │
│ closeSession() │ │ .activeView │
│ bindStaticListeners()│ │ │
└──────────────────────┘ │ store.js ← DEAD │
└──────────────────┘
Data flows ONE WAY: app.js → component props (push)
Events flow BACK: component events → app.js handlers (bubble)
```
### What's Wrong
1. **No single source of truth.** State lives in app.js module variables.
Components receive props from `renderGrid()` / `renderSidebar()` calls.
There's no store, no subscription. If a poll renders the grid but
something prevents renderSidebar(), the sidebar is stale.
2. **store.js exists but is unused.** It was meant to be the reactive layer
but nothing writes to it or reads from it.
3. **renderSidebar() clears and rebuilds every poll.** Line 961:
`list.innerHTML = ''; list.appendChild(fragment);` -- this destroys all
<sidebar-item> elements and recreates them every 2 seconds, losing
scroll position, hover state, and any transient UI state.
4. **Components can't self-update.** Since there's no store subscription,
components are purely passive. They render when app.js pushes props.
This means any bug in the push path = stale component.
### Target Architecture (stabilized, minimal change)
**Phase 1: Stabilize (this plan).** Don't introduce the store yet. Fix the
broken wiring so the existing push-props architecture works correctly.
```
app.js (owner of all state)
├── pollSessions()
│ └── _currentSessions = response
│ ├── renderGrid() → set props on <session-grid>
│ ├── renderSidebar() → DIFF <sidebar-item> elements (don't clear+rebuild)
│ ├── updatePill() → set props on <session-pill>
│ └── updateTitle()
├── openSession(name)
│ ├── _viewMode = 'fullscreen'
│ ├── _viewingSession = name
│ ├── view DOM swap
│ ├── _openTerminal(name)
│ └── renderSidebar()
└── closeSession()
├── _viewMode = 'grid'
├── _viewingSession = null
├── _closeTerminal()
└── view DOM swap
```
**Phase 2 (future): Introduce reactive store.**
```
store.js (EventTarget, single source of truth)
├── .sessions ← written by pollSessions()
├── .viewMode ← written by openSession/closeSession
├── .activeSession ← written by openSession/closeSession
├── .activeView ← written by view switcher
├── .settings ← written by loadServerSettings()
└── .on('sessions', fn) ← components subscribe
.on('viewMode', fn)
.on('activeSession', fn)
Components subscribe to store keys and self-update.
app.js becomes a thin orchestrator: event handlers + store.set().
```
Phase 2 is NOT this plan. This plan is about making Phase 1 work.
---
## 5. Diagnosed Issues + Fix Plan
### CRITICAL: Issues causing user-visible breakage NOW
#### C1. Sidebar clear+rebuild every poll (causes flicker, scroll loss, layout jank)
**File:** `app.js:961` -- `list.innerHTML = ''; list.appendChild(fragment);`
**Problem:** Every 2-second poll destroys all `<sidebar-item>` elements and
creates new ones. This causes layout reflow, loses scroll position, may cause
the "tiles sized incorrectly" report if a render cycle catches mid-layout.
**Fix:** Diff existing elements by session key. Update props on existing
elements; only add/remove what actually changed. The keyed map is already
built (`existingItems`), the clear+rebuild at the end undoes the work.
**Original pattern:** Original app.js also used innerHTML, but it was a
single string assignment -- no element lifecycle to disrupt.
#### C2. Sidebar <sidebar-item> uses `createRenderRoot() { return this; }` (light DOM)
**File:** `sidebar-item.js:38`
**Problem:** Light DOM means the component renders directly into itself. The
existing CSS `.sidebar-item { height: 120px; }` should apply. But the Lit
element ITSELF (`<sidebar-item>`) is an extra wrapper element that the
original CSS didn't account for. The CSS targets `.sidebar-item` (the article
inside), but the `<sidebar-item>` custom element wrapper has no explicit
`display` property, defaulting to `display: inline`. An inline wrapper
around a 120px-height block article creates layout havoc.
**Fix:** Add `sidebar-item { display: contents; }` or
`sidebar-item { display: block; }` to style.css. Same for `session-tile`.
#### C3. <session-tile> wrapper element also lacks display rule
**File:** `session-tile.js` (light DOM)
**Problem:** Same as C2. The `<session-tile>` custom element wrapping the
`<article class="session-tile">` has no CSS display rule. Grid layout
(`display: grid` on `.session-grid`) expects direct children to be grid
items. With `<session-tile>` as an inline wrapper, grid sizing breaks.
`<session-grid>` itself also needs a display rule.
**Fix:** Add CSS:
```css
session-grid { display: contents; }
session-tile { display: contents; }
sidebar-item { display: contents; }
```
Or alternatively `display: block` if contents causes other issues.
This is THE most likely cause of the broken grid layout and sidebar sizing.
#### C4. view-dropdown "New View" creation dead
**File:** `app.js:1131` queries `[data-action="new-view"]`, `view-dropdown.js`
doesn't render that attribute.
**Fix:** Add `data-action="new-view"` to the button in view-dropdown.js, OR
have app.js listen for the `view-new-input` custom event instead.
#### C5. Sidebar dropdown close references non-existent element
**File:** `app.js:1227` queries `$('sidebar-view-dropdown-trigger')` which
doesn't exist (now rendered anonymously inside `<view-dropdown>`).
**Fix:** Use the `<view-dropdown>` element's `.open` property instead.
### HIGH: Correctness issues
#### H1. Snapshot line count reduced (20 → 12) without rationale
**File:** `session-tile.js:82`, `sidebar-item.js:71`
**Problem:** Tiles show ~40% less terminal context. User reports "preview
thumbnail things missing."
**Fix:** Restore to 20 lines. The original 20 was tuned for 300px tile height
at the original font size.
#### H2. Dead code: buildTileHTML / buildSidebarHTML still in app.js
**File:** `app.js:516, 580`
**Problem:** Tests validate dead code. Live code is untested.
**Fix:** Remove dead functions. Update tests to validate Lit component
rendering instead, or add integration tests.
#### H3. store.js imported but unused
**File:** `index.html:288`, `components/store.js`
**Fix:** Remove the import. The store is a future concern.
### MEDIUM: Behavioral drift
#### M1. Duplicate utility functions (app.js globals + components/utils.js)
`formatTimestamp`, `sessionPriority`, `ansiToHtml`, etc. exist in both.
**Fix:** Components import from utils.js. Remove duplicates from app.js
and have app.js import from utils.js too, OR keep app.js self-contained
(it's a non-module script, can't import ES modules).
Decision: Keep both for now. app.js is `defer` (not `type=module`).
The utils.js copy is canonical for components; app.js copy is canonical
for itself. Document the constraint.
#### M2. Document-level `.tile-options-btn` delegated handler (dead)
**File:** `app.js:3904`
The old handler still exists but `session-tile._onOptionsClick` calls
`stopPropagation()` so it never fires.
**Fix:** Remove the dead handler.
---
## 6. Fix Execution Order
Priority: restore usability first, clean up second.
| # | Fix | Risk | Est. |
|---|-----|------|------|
| 1 | C3: Add `display: contents` CSS for custom element wrappers | LOW | 5 min |
| 2 | C1: Fix sidebar diff (stop clear+rebuild) | MED | 15 min |
| 3 | H1: Restore 20-line snapshot in tile + sidebar | LOW | 2 min |
| 4 | C4: Fix view-dropdown "New View" wiring | LOW | 5 min |
| 5 | C5: Fix sidebar dropdown close | LOW | 5 min |
| 6 | H3: Remove dead store.js import | LOW | 1 min |
| 7 | M2: Remove dead tile-options-btn handler | LOW | 1 min |
| 8 | H2: Remove dead buildTileHTML/buildSidebarHTML | MED | 10 min |
| 9 | Browser-verify all storyboards 1-5 | -- | 10 min |
**Total estimated: ~55 minutes**
Steps 1-3 should resolve the user's three reported symptoms:
- Grid/sidebar layout broken → C3 (display rules)
- Sidebar sizing wrong → C1 + C3
- Missing preview thumbnails → H1 (line count)
The "constantly hitting reconnect" report needs browser investigation.
terminal.js is unchanged. Possible causes:
- Layout break from C3 may cause terminal-container to have 0 dimensions,
which makes FitAddon report 0 cols/0 rows, which ttyd may reject
- The reconnect overlay may be visible due to CSS stacking issues from
the extra wrapper elements
- Investigate AFTER C3 fix is applied -- it may self-resolve
---
## 7. Verification Criteria
After all fixes, manually verify each storyboard:
- [ ] Dashboard loads, all session tiles visible with terminal previews
- [ ] Tiles show ~20 lines of terminal content, readable
- [ ] Click tile → zoom animation → terminal opens → cursor blinks
- [ ] Terminal receives keystrokes and displays output
- [ ] No "Reconnecting..." overlay unless network is actually down
- [ ] Sidebar shows all sessions, current session highlighted
- [ ] Click different sidebar session → switches correctly
- [ ] Back button / Escape → returns to dashboard
- [ ] Settings dialog opens and closes
- [ ] View dropdown works (switch views, create new view)
- [ ] Mobile: tiles render in list mode, bottom sheet works
- [ ] Test suite still passes: `pytest muxplex/tests/ -x -q --timeout=30`
+372 -541
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
import { unsafeHTML } from '/vendor/lit/lit.min.js';
import { formatTimestamp, sortByPriority } from './utils.js';
import { icons } from './icons.js';
/**
* <bottom-sheet-switcher> - Mobile session-switching overlay.
*
* Properties:
* sessions: Array - session list (will be sorted by priority internally)
* viewingSession: String - name of currently viewed session
* viewingRemoteId: String - remoteId of currently viewed session
* open: Boolean - whether the sheet is visible
*
* Events:
* sheet-select { name, remoteId } - Session selected
* sheet-close - Backdrop/handle dismissed
*/
export class BottomSheetSwitcher extends LitElement {
static properties = {
sessions: { type: Array },
viewingSession: { type: String, attribute: 'viewing-session' },
viewingRemoteId: { type: String, attribute: 'viewing-remote-id' },
open: { type: Boolean, reflect: true },
};
// Light DOM — existing CSS applies
createRenderRoot() { return this; }
constructor() {
super();
this.sessions = [];
this.viewingSession = '';
this.viewingRemoteId = '';
this.open = false;
}
_onBackdropClick() {
this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true }));
}
_onItemClick(name, remoteId) {
if (name !== this.viewingSession || (remoteId || '') !== (this.viewingRemoteId || '')) {
this.dispatchEvent(new CustomEvent('sheet-select', {
bubbles: true, composed: true,
detail: { name, remoteId: remoteId || '' },
}));
}
this.dispatchEvent(new CustomEvent('sheet-close', { bubbles: true, composed: true }));
}
render() {
const sorted = sortByPriority(this.sessions || []);
return html`
<div class="bottom-sheet__backdrop" @click=${this._onBackdropClick}></div>
<div class="bottom-sheet__panel">
<div class="bottom-sheet__handle" aria-hidden="true"></div>
<ul class="bottom-sheet__list" role="listbox" aria-label="Sessions">
${sorted.map(s => {
const hasBell = s.bell && s.bell.unseen_count > 0 &&
(s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at);
const isActive = s.name === this.viewingSession &&
(s.remoteId ?? '') === (this.viewingRemoteId ?? '');
const name = s.name || '';
const remoteId = s.remoteId ?? '';
return html`
<li class="sheet-item ${isActive ? 'sheet-item--active' : ''}"
role="option"
@click=${() => this._onItemClick(name, remoteId)}>
<span class="sheet-item__name">${name}</span>
${hasBell ? html`<span class="sheet-item__bell">${unsafeHTML(icons.bell)}</span>` : nothing}
<span class="sheet-item__time">${formatTimestamp(s.bell && s.bell.last_fired_at)}</span>
</li>`;
})}
</ul>
</div>
`;
}
}
customElements.define('bottom-sheet-switcher', BottomSheetSwitcher);
@@ -0,0 +1,51 @@
import { LitElement, html, css, svg } from '/vendor/lit/lit.min.js';
/**
* <connection-status> - Server connectivity indicator.
*
* Properties:
* level: 'ok' | 'warn' | 'err'
*/
export class ConnectionStatus extends LitElement {
static properties = {
level: { type: String, reflect: true },
};
static styles = css`
:host { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; }
.indicator { display: inline-flex; align-items: center; gap: 4px; }
:host([level="ok"]) .indicator { color: var(--status-ok, #3fb950); }
:host([level="warn"]) .indicator { color: var(--status-warn, #d29922); }
:host([level="err"]) .indicator { color: var(--status-err, #f85149); }
`;
constructor() {
super();
this.level = 'ok';
}
render() {
const content = {
ok: html`
<span class="indicator">
<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>
</span>
`,
warn: html`
<span class="indicator">
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>
slow
</span>
`,
err: html`
<span class="indicator">
<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>
offline
</span>
`,
};
return content[this.level] || content.ok;
}
}
customElements.define('connection-status', ConnectionStatus);
@@ -0,0 +1,98 @@
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
import { unsafeHTML } from '/vendor/lit/lit.min.js';
import { ansiToHtml } from './utils.js';
/**
* <hover-preview> - Full-window snapshot popover for session hover.
*
* Properties:
* sessionName: String - name of the session being previewed
* snapshot: String - raw terminal snapshot text (ANSI)
* open: Boolean - whether the preview is visible
*
* Events:
* preview-click { name } - Fired when user clicks the preview (navigate to session)
* preview-close - Fired when preview should be dismissed
*/
export class HoverPreview extends LitElement {
static properties = {
sessionName: { type: String, attribute: 'session-name' },
snapshot: { type: String },
open: { type: Boolean, reflect: true },
};
static styles = css`
:host { display: none; }
:host([open]) { display: block; }
.preview-popover {
position: fixed;
inset: 48px 12px 12px 12px;
z-index: 300;
background: var(--bg-primary, #0d1117);
border: 1px solid var(--border-muted, #30363d);
border-radius: 8px;
overflow: auto;
padding: 12px;
cursor: default;
pointer-events: none; /* clicks pass through to sidebar tiles below */
}
.preview-popover pre {
margin: 0;
font-family: inherit;
font-size: 12px;
line-height: 1.4;
white-space: pre;
color: var(--fg-default, #c9d1d9);
}
`;
constructor() {
super();
this.sessionName = '';
this.snapshot = '';
this.open = false;
this._boundDocClick = this._onDocumentClick.bind(this);
}
updated(changed) {
if (changed.has('open')) {
if (this.open) {
// Scroll to bottom after render (prompt area)
const popover = this.shadowRoot.querySelector('.preview-popover');
if (popover) popover.scrollTop = popover.scrollHeight;
// Close on any click outside (capture phase, next tick)
setTimeout(() => document.addEventListener('click', this._boundDocClick, true), 0);
} else {
document.removeEventListener('click', this._boundDocClick, true);
}
}
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this._boundDocClick, true);
}
_onDocumentClick(e) {
// Close the preview. Do NOT call stopPropagation() — that would swallow
// clicks intended for sidebar tiles and other elements behind the preview.
// pointer-events:none on .preview-popover lets clicks reach their real
// target; this handler just ensures the overlay is dismissed in sync.
this.open = false;
this.dispatchEvent(new CustomEvent('preview-close', {
bubbles: true, composed: true,
}));
}
render() {
if (!this.open) return html``;
return html`
<div class="preview-popover">
<pre>${unsafeHTML(ansiToHtml(this.snapshot || ''))}</pre>
</div>
`;
}
}
customElements.define('hover-preview', HoverPreview);
+16
View File
@@ -0,0 +1,16 @@
// Inline SVG icon strings shared across components.
// Matches the _icons object in app.js.
export const icons = {
statusOk: '<svg viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><circle cx="8" cy="8" r="4"/></svg>',
statusWarn: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 2"><circle cx="8" cy="8" r="5"/></svg>',
statusErr: '<svg viewBox="0 0 16 16" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
kebab: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg>',
chevronLeft: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg>',
chevronRight: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 3l5 5-5 5"/></svg>',
chevronUp: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg>',
chevronDown: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg>',
check: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 8l4 4 6-7"/></svg>',
bell: '<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/></svg>',
close: '<svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>',
};
@@ -0,0 +1,106 @@
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
export class SearchFilter extends LitElement {
static properties = {
query: { type: String, reflect: true },
placeholder: { type: String },
count: { type: Number },
total: { type: Number },
};
static styles = css`
:host { display: block; }
.filter-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--grid-padding, 16px);
height: 40px;
}
.filter-input {
flex: 1;
max-width: 320px;
padding: 6px 12px;
font-size: 13px;
background: var(--bg-tile, #10131C);
color: var(--text, #F0F6FF);
border: 1px solid var(--border, #2A3040);
border-radius: 6px;
outline: none;
transition: border-color 150ms;
}
.filter-input:focus {
border-color: var(--accent, #00D9F5);
}
.filter-input::placeholder {
color: var(--text-muted, #8E95A3);
}
.filter-count {
font-size: 12px;
color: var(--text-muted, #8E95A3);
}
.filter-clear {
background: none;
border: none;
color: var(--text-muted, #8E95A3);
cursor: pointer;
padding: 4px;
font-size: 12px;
}
.filter-clear:hover { color: var(--text, #F0F6FF); }
`;
constructor() {
super();
this.query = '';
this.placeholder = 'Filter sessions...';
this.count = 0;
this.total = 0;
}
_onInput(e) {
this.query = e.target.value;
this.dispatchEvent(new CustomEvent('filter-change', {
bubbles: true, composed: true,
detail: { query: this.query },
}));
}
_onClear() {
this.query = '';
this.dispatchEvent(new CustomEvent('filter-change', {
bubbles: true, composed: true,
detail: { query: '' },
}));
}
_onKeydown(e) {
if (e.key === 'Escape') {
this._onClear();
e.target.blur();
}
}
render() {
const showCount = this.query && this.query.length > 0;
return html`
<div class="filter-bar">
<input
class="filter-input"
type="text"
.value=${this.query}
placeholder=${this.placeholder}
@input=${this._onInput}
@keydown=${this._onKeydown}
aria-label="Filter sessions"
/>
${showCount ? html`
<span class="filter-count">${this.count} of ${this.total}</span>
<button class="filter-clear" @click=${this._onClear} aria-label="Clear filter">&times;</button>
` : html``}
</div>
`;
}
}
customElements.define('search-filter', SearchFilter);
+147
View File
@@ -0,0 +1,147 @@
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
import { repeat } from '/vendor/lit/lit.min.js';
import { unsafeHTML } from '/vendor/lit/lit.min.js';
import { sortByPriority, escapeHtml } from './utils.js';
import './session-tile.js';
/**
* <session-grid> - Dashboard grid of session tiles.
*
* Owns sorting, grouping, status tiles, and the keyed repeat loop.
* Replaces the renderGrid() function's innerHTML + diff-patch approach.
*
* Properties:
* sessions: Array - raw session list (includes status sentinels)
* visibleSessions: Array - already-filtered visible sessions
* sortOrder: String - 'alphabetical' | 'recent' | 'manual'
* groupMode: String - 'flat' | 'grouped'
* mobile: Boolean - mobile layout
* multiDevice: Boolean - multi-device enabled
* showDeviceBadges: Boolean
* activityIndicator: String
* viewMode: String - 'auto' | 'fit'
*
* Events:
* session-open { name, remoteId }
* session-options { name, remoteId, sessionKey, rect }
*/
export class SessionGrid extends LitElement {
static properties = {
sessions: { type: Array },
visibleSessions: { type: Array },
sortOrder: { type: String, attribute: 'sort-order' },
groupMode: { type: String, attribute: 'group-mode' },
mobile: { type: Boolean },
multiDevice: { type: Boolean, attribute: 'multi-device' },
showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
activityIndicator: { type: String, attribute: 'activity-indicator' },
viewMode: { type: String, attribute: 'view-mode' },
};
// Light DOM so existing CSS grid styles apply
createRenderRoot() { return this; }
constructor() {
super();
this.sessions = [];
this.visibleSessions = [];
this.sortOrder = 'manual';
this.groupMode = 'flat';
this.mobile = false;
this.multiDevice = false;
this.showDeviceBadges = true;
this.activityIndicator = 'both';
this.viewMode = 'auto';
}
get _ordered() {
const visible = this.visibleSessions || [];
if (this.sortOrder === 'alphabetical') {
return visible.slice().sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}
return this.mobile ? sortByPriority(visible) : visible;
}
get _statusTiles() {
return (this.sessions || []).filter(s =>
s.status === 'auth_failed' || s.status === 'unreachable'
);
}
_onTileClick(e) {
this.dispatchEvent(new CustomEvent('session-open', {
bubbles: true, composed: true,
detail: e.detail,
}));
}
_onTileOptions(e) {
this.dispatchEvent(new CustomEvent('session-options', {
bubbles: true, composed: true,
detail: e.detail,
}));
}
_renderTile(s) {
return html`
<session-tile
.session=${s}
?mobile=${this.mobile}
?multi-device=${this.multiDevice}
?show-device-badges=${this.showDeviceBadges}
activity-indicator=${this.activityIndicator}
view-mode=${this.viewMode}
@tile-click=${this._onTileClick}
@tile-options=${this._onTileOptions}
></session-tile>
`;
}
_renderStatusTile(s) {
const label = s.status === 'auth_failed' ? 'Auth required' : 'Offline';
const cls = s.status === 'auth_failed' ? 'auth' : 'offline';
const name = escapeHtml(s.deviceName || 'Unknown');
return html`
<article class="session-tile source-tile--${cls}">
<div class="tile-header">
<span class="tile-name">${name}</span>
<span class="tile-meta source-tile__status">${label}</span>
</div>
</article>
`;
}
render() {
const ordered = this._ordered;
const statusTiles = this._statusTiles;
if (ordered.length === 0 && statusTiles.length === 0) {
return nothing;
}
if (this.groupMode === 'grouped') {
const groups = new Map();
const groupOrder = [];
for (const s of ordered) {
const dn = s.deviceName || 'Unknown';
if (!groups.has(dn)) { groups.set(dn, []); groupOrder.push(dn); }
groups.get(dn).push(s);
}
return html`
${groupOrder.map(gname => html`
<h3 class="device-group-header">${gname}</h3>
${repeat(groups.get(gname), s => s.sessionKey || s.name, s => this._renderTile(s))}
`)}
${statusTiles.map(s => this._renderStatusTile(s))}
`;
}
return html`
${repeat(ordered, s => s.sessionKey || s.name, s => this._renderTile(s))}
${statusTiles.map(s => this._renderStatusTile(s))}
`;
}
}
customElements.define('session-grid', SessionGrid);
@@ -0,0 +1,77 @@
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
/**
* <session-pill> - Floating session-switcher button with bell badge.
*
* Properties:
* label: String - session name to display
* hasBell: Boolean - whether bell badge is visible
* visible: Boolean - whether the pill itself is shown
*
* Events:
* pill-click - Fired when user taps/clicks the pill
*/
export class SessionPill extends LitElement {
static properties = {
label: { type: String },
hasBell: { type: Boolean, attribute: 'has-bell' },
visible: { type: Boolean, reflect: true },
};
static styles = css`
:host { display: none; }
:host([visible]) { display: block; }
.pill {
position: fixed;
bottom: 18px;
right: 18px;
z-index: 200;
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 999px;
background: var(--pill-bg, #21262d);
color: var(--pill-fg, #c9d1d9);
font-size: 13px;
opacity: 0.75;
cursor: pointer;
transition: opacity 0.2s;
}
.pill:hover { opacity: 1; }
.bell {
display: none;
color: var(--bell-color, #d29922);
}
.bell.active { display: inline-flex; }
`;
constructor() {
super();
this.label = '';
this.hasBell = false;
this.visible = false;
}
_onClick() {
this.dispatchEvent(new CustomEvent('pill-click', { bubbles: true, composed: true }));
}
render() {
return html`
<button class="pill" aria-label="Switch session" @click=${this._onClick}>
<span class="label">${this.label}</span>
<span class="bell ${this.hasBell ? 'active' : ''}" aria-hidden="true">
<svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor">
<path d="M8 1a1 1 0 0 1 1 1v.3A4.5 4.5 0 0 1 12.5 7v2.5l1 2H2.5l1-2V7A4.5 4.5 0 0 1 7 2.3V2a1 1 0 0 1 1-1zM6.5 13a1.5 1.5 0 0 0 3 0z"/>
</svg>
</span>
</button>
`;
}
}
customElements.define('session-pill', SessionPill);
+150
View File
@@ -0,0 +1,150 @@
import { LitElement, html, css, nothing } from '/vendor/lit/lit.min.js';
import { unsafeHTML } from '/vendor/lit/lit.min.js';
import { escapeHtml, formatTimestamp, sessionPriority, ansiToHtml } from './utils.js';
import { icons } from './icons.js';
/**
* <session-tile> - Dashboard tile representing a tmux session.
*
* Properties:
* session: Object - session data { name, snapshot, last_activity_at, bell, ... }
* mobile: Boolean - mobile layout mode
* multiDevice: Boolean - multi-device support enabled
* showDeviceBadges: Boolean - whether to show device name badges
* activityIndicator: String - 'none' | 'glow' | 'dot' | 'both'
* viewMode: String - 'auto' | 'fit' (controls snapshot line count)
*
* Events:
* tile-click { name, remoteId } - Session selected
* tile-options { name, remoteId, sessionKey, rect } - Kebab menu opened
*/
export class SessionTile extends LitElement {
static properties = {
session: { type: Object },
mobile: { type: Boolean },
multiDevice: { type: Boolean, attribute: 'multi-device' },
showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
activityIndicator: { type: String, attribute: 'activity-indicator' },
viewMode: { type: String, attribute: 'view-mode' },
};
// Render into light DOM so existing CSS applies without migration.
createRenderRoot() { return this; }
constructor() {
super();
this.session = {};
this.mobile = false;
this.multiDevice = false;
this.showDeviceBadges = true;
this.activityIndicator = 'both';
this.viewMode = 'auto';
}
get _priority() {
return sessionPriority(this.session);
}
get _isBell() {
return this._priority === 'bell';
}
get _name() {
return this.session.name || '';
}
get _sessionKey() {
return this.session.sessionKey || this._name;
}
get _remoteId() {
return this.session.remoteId ?? null;
}
get _classes() {
let cls = 'session-tile';
const ind = this.activityIndicator;
const priority = this._priority;
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' session-tile--bell';
if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' session-tile--edge-bell';
if (priority === 'active') cls += ' session-tile--active';
const activity = this.session && this.session.last_activity_at;
if (!activity || (Date.now() / 1000 - activity) > 3600) cls += ' session-tile--stale';
if (this.mobile) cls += ` session-tile--tier-${priority}`;
return cls;
}
get _snapshotHtml() {
const snapshot = this.session.snapshot || '';
const lineCount = this.viewMode === 'fit' ? -80 : -20;
// Trim trailing blank lines first (see buildTileHTML comment in app.js)
const allLines = snapshot.split('\n');
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
allLines.pop();
}
const lastLines = allLines.slice(lineCount).join('\n');
return ansiToHtml(lastLines);
}
_onTileClick(e) {
// Don't trigger tile-click when clicking the options button
if (e.target.closest('.tile-options-btn')) return;
this.dispatchEvent(new CustomEvent('tile-click', {
bubbles: true, composed: true,
detail: { name: this._name, remoteId: this._remoteId },
}));
}
_onOptionsClick(e) {
e.stopPropagation();
const btn = e.currentTarget;
this.dispatchEvent(new CustomEvent('tile-options', {
bubbles: true, composed: true,
detail: {
name: this._name,
remoteId: this._remoteId,
sessionKey: this._sessionKey,
rect: btn.getBoundingClientRect(),
},
}));
}
render() {
const name = this._name;
const timeStr = formatTimestamp(this.session.last_activity_at || null);
const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges;
return html`
<article
class=${this._classes}
data-session=${name}
data-session-key=${this._sessionKey}
tabindex="0"
role="listitem"
aria-label=${name}
@click=${this._onTileClick}
>
<div class="tile-header">
<span class="tile-name">${name}</span>
${showBadge
? html`<span class="device-badge">${this.session.deviceName}</span>`
: nothing}
<span class="tile-meta">${timeStr}</span>
<button
class="tile-options-btn"
data-session=${name}
aria-label="Session options"
aria-haspopup="true"
@click=${this._onOptionsClick}
>${unsafeHTML(icons.kebab)}</button>
</div>
<div class="tile-body">
<pre>${unsafeHTML(this._snapshotHtml)}</pre>
</div>
</article>
`;
}
}
customElements.define('session-tile', SessionTile);
+134
View File
@@ -0,0 +1,134 @@
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
import { unsafeHTML } from '/vendor/lit/lit.min.js';
import { escapeHtml, sessionPriority, ansiToHtml } from './utils.js';
import { icons } from './icons.js';
/**
* <sidebar-item> - Session entry in the expanded-view sidebar.
*
* Properties:
* session: Object - session data
* active: Boolean - whether this is the currently viewed session
* multiDevice: Boolean - multi-device enabled
* showDeviceBadges: Boolean - show device name badges
* activityIndicator: String - 'none' | 'glow' | 'dot' | 'both'
*
* Events:
* sidebar-select { name, remoteId } - Session clicked
* sidebar-options { name, remoteId, sessionKey, rect } - Kebab menu clicked
*/
export class SidebarItem extends LitElement {
static properties = {
session: { type: Object },
active: { type: Boolean, reflect: true },
multiDevice: { type: Boolean, attribute: 'multi-device' },
showDeviceBadges: { type: Boolean, attribute: 'show-device-badges' },
activityIndicator: { type: String, attribute: 'activity-indicator' },
};
// Light DOM — existing CSS applies
createRenderRoot() { return this; }
constructor() {
super();
this.session = {};
this.active = false;
this.multiDevice = false;
this.showDeviceBadges = true;
this.activityIndicator = 'both';
}
get _name() { return this.session.name || ''; }
get _sessionKey() { return this.session.sessionKey || this._name; }
get _remoteId() { return this.session.remoteId ?? null; }
get _isBell() {
const unseen = this.session.bell && this.session.bell.unseen_count;
return unseen && unseen > 0;
}
get _priority() {
return sessionPriority(this.session);
}
get _classes() {
let cls = 'sidebar-item';
if (this.active) cls += ' sidebar-item--active';
const ind = this.activityIndicator;
const priority = this._priority;
if (this._isBell && (ind === 'glow' || ind === 'both')) cls += ' sidebar-item--bell';
if (this._isBell && (ind === 'dot' || ind === 'both')) cls += ' sidebar-item--edge-bell';
if (priority === 'active') cls += ' sidebar-item--activity';
const activity = this.session && this.session.last_activity_at;
if (!activity || (Date.now() / 1000 - activity) > 3600) cls += ' sidebar-item--stale';
return cls;
}
get _snapshotHtml() {
const snapshot = this.session.snapshot || '';
const allLines = snapshot.split('\n');
while (allLines.length > 0 && allLines[allLines.length - 1].trim() === '') {
allLines.pop();
}
return ansiToHtml(allLines.slice(-20).join('\n'));
}
_onClick(e) {
if (e.target.closest('.tile-options-btn')) return;
this.dispatchEvent(new CustomEvent('sidebar-select', {
bubbles: true, composed: true,
detail: { name: this._name, remoteId: this._remoteId },
}));
}
_onOptionsClick(e) {
e.stopPropagation();
const btn = e.currentTarget;
this.dispatchEvent(new CustomEvent('sidebar-options', {
bubbles: true, composed: true,
detail: {
name: this._name,
remoteId: this._remoteId,
sessionKey: this._sessionKey,
rect: btn.getBoundingClientRect(),
},
}));
}
render() {
const name = this._name;
const remoteIdStr = this._remoteId != null ? String(this._remoteId) : '';
const showBadge = this.multiDevice && this.session.deviceName && this.showDeviceBadges;
return html`
<article
class=${this._classes}
data-session=${name}
data-session-key=${this._sessionKey}
data-remote-id=${remoteIdStr}
tabindex="0"
role="listitem"
@click=${this._onClick}
>
<div class="sidebar-item-header">
<span class="sidebar-item-name">${name}</span>
${showBadge
? html`<span class="device-badge">${this.session.deviceName}</span>`
: nothing}
<button
class="tile-options-btn"
data-session=${name}
aria-label="Session options"
aria-haspopup="true"
@click=${this._onOptionsClick}
>${unsafeHTML(icons.kebab)}</button>
</div>
<div class="sidebar-item-body">
<pre>${unsafeHTML(this._snapshotHtml)}</pre>
</div>
</article>
`;
}
}
customElements.define('sidebar-item', SidebarItem);
+73
View File
@@ -0,0 +1,73 @@
/**
* Minimal reactive store for shared application state.
*
* Uses EventTarget for change notification. Components subscribe via
* store.on('sessions', callback) and unsubscribe via store.off().
*
* Usage:
* import { store } from './store.js';
* store.sessions = [...]; // triggers 'sessions' event
* store.on('sessions', (sessions) => { ... });
*/
class AppStore extends EventTarget {
#state = {
sessions: [],
viewingSession: null,
viewingRemoteId: '',
viewMode: 'grid',
activeView: 'all',
serverSettings: null,
gridViewMode: 'flat',
deviceId: '',
};
/** Get a state value. */
get(key) { return this.#state[key]; }
/** Set a state value and dispatch a change event. */
set(key, value) {
const old = this.#state[key];
if (old === value) return;
this.#state[key] = value;
this.dispatchEvent(new CustomEvent(key, { detail: { value, old } }));
this.dispatchEvent(new CustomEvent('change', { detail: { key, value, old } }));
}
/** Batch-set multiple keys; fires one event per key then one 'batch'. */
batch(updates) {
const changed = [];
for (const [key, value] of Object.entries(updates)) {
const old = this.#state[key];
if (old !== value) {
this.#state[key] = value;
this.dispatchEvent(new CustomEvent(key, { detail: { value, old } }));
changed.push(key);
}
}
if (changed.length > 0) {
this.dispatchEvent(new CustomEvent('batch', { detail: { keys: changed } }));
}
}
/** Subscribe to a state key change. Returns unsubscribe function. */
on(key, fn) {
const handler = (e) => fn(e.detail.value, e.detail.old);
this.addEventListener(key, handler);
return () => this.removeEventListener(key, handler);
}
/** Remove a specific listener. */
off(key, fn) {
this.removeEventListener(key, fn);
}
// Convenience accessors for hot paths
get sessions() { return this.#state.sessions; }
set sessions(v) { this.set('sessions', v); }
get activeView() { return this.#state.activeView; }
set activeView(v) { this.set('activeView', v); }
get serverSettings() { return this.#state.serverSettings; }
set serverSettings(v) { this.set('serverSettings', v); }
}
export const store = new AppStore();
@@ -0,0 +1,55 @@
import { LitElement, html, css } from '/vendor/lit/lit.min.js';
/**
* <toast-notification> - Brief popup message that auto-hides.
*
* Usage:
* const toast = document.querySelector('toast-notification');
* toast.show('Session created');
*/
export class ToastNotification extends LitElement {
static properties = {
_message: { state: true },
_visible: { state: true },
};
static styles = css`
:host { display: block; }
.toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%);
background: var(--toast-bg, #e6edf3);
color: var(--toast-fg, #0d1117);
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
}
.toast.visible { opacity: 1; }
`;
constructor() {
super();
this._message = '';
this._visible = false;
this._timer = null;
}
show(msg, duration = 3000) {
this._message = msg;
this._visible = true;
clearTimeout(this._timer);
this._timer = setTimeout(() => { this._visible = false; }, duration);
}
render() {
return html`<div class="toast ${this._visible ? 'visible' : ''}" role="status" aria-live="polite" aria-atomic="true">${this._message}</div>`;
}
}
customElements.define('toast-notification', ToastNotification);
+208
View File
@@ -0,0 +1,208 @@
// Pure utility functions extracted from app.js
// These have zero DOM coupling and zero global state.
/**
* Escape HTML special characters to safe entities.
* @param {string} str
* @returns {string}
*/
export function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Format a Unix timestamp (seconds) into a relative time string.
* @param {number|null|undefined} ts
* @returns {string}
*/
export function formatTimestamp(ts) {
if (ts == null) return '';
const diff = Math.floor(Date.now() / 1000 - ts);
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
return `${Math.floor(diff / 3600)}h ago`;
}
/**
* Return the priority label for a session object.
* @param {object} session
* @returns {'bell'|'active'|'idle'}
*/
export function sessionPriority(session) {
const bell = session && session.bell;
if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) {
return 'bell';
}
const activity = session && session.last_activity_at;
if (activity && (Date.now() / 1000 - activity) < 300) {
return 'active';
}
return 'idle';
}
/** Priority rank map. Lower = higher priority. */
export const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 };
/**
* Sort sessions by priority (ascending rank). Returns new array.
* @param {object[]} sessions
* @returns {object[]}
*/
export function sortByPriority(sessions) {
return sessions.slice().sort((a, b) => {
const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2;
const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2;
return rankA - rankB;
});
}
/**
* Filter sessions by search query (case-insensitive substring on name).
* @param {object[]} sessions
* @param {string|null} query
* @returns {object[]}
*/
export function filterByQuery(sessions, query) {
if (!query) return sessions;
const q = query.toLowerCase();
return sessions.filter((s) => (s.name || '').toLowerCase().includes(q));
}
/**
* Detect sessions that transitioned to new/increased bell state.
* @param {object[]} prev
* @param {object[]} next
* @returns {string[]}
*/
export function detectBellTransitions(prev, next) {
const prevMap = new Map(
(prev || []).map((s) => [s.sessionKey || s.name, (s.bell && s.bell.unseen_count) || 0]),
);
return (next || [])
.filter((s) => {
const unseen = s.bell && s.bell.unseen_count;
if (!unseen || unseen <= 0) return false;
const key = s.sessionKey || s.name;
const prevCount = prevMap.has(key) ? prevMap.get(key) : 0;
return unseen > prevCount;
})
.map((s) => s.name);
}
/**
* Generate a pseudo-random device ID string.
* @returns {string}
*/
export function generateDeviceId() {
return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10);
}
/**
* Build a heartbeat payload object.
* @param {string} device_id
* @param {string|null} viewing_session
* @param {string} view_mode
* @param {number} last_interaction_at
* @returns {object}
*/
export function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) {
const label =
typeof navigator !== 'undefined' && navigator.userAgent
? navigator.userAgent.slice(0, 50)
: 'unknown';
return { device_id, label, viewing_session, view_mode, last_interaction_at };
}
// ---------------------------------------------------------------------------
// ANSI escape -> HTML span converter (SGR codes only)
// ---------------------------------------------------------------------------
const ANSI_COLORS = [
'#2e3436','#cc0000','#4e9a06','#c4a000','#3465a4','#75507b','#06989a','#d3d7cf',
'#555753','#ef2929','#8ae234','#fce94f','#729fcf','#ad7fa8','#34e2e2','#eeeeec'
];
function ansi256Color(n) {
if (n < 16) return ANSI_COLORS[n];
if (n >= 232) { const g = 8 + (n - 232) * 10; return `rgb(${g},${g},${g})`; }
n -= 16;
const r = Math.floor(n / 36) * 51;
const g2 = Math.floor((n % 36) / 6) * 51;
const b = (n % 6) * 51;
return `rgb(${r},${g2},${b})`;
}
function ansiParamsToStyle(params) {
const styles = [];
let k = 0;
while (k < params.length) {
const p = parseInt(params[k], 10) || 0;
if (p === 0) return 'reset';
if (p === 1) styles.push('font-weight:bold');
else if (p === 2) styles.push('opacity:0.7');
else if (p === 3) styles.push('font-style:italic');
else if (p === 4) styles.push('text-decoration:underline');
else if (p === 7) styles.push('filter:invert(1)');
else if (p === 9) styles.push('text-decoration:line-through');
else if (p >= 30 && p <= 37) styles.push('color:' + ANSI_COLORS[p - 30]);
else if (p === 38 && params[k + 1] === '5') {
styles.push('color:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
k += 2;
}
else if (p === 39) styles.push('color:inherit');
else if (p >= 40 && p <= 47) styles.push('background:' + ANSI_COLORS[p - 40]);
else if (p === 48 && params[k + 1] === '5') {
styles.push('background:' + ansi256Color(parseInt(params[k + 2], 10) || 0));
k += 2;
}
else if (p === 49) styles.push('background:inherit');
else if (p >= 90 && p <= 97) styles.push('color:' + ANSI_COLORS[p - 90 + 8]);
else if (p >= 100 && p <= 107) styles.push('background:' + ANSI_COLORS[p - 100 + 8]);
k++;
}
return styles.length ? styles.join(';') : '';
}
/**
* Convert ANSI SGR escape sequences to HTML spans with inline styles.
* @param {string} raw
* @returns {string}
*/
export function ansiToHtml(raw) {
if (!raw) return '';
let out = '';
let spans = 0;
let i = 0;
const len = raw.length;
while (i < len) {
if (raw[i] === '\x1b' && raw[i + 1] === '[') {
let j = i + 2;
while (j < len && raw[j] !== 'm' && j - i < 20) j++;
if (j < len && raw[j] === 'm') {
const params = raw.substring(i + 2, j).split(';');
const style = ansiParamsToStyle(params);
if (style === 'reset') {
while (spans > 0) { out += '</span>'; spans--; }
} else if (style) {
out += '<span style="' + style + '">';
spans++;
}
i = j + 1;
continue;
}
}
const ch = raw[i];
if (ch === '<') out += '&lt;';
else if (ch === '>') out += '&gt;';
else if (ch === '&') out += '&amp;';
else if (ch === '"') out += '&quot;';
else out += ch;
i++;
}
while (spans > 0) { out += '</span>'; spans--; }
return out;
}
@@ -0,0 +1,184 @@
import { LitElement, html, nothing } from '/vendor/lit/lit.min.js';
import { repeat } from '/vendor/lit/lit.min.js';
/**
* <view-dropdown> - Unified view-switcher dropdown.
*
* Used in BOTH the header and sidebar (replaces the two parallel implementations).
*
* Properties:
* views: Array - user-created views [{name, sessions}, ...]
* activeView: String - currently active view name ('all', 'hidden', or user view)
* sessions: Array - all sessions (for computing counts)
* settings: Object - serverSettings (for filterVisible)
* variant: String - 'header' | 'sidebar' (affects label rendering)
* open: Boolean - whether the menu is visible
*
* Events:
* view-switch { view } - User selected a view
* view-new-input - User clicked "+ New View"
* view-manage { view } - User clicked "Manage [view]..."
* view-manage-all - User clicked "Manage All Views..."
* dropdown-toggle - Trigger clicked
* dropdown-close - Menu should close
*/
export class ViewDropdown extends LitElement {
static properties = {
views: { type: Array },
activeView: { type: String, attribute: 'active-view' },
sessions: { type: Array },
settings: { type: Object },
variant: { type: String },
open: { type: Boolean, reflect: true },
};
// Light DOM -- existing CSS applies
createRenderRoot() { return this; }
constructor() {
super();
this.views = [];
this.activeView = 'all';
this.sessions = [];
this.settings = null;
this.variant = 'header';
this.open = false;
this._showInput = false;
this._boundDocClick = this._onDocumentClick.bind(this);
}
updated(changed) {
if (changed.has('open')) {
if (this.open) {
setTimeout(() => document.addEventListener('click', this._boundDocClick, true), 0);
} else {
document.removeEventListener('click', this._boundDocClick, true);
this._showInput = false;
}
}
}
disconnectedCallback() {
super.disconnectedCallback();
document.removeEventListener('click', this._boundDocClick, true);
}
_onDocumentClick(e) {
if (this.contains(e.target)) return;
if (this._showInput) return;
this.dispatchEvent(new CustomEvent('dropdown-close', { bubbles: true, composed: true }));
}
/** Compute visible session count for a view name. */
_countFor(viewName) {
if (typeof window.filterVisible === 'function') {
return window.filterVisible(this.sessions, this.settings, viewName).length;
}
return (this.sessions || []).filter(s => !s.status).length;
}
get _label() {
if (this.activeView === 'all') return 'All Sessions';
if (this.activeView === 'hidden') return 'Hidden';
return this.activeView;
}
_onTriggerClick() {
this.dispatchEvent(new CustomEvent('dropdown-toggle', { bubbles: true, composed: true }));
}
_onViewClick(viewName) {
this.dispatchEvent(new CustomEvent('view-switch', {
bubbles: true, composed: true,
detail: { view: viewName },
}));
}
_onNewView() {
this._showInput = true;
this.requestUpdate();
this.dispatchEvent(new CustomEvent('view-new-input', { bubbles: true, composed: true }));
}
_onManageView() {
this.dispatchEvent(new CustomEvent('view-manage', {
bubbles: true, composed: true,
detail: { view: this.activeView },
}));
}
_onManageAll() {
this.dispatchEvent(new CustomEvent('view-manage-all', { bubbles: true, composed: true }));
}
_renderMenu() {
if (!this.open) return nothing;
const views = this.views || [];
const allCount = this._countFor('all');
const hiddenCount = this._countFor('hidden');
const isUserView = this.activeView !== 'all' && this.activeView !== 'hidden';
const displayViewName = this.activeView.length > 20
? this.activeView.substring(0, 20) + '\u2026'
: this.activeView;
return html`
<div class="view-dropdown__menu" role="menu" aria-label="Switch view">
<button class="view-dropdown__item ${this.activeView === 'all' ? 'view-dropdown__item--active' : ''}"
role="menuitem" @click=${() => this._onViewClick('all')}>
All Sessions <span class="view-dropdown__count">${allCount}</span>
</button>
${views.length > 0 ? html`<div class="view-dropdown__separator"></div>` : nothing}
${repeat(views.slice(0, 7), v => v.name, v => html`
<button class="view-dropdown__item ${this.activeView === v.name ? 'view-dropdown__item--active' : ''}"
role="menuitem" @click=${() => this._onViewClick(v.name)}>
${v.name} <span class="view-dropdown__count">${this._countFor(v.name)}</span>
</button>
`)}
<div class="view-dropdown__separator"></div>
<button class="view-dropdown__item ${this.activeView === 'hidden' ? 'view-dropdown__item--active' : ''}"
role="menuitem" @click=${() => this._onViewClick('hidden')}>
Hidden <span class="view-dropdown__count">${hiddenCount}</span>
</button>
<div class="view-dropdown__separator view-dropdown__separator--strong"></div>
${isUserView ? html`
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
@click=${this._onManageView}>
Manage \u201c${displayViewName}\u201d\u2026
</button>
` : nothing}
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
@click=${this._onManageAll}>Manage All Views\u2026</button>
<button class="view-dropdown__item view-dropdown__action" role="menuitem"
data-action="new-view"
@click=${this._onNewView}>+ New View</button>
</div>
`;
}
render() {
if (this.variant === 'sidebar') {
return html`
<button class="sidebar-view-trigger"
aria-haspopup="true" aria-expanded=${this.open ? 'true' : 'false'}
@click=${this._onTriggerClick}>
<span>${this._label}</span>
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
</button>
${this._renderMenu()}
`;
}
return html`
<button class="view-dropdown__trigger"
aria-haspopup="true" aria-expanded=${this.open ? 'true' : 'false'}
@click=${this._onTriggerClick}>
<span>${this._label}</span>
<svg class="view-dropdown__caret" aria-hidden="true" viewBox="0 0 16 16" width="10" height="10" fill="currentColor"><path d="M4 6l4 4 4-4z"/></svg>
</button>
${this._renderMenu()}
`;
}
}
customElements.define('view-dropdown', ViewDropdown);
+55 -44
View File
@@ -10,7 +10,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="/vendor/xterm.css" /> <!-- ghostty-web uses canvas rendering, no CSS needed -->
<link rel="stylesheet" href="/style.css" /> <link rel="stylesheet" href="/style.css" />
<title>muxplex</title> <title>muxplex</title>
</head> </head>
@@ -19,44 +19,32 @@
<div id="view-overview" class="view view--active"> <div id="view-overview" class="view view--active">
<header class="app-header"> <header class="app-header">
<h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1> <h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
<div class="view-dropdown" id="view-dropdown"> <view-dropdown id="view-dropdown" variant="header" active-view="all"></view-dropdown>
<button id="view-dropdown-trigger" class="view-dropdown__trigger" aria-haspopup="true" aria-expanded="false" aria-controls="view-dropdown-menu">
<span id="view-dropdown-label">All Sessions</span>
<span class="view-dropdown__caret" aria-hidden="true">&#9662;</span>
</button>
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
</div>
<div class="header-actions"> <div class="header-actions">
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button> <button id="new-session-btn" class="header-btn" aria-label="New session"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">&#9638;</button> <button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto"><svg viewBox="0 0 16 16" width="14" height="14" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg></button>
<button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</button> <button id="settings-btn" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
<span id="connection-status"></span> <connection-status id="connection-status" level="ok"></connection-status>
</div> </div>
</header> </header>
<div id="filter-bar" class="filter-bar"></div> <search-filter id="filter-bar"></search-filter>
<div id="session-grid" class="session-grid" role="list"></div> <session-grid id="session-grid" class="session-grid" role="list"></session-grid>
<div id="empty-state" class="empty-state hidden">No active tmux sessions</div> <div id="empty-state" class="empty-state hidden">No active tmux sessions</div>
</div> </div>
<!-- ── Expanded (terminal) view ──────────────────────────────────────── --> <!-- ── Expanded (terminal) view ──────────────────────────────────────── -->
<div id="view-expanded" class="view hidden"> <div id="view-expanded" class="view hidden">
<header class="expanded-header"> <header class="expanded-header">
<button id="back-btn" class="back-btn" aria-label="Back">&#8592;</button> <button id="back-btn" class="back-btn" aria-label="Back"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg></button>
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">&#9776;</button> <button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M2 4h12M2 8h12M2 12h12"/></svg></button>
<span id="expanded-session-name" class="expanded-session-name"></span> <span id="expanded-session-name" class="expanded-session-name"></span>
<button id="settings-btn-expanded" class="header-btn" aria-label="Settings">&#9881;</button> <button id="settings-btn-expanded" class="header-btn" aria-label="Settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M2.9 2.9l1.4 1.4M11.7 11.7l1.4 1.4M2.9 13.1l1.4-1.4M11.7 4.3l1.4-1.4"/></svg></button>
</header> </header>
<div class="view-body"> <div class="view-body">
<div id="session-sidebar" class="session-sidebar"> <div id="session-sidebar" class="session-sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<div class="sidebar-view-dropdown" id="sidebar-view-dropdown"> <view-dropdown id="sidebar-view-dropdown" variant="sidebar" active-view="all"></view-dropdown>
<button id="sidebar-view-dropdown-trigger" class="sidebar-view-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="sidebar-view-dropdown-menu"> <button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 3L5 8l5 5"/></svg></button>
<span id="sidebar-view-label">All Sessions</span>
<span class="view-dropdown__caret" aria-hidden="true">&#9662;</span>
</button>
<div id="sidebar-view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
</div>
<button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list">&#8249;</button>
</div> </div>
<div id="sidebar-list" class="sidebar-list"></div> <div id="sidebar-list" class="sidebar-list"></div>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -67,24 +55,35 @@
<div id="terminal-search-bar" class="terminal-search-bar hidden"> <div id="terminal-search-bar" class="terminal-search-bar hidden">
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." /> <input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
<span id="terminal-search-count" class="terminal-search-count"></span> <span id="terminal-search-count" class="terminal-search-count"></span>
<button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)">&#9650;</button> <button id="terminal-search-prev" class="terminal-search-btn" title="Previous (Shift+Enter)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10l4-4 4 4"/></svg></button>
<button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)">&#9660;</button> <button id="terminal-search-next" class="terminal-search-btn" title="Next (Enter)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6l4 4 4-4"/></svg></button>
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">&times;</button> <button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)"><svg viewBox="0 0 16 16" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
</div> </div>
<div id="terminal-container" class="terminal-container"></div> <div id="terminal-container" class="terminal-container"></div>
<div id="mobile-toolbar" class="mobile-toolbar hidden">
<div class="mobile-toolbar__scroll">
<button class="mobile-toolbar__key" data-key="Escape">Esc</button>
<button class="mobile-toolbar__key" data-key="Tab">Tab</button>
<button class="mobile-toolbar__key mobile-toolbar__key--modifier" data-modifier="ctrl">Ctrl</button>
<button class="mobile-toolbar__key mobile-toolbar__key--modifier" data-modifier="alt">Alt</button>
<button class="mobile-toolbar__key" data-key="ArrowUp"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 12V4M4 7l4-4 4 4"/></svg></button>
<button class="mobile-toolbar__key" data-key="ArrowDown"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 4v8M4 9l4 4 4-4"/></svg></button>
<button class="mobile-toolbar__key" data-key="ArrowLeft"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 8H4M7 4L3 8l4 4"/></svg></button>
<button class="mobile-toolbar__key" data-key="ArrowRight"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 8h8M9 4l4 4-4 4"/></svg></button>
<button class="mobile-toolbar__key" data-input="|">|</button>
<button class="mobile-toolbar__key" data-input="~">~</button>
<button class="mobile-toolbar__key" data-input="/">/</button>
<button class="mobile-toolbar__key" data-input="-">-</button>
<button class="mobile-toolbar__key" data-input="`">`</button>
</div>
</div>
</div> </div>
</div> </div>
<div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</div> <div id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</div>
</div> </div>
<!-- ── Bottom sheet (session switcher) ────────────────────────────────── --> <!-- ── Bottom sheet (session switcher) ────────────────────────────────── -->
<div id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session"> <bottom-sheet-switcher id="bottom-sheet" class="bottom-sheet hidden" role="dialog" aria-modal="true" aria-label="Switch session"></bottom-sheet-switcher>
<div class="bottom-sheet__backdrop" id="sheet-backdrop"></div>
<div class="bottom-sheet__panel">
<div class="bottom-sheet__handle" aria-hidden="true"></div>
<ul id="sheet-list" class="bottom-sheet__list" role="listbox" aria-label="Sessions"></ul>
</div>
</div>
<!-- —— Manage View panel ———————————————————————————————————————————————————————————————————————————————————— --> <!-- —— Manage View panel ———————————————————————————————————————————————————————————————————————————————————— -->
<div id="manage-view-panel" class="manage-view-panel hidden" role="dialog" aria-modal="true" aria-label="Manage view"> <div id="manage-view-panel" class="manage-view-panel hidden" role="dialog" aria-modal="true" aria-label="Manage view">
@@ -104,23 +103,23 @@
</div> </div>
<!-- ── Session pill (persistent overlay button) ────────────────────────── --> <!-- ── Session pill (persistent overlay button) ────────────────────────── -->
<button id="session-pill" class="session-pill hidden" aria-label="Switch session"> <session-pill id="session-pill"></session-pill>
<span id="session-pill-label" class="session-pill__label"></span>
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">&#128276;</span>
</button>
<!-- ── Mobile FAB (new session) ──────────────────────────────────────────── --> <!-- ── Mobile FAB (new session) ──────────────────────────────────────────── -->
<button id="new-session-fab" class="new-session-fab" aria-label="New session">+</button> <button id="new-session-fab" class="new-session-fab" aria-label="New session"><svg viewBox="0 0 16 16" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v10M3 8h10"/></svg></button>
<!-- ── Hover preview ──────────────────────────────────────────────── -->
<hover-preview id="hover-preview"></hover-preview>
<!-- ── Toast notification ──────────────────────────────────────────────── --> <!-- ── Toast notification ──────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div> <toast-notification id="toast"></toast-notification>
<!-- ── Settings ──────────────────────────────────────────────────────────── --> <!-- ── Settings ──────────────────────────────────────────────────────────── -->
<div id="settings-backdrop" class="settings-backdrop hidden"></div> <div id="settings-backdrop" class="settings-backdrop hidden"></div>
<dialog id="settings-dialog" class="settings-dialog"> <dialog id="settings-dialog" class="settings-dialog">
<div class="settings-dialog-header"> <div class="settings-dialog-header">
<h2 class="settings-dialog-title">Settings</h2> <h2 class="settings-dialog-title">Settings</h2>
<button id="settings-close-btn" class="settings-close-btn" aria-label="Close settings">&times;</button> <button id="settings-close-btn" class="settings-close-btn" aria-label="Close settings"><svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 4l8 8M12 4l-8 8"/></svg></button>
</div> </div>
<div class="settings-layout"> <div class="settings-layout">
<nav class="settings-tabs"> <nav class="settings-tabs">
@@ -270,12 +269,24 @@
</dialog> </dialog>
<!-- ── Scripts ──────────────────────────────────────────────────────────── --> <!-- ── Scripts ──────────────────────────────────────────────────────────── -->
<script src="/vendor/xterm.js"></script> <script src="/vendor/ghostty-web.js"></script>
<script src="/vendor/xterm-addon-fit.js"></script>
<script src="/vendor/xterm-addon-web-links.js"></script> <script src="/vendor/xterm-addon-web-links.js"></script>
<script src="/vendor/xterm-addon-search.js"></script> <script src="/vendor/xterm-addon-search.js"></script>
<script src="/vendor/addon-image.js"></script> <script src="/vendor/addon-image.js"></script>
<script src="/app.js" defer></script> <script src="/app.js" defer></script>
<script src="/terminal.js" defer></script> <script src="/terminal.js" defer></script>
<script type="module">
import '/components/toast-notification.js';
import '/components/connection-status.js';
import '/components/session-pill.js';
import '/components/session-tile.js';
import '/components/sidebar-item.js';
import '/components/hover-preview.js';
import '/components/bottom-sheet-switcher.js';
import '/components/session-grid.js';
import '/components/view-dropdown.js';
import '/components/search-filter.js';
</script>
</body> </body>
</html> </html>
+131 -17
View File
@@ -51,6 +51,35 @@
/* Typography */ /* Typography */
--font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif; --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace; --font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
/* Preview */
--preview-font-size: 12px;
}
/* Custom element wrappers — Lit components using light DOM render children
into themselves, but the custom element tag itself defaults to display:inline.
Grid and flex layouts need these to be transparent or block-level. */
session-grid,
session-tile,
sidebar-item { display: contents; }
view-dropdown { display: block; position: relative; }
/* Inline SVG icons — ensure consistent vertical alignment */
.header-btn svg,
.back-btn svg,
.sidebar-toggle-btn svg,
.sidebar-collapse-btn svg,
.terminal-search-btn svg,
.settings-close-btn svg,
.tile-options-btn svg,
.new-session-fab svg {
display: block;
}
.view-dropdown__caret {
display: inline-block;
vertical-align: middle;
} }
/* Box-sizing reset */ /* Box-sizing reset */
@@ -197,6 +226,11 @@ body {
border-left-color: var(--bell); border-left-color: var(--bell);
} }
/* Active tile (recent activity within last 5 minutes) */
.session-tile--active {
border-left-color: var(--accent);
}
/* ============================================================ /* ============================================================
Tile header Tile header
============================================================ */ ============================================================ */
@@ -233,10 +267,7 @@ body {
margin: 0 2px; margin: 0 2px;
} }
.session-tile:hover .tile-meta, /* P4 hover fade fix: show meta on hover instead of hiding */
.session-tile:focus-within .tile-meta {
opacity: 0;
}
/* Bell notification badge */ /* Bell notification badge */
.tile-bell { .tile-bell {
@@ -294,8 +325,8 @@ body {
right: 0; right: 0;
padding: 6px 8px; padding: 6px 8px;
font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-family: 'SF Mono', 'Fira Code', Consolas, monospace;
font-size: 11px; font-size: var(--preview-font-size, 12px);
line-height: 1.0; line-height: 1.2;
color: #c9d1d9; /* match xterm.js foreground */ color: #c9d1d9; /* match xterm.js foreground */
white-space: pre; white-space: pre;
overflow: hidden; overflow: hidden;
@@ -331,13 +362,21 @@ body {
inset: 0; inset: 0;
padding: 6px 8px; padding: 6px 8px;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 11px; font-size: var(--preview-font-size, 12px);
line-height: 1.2;
color: var(--text-dim); color: var(--text-dim);
white-space: pre; white-space: pre;
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;
} }
/* Stale / idle tile dimming (no activity for >1 hour) */
.session-tile--stale .tile-name {
color: var(--text-muted);
}
.session-tile--stale .tile-body {
opacity: 0.6;
}
/* ============================================================ /* ============================================================
@@ -462,7 +501,6 @@ body {
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
transition: width 0.25s ease, min-width 0.25s ease;
} }
.session-sidebar.sidebar--collapsed { .session-sidebar.sidebar--collapsed {
@@ -696,7 +734,6 @@ body {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
padding: 0 4px; /* keep text off the side edges */
} }
/* Terminal search bar */ /* Terminal search bar */
@@ -752,9 +789,13 @@ body {
border-color: var(--accent); border-color: var(--accent);
} }
/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */ /* xterm.js manages touch scrolling natively via its Viewport — let it work.
Hide the visual scrollbar on touch devices but keep overflow-y functional. */
.xterm .xterm-viewport { .xterm .xterm-viewport {
overflow-y: hidden !important; scrollbar-width: none;
}
.xterm .xterm-viewport::-webkit-scrollbar {
display: none;
} }
.reconnect-overlay { .reconnect-overlay {
@@ -775,12 +816,6 @@ body {
position: fixed; position: fixed;
z-index: 50; z-index: 50;
border-radius: 4px; border-radius: 4px;
transition:
top var(--t-zoom),
left var(--t-zoom),
width var(--t-zoom),
height var(--t-zoom),
border-radius var(--t-zoom);
} }
.session-tile--expanded { .session-tile--expanded {
@@ -2056,6 +2091,13 @@ body {
margin-right: 4px; margin-right: 4px;
} }
/* Ensure mobile toolbar is hidden on non-touch desktop */
@media (hover: hover) and (pointer: fine) {
.mobile-toolbar {
display: none !important;
}
}
/* ============================================================ /* ============================================================
Responsive overlay sidebar at <960px Responsive overlay sidebar at <960px
============================================================ */ ============================================================ */
@@ -2539,3 +2581,75 @@ body {
margin: 4px 0; margin: 4px 0;
background: var(--border-subtle); background: var(--border-subtle);
} }
/* ============================================================
Mobile terminal toolbar — virtual keys for touch devices
============================================================ */
.mobile-toolbar {
flex-shrink: 0;
background: var(--bg-secondary);
border-top: 1px solid var(--border-subtle);
padding: 4px 6px;
}
.mobile-toolbar__scroll {
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none; /* Firefox */
padding-bottom: 2px; /* prevent clipping of active state */
}
.mobile-toolbar__scroll::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
.mobile-toolbar__key {
flex-shrink: 0;
min-width: 38px;
height: 32px;
padding: 0 8px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
.mobile-toolbar__key:active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
}
.mobile-toolbar__key svg {
pointer-events: none;
}
/* Modifier keys (Ctrl, Alt) — slightly different base style */
.mobile-toolbar__key--modifier {
font-family: var(--font-ui);
font-weight: 600;
font-size: 11px;
letter-spacing: 0.02em;
}
/* Active (toggled on) state for modifier keys */
.mobile-toolbar__key--active {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
}
File diff suppressed because it is too large Load Diff
+44 -47
View File
@@ -1126,14 +1126,14 @@ test('openSession with skipAnimation calls window._openTerminal after connect PO
globalThis.setTimeout = origSetTimeout; globalThis.setTimeout = origSetTimeout;
}); });
test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', async () => { test('openSession for local session does NOT POST to /connect (muxterm handles attach via WebSocket)', async () => {
const fetchCalls = []; const fetchCalls = [];
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById; const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -1141,17 +1141,16 @@ test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', as
await app.openSession('work', {}); await app.openSession('work', {});
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect'); const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
assert.ok(connectCall, 'should POST to /api/sessions/work/connect'); assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
assert.strictEqual(connectCall.opts.method, 'POST');
globalThis.fetch = origFetch; globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS; globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout; globalThis.setTimeout = origSetTimeout;
}); });
test('openSession shows toast and calls closeSession on connect failure', async () => { test('openSession shows toast and calls closeSession on remote connect failure', async () => {
let closeTerminalCalled = false; let closeTerminalCalled = false;
const mockToast = { textContent: '', classList: { remove: () => {}, add: () => {} } }; let toastMsg = '';
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById; const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
@@ -1161,17 +1160,18 @@ test('openSession shows toast and calls closeSession on connect failure', async
return { ok: true }; return { ok: true };
}; };
globalThis.document.getElementById = (id) => { globalThis.document.getElementById = (id) => {
if (id === 'toast') return mockToast; if (id === 'toast') return { show: (msg) => { toastMsg = msg; }, textContent: '', classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }; return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
}; };
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
globalThis.window._closeTerminal = () => { closeTerminalCalled = true; }; globalThis.window._closeTerminal = () => { closeTerminalCalled = true; };
await app.openSession('failing-session', {}); // Use a remote session so /connect is actually called (local sessions skip /connect)
await app.openSession('failing-session', { remoteId: 'fed-fail' });
assert.ok(mockToast.textContent.length > 0, 'toast should show an error message'); assert.ok(toastMsg.length > 0, 'toast should show an error message');
assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)'); assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)');
globalThis.fetch = origFetch; globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
@@ -1186,7 +1186,7 @@ test('openSession with remoteId POSTs connect to federation proxy URL', async ()
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -1209,7 +1209,7 @@ test('openSession with remoteId passes remoteId to window._openTerminal', async
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async () => ({ ok: true }); globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = (fn) => { fn(); }; globalThis.setTimeout = (fn) => { fn(); };
globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; }; globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; };
@@ -1251,14 +1251,14 @@ test('openSession passes getDisplaySettings().fontSize to window._openTerminal a
globalThis.setTimeout = origSetTimeout; globalThis.setTimeout = origSetTimeout;
}); });
test('openSession for local session still POSTs to local /api/sessions/{name}/connect', async () => { test('openSession for local session skips /connect POST (muxterm handles attach via WebSocket)', async () => {
const fetchCalls = []; const fetchCalls = [];
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById; const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -1266,8 +1266,7 @@ test('openSession for local session still POSTs to local /api/sessions/{name}/co
await app.openSession('local-session', {}); await app.openSession('local-session', {});
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect'); const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect');
assert.ok(connectCall, 'should POST to /api/sessions/local-session/connect'); assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
assert.strictEqual(connectCall.opts.method, 'POST');
globalThis.fetch = origFetch; globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS; globalThis.document.querySelector = origQS;
@@ -1354,7 +1353,7 @@ test('closeSession calls window._closeTerminal', async () => {
globalThis.fetch = undefined; globalThis.fetch = undefined;
}); });
test('closeSession fires DELETE /api/sessions/current', async () => { test('closeSession does NOT fire DELETE /api/sessions/current (detach handled by closeTerminal)', async () => {
const fetchCalls = []; const fetchCalls = [];
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById; const origGetById = globalThis.document.getElementById;
@@ -1363,16 +1362,16 @@ test('closeSession fires DELETE /api/sessions/current', async () => {
globalThis.window._closeTerminal = () => {}; globalThis.window._closeTerminal = () => {};
await app.closeSession(); await app.closeSession();
// yield microtask queue for fire-and-forget DELETE // yield microtask queue for any fire-and-forget calls
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE'); const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
assert.ok(deleteCall, 'should fire DELETE /api/sessions/current'); assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current');
globalThis.fetch = origFetch; globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
}); });
test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRemoteId)', async () => { test('closeSession does NOT fire DELETE for remote session (detach handled by closeTerminal)', async () => {
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById; const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
@@ -1380,7 +1379,7 @@ test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRe
// Setup to call openSession with remote remoteId // Setup to call openSession with remote remoteId
globalThis.fetch = async () => ({ ok: true }); globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -1410,7 +1409,7 @@ test('closeSession does NOT fire DELETE for remote session (non-empty _viewingRe
globalThis.setTimeout = origSetTimeout; globalThis.setTimeout = origSetTimeout;
}); });
test('closeSession still fires DELETE /api/sessions/current for local session', async () => { test('closeSession does NOT fire DELETE /api/sessions/current for local session (detach handled by closeTerminal)', async () => {
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById; const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
@@ -1418,7 +1417,7 @@ test('closeSession still fires DELETE /api/sessions/current for local session',
// Setup to call openSession for a local session (no remoteId) // Setup to call openSession for a local session (no remoteId)
globalThis.fetch = async () => ({ ok: true }); globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -1436,11 +1435,11 @@ test('closeSession still fires DELETE /api/sessions/current for local session',
// Close session // Close session
await app.closeSession(); await app.closeSession();
// yield microtask queue for fire-and-forget DELETE // yield microtask queue for any fire-and-forget calls
await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0));
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE'); const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
assert.ok(deleteCall, 'closeSession should fire DELETE /api/sessions/current for local session'); assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current — detach is handled by closeTerminal()');
globalThis.fetch = origFetch; globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
@@ -2075,14 +2074,14 @@ test('bindSidebarClickAway registers click listener on terminal-container', () =
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
}); });
test('openSession mounts terminal AFTER connect POST, not inside animation timer', () => { test('openSession mounts terminal AFTER federation connect POST, not inside animation timer', () => {
const source = fs.readFileSync( const source = fs.readFileSync(
new URL('../app.js', import.meta.url), 'utf8' new URL('../app.js', import.meta.url), 'utf8'
); );
// Find the openSession function body // Find the openSession function body
const fnStart = source.indexOf('async function openSession'); const fnStart = source.indexOf('async function openSession');
const fnBody = source.substring(fnStart, fnStart + 4000); const fnBody = source.substring(fnStart, fnStart + 5000);
// _openTerminal must NOT appear inside setTimeout // _openTerminal must NOT appear inside setTimeout
const setTimeoutIdx = fnBody.indexOf('setTimeout'); const setTimeoutIdx = fnBody.indexOf('setTimeout');
@@ -2092,11 +2091,11 @@ test('openSession mounts terminal AFTER connect POST, not inside animation timer
assert.ok(!setTimeoutBody.includes('_openTerminal'), assert.ok(!setTimeoutBody.includes('_openTerminal'),
'_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST'); '_openTerminal must NOT be inside the 260ms setTimeout — causes race condition with /connect POST');
// _openTerminal must appear AFTER the /connect POST // _openTerminal must appear AFTER the federation connect POST
const connectIdx = fnBody.indexOf('/api/sessions/'); const connectIdx = fnBody.indexOf('/api/federation/');
const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx); const openTermIdx = fnBody.indexOf('_openTerminal', connectIdx);
assert.ok(openTermIdx > connectIdx, assert.ok(openTermIdx > connectIdx,
'_openTerminal must appear AFTER the /connect POST in the source'); '_openTerminal must appear AFTER the federation connect POST in the source');
}); });
// --- Hover preview popover --- // --- Hover preview popover ---
@@ -2990,16 +2989,15 @@ test('CSS style.css has tile--loading and shimmer animation', () => {
// --- Issue 2: Always call connect on restore --- // --- Issue 2: Always call connect on restore ---
test('openSession always POSTs to connect even when skipConnect option is passed', async () => { test('openSession for local session does NOT POST to connect regardless of skipConnect option', async () => {
// Before the fix, skipConnect:true skipped the connect POST entirely. // Local sessions no longer POST to /connect — muxterm handles attach via WebSocket.
// After the fix, connect is always called; the option is renamed to skipAnimation.
const fetchCalls = []; const fetchCalls = [];
const origFetch = globalThis.fetch; const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById; const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: { removeProperty: () => {} }, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: { removeProperty: () => {} }, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -3007,8 +3005,7 @@ test('openSession always POSTs to connect even when skipConnect option is passed
await app.openSession('work', { skipConnect: true }); await app.openSession('work', { skipConnect: true });
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect'); const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect');
assert.ok(connectCall, 'skipConnect:true must NOT prevent connect POST connect always fires after fix'); assert.ok(!connectCall, 'local session should NOT POST to /connect — muxterm handles attach via WebSocket');
assert.strictEqual(connectCall.opts.method, 'POST');
globalThis.fetch = origFetch; globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS; globalThis.document.querySelector = origQS;
@@ -4113,11 +4110,11 @@ test('tile click handler ignores clicks on tile-options-btn button', () => {
// --- index.html: self-hosted vendor libs (no CDN) --- // --- index.html: self-hosted vendor libs (no CDN) ---
test('index.html loads xterm.css from local /vendor/ path (not CDN)', () => { test('index.html loads ghostty-web.js from local /vendor/ path (not CDN)', () => {
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
assert.ok( assert.ok(
html.includes('href="/vendor/xterm.css"'), html.includes('ghostty-web.js'),
'index.html must reference /vendor/xterm.css (not CDN)', 'index.html must reference ghostty-web.js (not xterm.js)',
); );
assert.ok( assert.ok(
!html.includes('cdn.jsdelivr.net'), !html.includes('cdn.jsdelivr.net'),
@@ -4125,11 +4122,11 @@ test('index.html loads xterm.css from local /vendor/ path (not CDN)', () => {
); );
}); });
test('index.html loads all 5 xterm JS scripts from local /vendor/ paths (not CDN)', () => { test('index.html loads ghostty-web and remaining addon scripts from local /vendor/ paths (not CDN)', () => {
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8');
// ghostty-web replaces xterm.js + xterm-addon-fit.js (fit is built-in)
const vendorScripts = [ const vendorScripts = [
'/vendor/xterm.js', '/vendor/ghostty-web.js',
'/vendor/xterm-addon-fit.js',
'/vendor/xterm-addon-web-links.js', '/vendor/xterm-addon-web-links.js',
'/vendor/xterm-addon-search.js', '/vendor/xterm-addon-search.js',
'/vendor/addon-image.js', '/vendor/addon-image.js',
@@ -4180,7 +4177,7 @@ test('openSession with integer remoteId=0 POSTs to federation proxy URL, not loc
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -4208,7 +4205,7 @@ test('openSession with integer remoteId=0 passes 0 to window._openTerminal as se
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async () => ({ ok: true }); globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = (fn) => { fn(); }; globalThis.setTimeout = (fn) => { fn(); };
globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; }; globalThis.window._openTerminal = (...args) => { openTerminalArgs = args; };
@@ -4233,7 +4230,7 @@ test('closeSession after openSession with remoteId=0 does NOT fire DELETE /api/s
// Open a remote session with remoteId=0 — sets _viewingRemoteId = 0 // Open a remote session with remoteId=0 — sets _viewingRemoteId = 0
globalThis.fetch = async () => ({ ok: true }); globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {}; globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -4568,7 +4565,7 @@ test('openSession PATCHes /api/state with active_remote_id after successful conn
const origQS = globalThis.document.querySelector; const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout; const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = (fn) => { fn(); }; // invoke immediately so animation resolves globalThis.setTimeout = (fn) => { fn(); }; // invoke immediately so animation resolves
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -4594,7 +4591,7 @@ test('closeSession PATCHes /api/state to clear active_remote_id', async () => {
// Open a remote session first so _viewingRemoteId is set // Open a remote session first so _viewingRemoteId is set
globalThis.fetch = async () => ({ ok: true }); globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null });
globalThis.document.querySelector = () => null; globalThis.document.querySelector = () => null;
globalThis.setTimeout = (fn) => { fn(); }; globalThis.setTimeout = (fn) => { fn(); };
globalThis.window._openTerminal = () => {}; globalThis.window._openTerminal = () => {};
@@ -0,0 +1,178 @@
// localStorage stub — must be set before importing app.js
let _localStorageStore = {};
globalThis.localStorage = {
getItem: (key) => (Object.prototype.hasOwnProperty.call(_localStorageStore, key) ? _localStorageStore[key] : null),
setItem: (key, value) => { _localStorageStore[key] = String(value); },
removeItem: (key) => { delete _localStorageStore[key]; },
};
// Browser global stubs — must be set before importing app.js
globalThis.document = {
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }),
addEventListener: () => {},
removeEventListener: () => {},
};
// Stubs for functions called by pollSessions (implemented in later tasks)
globalThis.renderGrid = () => {};
globalThis.handleBellTransitions = () => {};
globalThis.openSession = () => {};
globalThis.updatePillBell = () => {};
globalThis.window = {
addEventListener: () => {},
location: { href: '' },
innerWidth: 1024,
};
globalThis.Notification = {
permission: 'default',
requestPermission: async () => 'default',
};
// navigator is read-only in Node v24+, use defineProperty
Object.defineProperty(globalThis, 'navigator', {
value: { userAgent: 'test-agent' },
writable: true,
configurable: true,
});
import { createRequire } from 'node:module';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
const app = require(join(__dirname, '..', 'app.js'));
// Helper: returns a mock DOM element with querySelector support
function mockEl() {
return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
}
// --- Task 12: openSession should NOT POST /connect for local sessions ---
test('openSession for local session does NOT POST to /connect', async () => {
const fetchCalls = [];
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => mockEl();
globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {};
await app.openSession('local-session', {});
// Local sessions should NOT POST to /api/sessions/{name}/connect
const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/local-session/connect');
assert.ok(!connectCall, 'local session should NOT POST to /api/sessions/{name}/connect');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout;
});
test('openSession for remote session still POSTs to federation connect', async () => {
const fetchCalls = [];
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
globalThis.document.getElementById = () => mockEl();
globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {};
await app.openSession('work-project', { remoteId: 'fed-abc123' });
const connectCall = fetchCalls.find((c) => c.url === '/api/federation/fed-abc123/connect/work-project');
assert.ok(connectCall, 'remote session should POST to federation connect');
assert.strictEqual(connectCall.opts.method, 'POST');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout;
});
test('openSession for remote session shows toast and closes on federation connect failure', async () => {
let closeTerminalCalled = false;
let toastMsg = '';
const mockToast = { show: (msg) => { toastMsg = msg; }, textContent: '', classList: { remove: () => {}, add: () => {} }, querySelector: () => null };
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout;
globalThis.fetch = async (url) => {
if (url.includes('/connect')) throw new Error('Connection failed');
return { ok: true };
};
globalThis.document.getElementById = (id) => {
if (id === 'toast') return mockToast;
return mockEl();
};
globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {};
globalThis.window._closeTerminal = () => { closeTerminalCalled = true; };
// Use a remote session so /connect is actually called
await app.openSession('failing-session', { remoteId: 'fed-xyz' });
assert.ok(toastMsg.length > 0, 'toast should show an error message');
assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout;
});
// --- Task 12: closeSession should NOT fire DELETE /api/sessions/current ---
test('closeSession does NOT fire DELETE /api/sessions/current for local session', async () => {
const origFetch = globalThis.fetch;
const origGetById = globalThis.document.getElementById;
const origQS = globalThis.document.querySelector;
const origSetTimeout = globalThis.setTimeout;
// Setup to call openSession for a local session (no remoteId)
globalThis.fetch = async () => ({ ok: true });
globalThis.document.getElementById = () => mockEl();
globalThis.document.querySelector = () => null;
globalThis.setTimeout = () => {};
globalThis.window._openTerminal = () => {};
globalThis.window._closeTerminal = () => {};
// Open a local session - this sets _viewingRemoteId = ''
await app.openSession('local-sess', {});
// Restore setTimeout so Promise-based yielding works
globalThis.setTimeout = origSetTimeout;
// Reset fetch tracking
const fetchCalls = [];
globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; };
// Close session
await app.closeSession();
// yield microtask queue for any fire-and-forget calls
await new Promise((r) => setTimeout(r, 0));
const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE');
assert.ok(!deleteCall, 'closeSession should NOT fire DELETE /api/sessions/current — detach is handled by closeTerminal()');
globalThis.fetch = origFetch;
globalThis.document.getElementById = origGetById;
globalThis.document.querySelector = origQS;
globalThis.setTimeout = origSetTimeout;
});
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-2
View File
@@ -1,2 +0,0 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
//# sourceMappingURL=xterm-addon-fit.js.map
-209
View File
@@ -1,209 +0,0 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* 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.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/
/**
* Default styles for xterm.js
*/
.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}
.xterm.focus,
.xterm:focus {
outline: none;
}
.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}
.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}
.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}
.xterm .composition-view.active {
display: block;
}
.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}
.xterm .xterm-screen {
position: relative;
}
.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}
.xterm .xterm-scroll-area {
visibility: hidden;
}
.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}
.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}
.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}
.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}
.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}
.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}
.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }
.xterm-overline {
text-decoration: overline;
}
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
.xterm-strikethrough {
text-decoration: line-through;
}
.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}
.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}
.xterm-decoration-top {
z-index: 2;
position: relative;
}
File diff suppressed because one or more lines are too long
+185 -173
View File
@@ -10,6 +10,7 @@ Background poll loop reconciles tmux session state every POLL_INTERVAL seconds.
import asyncio import asyncio
import contextlib import contextlib
import copy import copy
import hashlib
import hmac import hmac
import importlib.metadata import importlib.metadata
import json import json
@@ -18,8 +19,10 @@ import os
import pathlib import pathlib
import pwd import pwd
import re import re
import secrets as _secrets_mod
import socket import socket
import ssl import ssl
import shlex
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -50,6 +53,7 @@ from muxplex.auth import (
from muxplex.bells import apply_bell_clear_rule, process_bell_flags from muxplex.bells import apply_bell_clear_rule, process_bell_flags
from muxplex.sessions import ( from muxplex.sessions import (
enumerate_sessions, enumerate_sessions,
get_session_activity,
get_session_list, get_session_list,
get_snapshots, get_snapshots,
run_tmux, run_tmux,
@@ -76,7 +80,7 @@ from muxplex.settings import (
from muxplex.pruning import load_pruning_state, save_pruning_state from muxplex.pruning import load_pruning_state, save_pruning_state
from muxplex.views import normalize_session_keys, prune_stale_keys from muxplex.views import normalize_session_keys, prune_stale_keys
from muxplex.identity import load_device_id from muxplex.identity import load_device_id
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT from muxplex.muxterm import start_muxterm, stop_muxterm
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Configuration # Configuration
@@ -84,8 +88,13 @@ from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0")) POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
SERVER_PORT: int = int(os.environ.get("MUXPLEX_PORT", "8088")) SERVER_PORT: int = int(os.environ.get("MUXPLEX_PORT", "8088"))
MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
SETTINGS_SYNC_INTERVAL: int = 15 # sync every ~30 seconds (15 * 2s poll interval) SETTINGS_SYNC_INTERVAL: int = 15 # sync every ~30 seconds (15 * 2s poll interval)
_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "") or _secrets_mod.token_hex(
32
)
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -179,12 +188,12 @@ async def _run_poll_cycle() -> None:
global _settings_sync_counter global _settings_sync_counter
async with state_lock: async with state_lock:
# 1. Enumerate live tmux sessions # 1. Enumerate live tmux sessions
names = await enumerate_sessions() names, activity = await enumerate_sessions()
name_set = set(names) name_set = set(names)
# 2. Capture pane snapshots and update in-memory snapshot cache # 2. Capture pane snapshots and update in-memory snapshot cache
new_snapshots = await snapshot_all(names) new_snapshots = await snapshot_all(names)
update_session_cache(names, new_snapshots) update_session_cache(names, new_snapshots, activity)
# 3. Load current persisted state # 3. Load current persisted state
state = load_state() state = load_state()
@@ -362,9 +371,15 @@ async def lifespan(app: FastAPI):
global _poll_task global _poll_task
global _federation_client global _federation_client
# Startup: kill any orphaned ttyd from a previous muxplex run, then # Startup: start muxterm (if secret is configured) then start the
# start the background poll loop. # background poll loop.
await kill_orphan_ttyd() if _muxterm_secret:
try:
await start_muxterm(secret=_muxterm_secret, port=MUXTERM_PORT)
except FileNotFoundError:
_log.warning("muxterm binary not found; terminal feature disabled")
except Exception:
_log.warning("failed to start muxterm", exc_info=True)
_poll_task = asyncio.create_task(_poll_loop()) _poll_task = asyncio.create_task(_poll_loop())
# Register tmux alert-bell hook so bells are detected even when clients are attached. # Register tmux alert-bell hook so bells are detected even when clients are attached.
@@ -374,7 +389,9 @@ async def lifespan(app: FastAPI):
"set-hook", "set-hook",
"-g", "-g",
"alert-bell", "alert-bell",
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'", "run-shell '"
f'name=$(printf "%s" "#{{session_name}}" | sed "s/ /%20/g"); '
f"curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/$name/bell || true'",
) )
except Exception: except Exception:
pass # tmux not running at startup is OK; hook will be set on first poll pass # tmux not running at startup is OK; hook will be set on first poll
@@ -399,7 +416,8 @@ async def lifespan(app: FastAPI):
except Exception: except Exception:
_log.exception("federation_client aclose error") _log.exception("federation_client aclose error")
finally: finally:
# Cleanup: cancel the poll loop task and wait for it to finish. # Cleanup: stop muxterm and cancel the poll loop task.
await stop_muxterm()
if _poll_task is not None: if _poll_task is not None:
_poll_task.cancel() _poll_task.cancel()
try: try:
@@ -528,10 +546,38 @@ _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
# which machine each muxplex instance is running on. # which machine each muxplex instance is running on.
_HOSTNAME = socket.gethostname().split(".")[0] _HOSTNAME = socket.gethostname().split(".")[0]
# Canonical version string — sourced from package metadata (same as `app.version` # Canonical package version — exposed by app.version, /health, and the `doctor`
# and the `doctor` command). Used to append `?v=<version>` to every static-asset # command. Kept separate from the cache-busting token below.
# URL so browsers immediately pick up new code on each release. _PACKAGE_VERSION: str = importlib.metadata.version("muxplex")
_UI_VERSION: str = importlib.metadata.version("muxplex")
def _compute_ui_version() -> str:
"""Return an 8-char hex token derived from frontend file modification times.
Hashes the *maximum* mtime across all files under the frontend directory.
Any frontend file edit produces a new token, so browsers and CDN edges
(e.g. Cloudflare with ``max-age=14400``) always fetch the updated assets
instead of serving a stale copy with the same ``?v=<token>`` URL.
Falls back to the package version string if the directory is unreadable
(e.g. frozen / packaged deployments where mtimes are unreliable).
"""
try:
mtimes = [
f.stat().st_mtime
for f in _FRONTEND_DIR.rglob("*")
if f.is_file()
]
if mtimes:
return hashlib.md5(str(max(mtimes)).encode()).hexdigest()[:8]
except Exception:
pass
return _PACKAGE_VERSION
# Cache-busting token appended as ``?v=<token>`` to every static-asset URL
# served by index_page(). Recomputed at startup from frontend file mtimes.
_UI_VERSION: str = _compute_ui_version()
# Matches src="/<path>" and href="/<path>" in served HTML, excluding /api/ URLs. # Matches src="/<path>" and href="/<path>" in served HTML, excluding /api/ URLs.
# Used by index_page() to inject cache-busting version query parameters. # Used by index_page() to inject cache-busting version query parameters.
@@ -580,9 +626,10 @@ async def patch_state(patch: StatePatch) -> dict:
@app.get("/api/sessions") @app.get("/api/sessions")
async def get_sessions() -> list[dict]: async def get_sessions() -> list[dict]:
"""Return list of sessions with name, snapshot, and bell data.""" """Return list of sessions with name, snapshot, bell, and activity data."""
names = get_session_list() names = get_session_list()
snapshots = get_snapshots() snapshots = get_snapshots()
activities = get_session_activity()
state = await read_state() state = await read_state()
result = [] result = []
@@ -594,6 +641,7 @@ async def get_sessions() -> list[dict]:
"name": name, "name": name,
"snapshot": snapshots.get(name, ""), "snapshot": snapshots.get(name, ""),
"bell": bell, "bell": bell,
"last_activity_at": activities.get(name),
} }
) )
return result return result
@@ -635,7 +683,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
"Ensure it is installed and in the server's PATH.", "Ensure it is installed and in the server's PATH.",
) )
command = template.replace("{name}", name) command = template.replace("{name}", shlex.quote(name))
_log.info("Creating session '%s' with command: %s", name, command) _log.info("Creating session '%s' with command: %s", name, command)
try: try:
proc = await asyncio.create_subprocess_shell( proc = await asyncio.create_subprocess_shell(
@@ -651,7 +699,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
# Some commands (amplifier-workspace) create the session then # Some commands (amplifier-workspace) create the session then
# try to attach (which fails without a TTY). If the session # try to attach (which fails without a TTY). If the session
# exists despite the non-zero exit, treat it as success. # exists despite the non-zero exit, treat it as success.
sessions = await enumerate_sessions() sessions, _act = await enumerate_sessions()
if name in sessions: if name in sessions:
_log.info( _log.info(
"Session command exited %d but session '%s' exists -- " "Session command exited %d but session '%s' exists -- "
@@ -692,45 +740,27 @@ async def create_session(payload: CreateSessionPayload) -> dict:
status_code=500, status_code=500,
detail=f"Failed to launch command: {exc}", detail=f"Failed to launch command: {exc}",
) )
# Eagerly refresh the session cache so the next GET /api/sessions
# reflects the newly-created session without waiting for the poll loop.
try:
fresh_names, fresh_activity = await enumerate_sessions()
fresh_snapshots = await snapshot_all(fresh_names)
update_session_cache(fresh_names, fresh_snapshots, fresh_activity)
except Exception:
pass # non-fatal; poll loop will catch up
return {"name": name, "ok": True} return {"name": name, "ok": True}
@app.post("/api/sessions/{name}/connect")
async def connect_session(name: str) -> dict:
"""Connect to a tmux session via ttyd.
Kills any existing ttyd process, spawns a new one attached to *name*,
and updates the active_session in persistent state.
Returns {active_session: name, ttyd_port: 7682}.
Raises HTTP 404 if *name* is not in the known session list (when non-empty).
"""
known = get_session_list()
if known and name not in known:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
_log.info("Connecting to session '%s'", name)
await kill_ttyd()
await spawn_ttyd(name)
async with state_lock:
state = load_state()
state["active_session"] = name
save_state(state)
return {"active_session": name, "ttyd_port": TTYD_PORT}
@app.delete("/api/sessions/current") @app.delete("/api/sessions/current")
async def delete_current_session() -> dict: async def delete_current_session() -> dict:
"""Disconnect the current ttyd session. """Clear the active session.
Kills the running ttyd process and clears active_session in persistent state. Sets active_session to None in persistent state.
Returns {active_session: None}. Returns {active_session: None}.
""" """
await kill_ttyd()
async with state_lock: async with state_lock:
state = load_state() state = load_state()
state["active_session"] = None state["active_session"] = None
@@ -758,7 +788,7 @@ async def delete_session(name: str) -> dict:
settings = load_settings() settings = load_settings()
command = settings.get( command = settings.get(
"delete_session_template", "tmux kill-session -t {name}" "delete_session_template", "tmux kill-session -t {name}"
).replace("{name}", name) ).replace("{name}", shlex.quote(name))
_log.info("Deleting session '%s' with command: %s", name, command) _log.info("Deleting session '%s' with command: %s", name, command)
try: try:
@@ -858,7 +888,9 @@ async def setup_hooks() -> dict:
"set-hook", "set-hook",
"-g", "-g",
"alert-bell", "alert-bell",
f"run-shell 'curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/#{{session_name}}/bell || true'", "run-shell '"
f'name=$(printf "%s" "#{{session_name}}" | sed "s/ /%20/g"); '
f"curl -sfo /dev/null -X POST http://localhost:{SERVER_PORT}/api/sessions/$name/bell || true'",
) )
return {"ok": True} return {"ok": True}
except Exception as e: except Exception as e:
@@ -939,6 +971,95 @@ async def put_settings_sync(payload: SettingsSyncPayload):
) )
def _generate_muxterm_token() -> str:
"""Generate an HMAC-signed token for muxterm WebSocket authentication.
Token format: ``hex_signature.timestamp`` (valid ~30 seconds).
"""
ts = str(int(time.time()))
sig = hmac.new(_muxterm_secret.encode(), ts.encode(), hashlib.sha256).hexdigest()
return f"{sig}.{ts}"
@app.get("/api/terminal-token")
async def get_terminal_token() -> dict:
"""Return a short-lived HMAC token for muxterm WebSocket auth.
Raises HTTP 503 if MUXTERM_SECRET is not configured.
"""
if not _muxterm_secret:
raise HTTPException(status_code=503, detail="MUXTERM_SECRET not configured")
return {"token": _generate_muxterm_token(), "port": MUXTERM_PORT}
@app.websocket("/terminal/ws")
async def terminal_ws_proxy(websocket: WebSocket) -> None:
"""Proxy WebSocket frames between the browser and the local muxterm process.
Allows browsers behind a reverse proxy (Caddy, Tailscale, nginx) to reach
muxterm through the main app port instead of muxterm's internal port (7682),
which is never exposed externally. Also ensures the connection uses the
correct protocol (wss:// on HTTPS sites, avoiding mixed-content errors).
Auth is two-layered:
1. Session cookie is verified here (same check as federation WS proxy).
2. The HMAC token in the query string is verified by muxterm.
Closes with code 4001 if the session cookie is missing or invalid.
Closes with code 1011 (internal error) if muxterm is not reachable.
"""
# Session-cookie auth — BaseHTTPMiddleware does not cover WebSocket scope.
host = websocket.client.host if websocket.client else ""
if host not in ("127.0.0.1", "::1"):
session_cookie = websocket.cookies.get("muxplex_session")
cookie_ok = session_cookie and verify_session_cookie(
_auth_secret, session_cookie, _auth_ttl
)
if not cookie_ok:
await websocket.close(code=4001)
return
token = websocket.query_params.get("token", "")
muxterm_url = f"ws://127.0.0.1:{MUXTERM_PORT}/ws"
if token:
muxterm_url += f"?token={token}"
await websocket.accept()
try:
async with websockets.connect(muxterm_url) as muxterm_ws:
async def client_to_muxterm() -> None:
try:
while True:
msg = await websocket.receive()
if msg.get("bytes"):
await muxterm_ws.send(msg["bytes"])
elif msg.get("text"):
await muxterm_ws.send(msg["text"])
except Exception as exc:
_log.debug("terminal ws relay closed (client\u2192muxterm): %s", exc)
async def muxterm_to_client() -> None:
try:
async for message in muxterm_ws:
if isinstance(message, bytes):
await websocket.send_bytes(message)
else:
await websocket.send_text(message)
except Exception as exc:
_log.debug("terminal ws relay closed (muxterm\u2192client): %s", exc)
await asyncio.gather(client_to_muxterm(), muxterm_to_client())
except Exception as exc:
_log.debug("terminal ws proxy closed: %s", exc)
finally:
try:
await websocket.close()
except Exception:
pass
@app.get("/api/instance-info") @app.get("/api/instance-info")
async def instance_info() -> dict: async def instance_info() -> dict:
"""Return this instance's display name, device identity, and version. """Return this instance's display name, device identity, and version.
@@ -957,129 +1078,6 @@ async def instance_info() -> dict:
} }
# ---------------------------------------------------------------------------
# WebSocket proxy — bridges browser to ttyd (eliminates Caddy dependency)
# ---------------------------------------------------------------------------
def _ttyd_is_listening() -> bool:
"""Return True if something is accepting TCP connections on TTYD_PORT.
Uses a raw socket connect (no WebSocket handshake, no PTY spawned).
Takes < 1 ms on localhost when ttyd is running; fails immediately with
ConnectionRefusedError when it's not. OSError/TimeoutError are also
caught so the caller always gets a bool.
"""
try:
with socket.create_connection(("127.0.0.1", TTYD_PORT), timeout=0.5):
return True
except (ConnectionRefusedError, OSError, TimeoutError):
return False
async def _ws_auth_check(websocket: WebSocket) -> bool:
"""Return True if the WebSocket caller is authorized.
Closes the WebSocket with code 4001 and returns False if the caller
is not authorized. Localhost connections (127.0.0.1 / ::1) are
unconditionally trusted. Remote callers must present a valid
``muxplex_session`` cookie OR a Bearer token matching ``_federation_key``.
"""
host = websocket.client.host if websocket.client else ""
if host in ("127.0.0.1", "::1"):
return True
session_cookie = websocket.cookies.get("muxplex_session")
cookie_ok = session_cookie and verify_session_cookie(
_auth_secret, session_cookie, _auth_ttl
)
bearer_ok = False
if _federation_key:
auth_header = websocket.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
bearer_ok = hmac.compare_digest(auth_header[7:], _federation_key)
if not cookie_ok and not bearer_ok:
await websocket.close(code=4001)
return False
return True
@app.websocket("/terminal/ws")
async def terminal_ws_proxy(websocket: WebSocket) -> None:
"""Proxy WebSocket frames between the browser and ttyd.
Checks that ttyd is alive BEFORE accepting the browser WebSocket. If ttyd
is not listening (e.g. after a service restart), auto-spawns it using the
active_session from state, then waits briefly for it to bind its port.
Only after ttyd is confirmed reachable does the function call
websocket.accept() — so the browser's 'open' event only fires once a real
relay is possible. This prevents the reconnect-counter bounce bug where
the proxy accepted immediately (resetting _reconnectAttempts to 0) and
then closed as soon as it couldn't reach the dead ttyd.
"""
# Auth check before accepting — BaseHTTPMiddleware doesn't cover WebSocket scope
if not await _ws_auth_check(websocket):
return
# Ensure ttyd is reachable BEFORE accepting the browser WS.
# After a service restart ttyd is dead but clients reconnect immediately.
# Auto-spawn from active_session so the browser's 'open' event only fires
# when a real relay is possible — eliminates the 0→1→0→1 counter bounce.
if not _ttyd_is_listening():
try:
async with state_lock:
state = load_state()
session_name = state.get("active_session")
if session_name:
_log.info(
"WS proxy: ttyd not listening, auto-spawning for '%s'",
session_name,
)
await kill_ttyd()
await spawn_ttyd(session_name)
await asyncio.sleep(0.8) # wait for ttyd to bind its port
except Exception as exc:
_log.warning("WS proxy: failed to auto-spawn ttyd: %s", exc)
await websocket.accept(subprotocol="tty")
ttyd_url = f"ws://localhost:{TTYD_PORT}/ws"
try:
async with websockets.connect(
ttyd_url, subprotocols=[Subprotocol("tty")]
) as ttyd_ws:
async def client_to_ttyd() -> None:
try:
while True:
msg = await websocket.receive()
if msg.get("bytes"):
await ttyd_ws.send(msg["bytes"])
elif msg.get("text"):
await ttyd_ws.send(msg["text"])
except Exception as exc:
_log.debug("ws relay closed (client_to_ttyd): %s", exc)
async def ttyd_to_client() -> None:
try:
async for message in ttyd_ws:
if isinstance(message, bytes):
await websocket.send_bytes(message)
else:
await websocket.send_text(message)
except Exception as exc:
_log.debug("ws relay closed (ttyd_to_client): %s", exc)
await asyncio.gather(client_to_ttyd(), ttyd_to_client())
except Exception as exc:
_log.debug("ws proxy closed: %s", exc)
finally:
try:
await websocket.close()
except Exception:
pass
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Federation helper utilities # Federation helper utilities
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1133,9 +1131,21 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, device_id: str) ->
Auth check uses the same cookie + bearer pattern as terminal_ws_proxy. Auth check uses the same cookie + bearer pattern as terminal_ws_proxy.
Closes with code 4004 if device_id does not match any remote. Closes with code 4004 if device_id does not match any remote.
""" """
# Auth check before accepting — same pattern as terminal_ws_proxy # Auth check before accepting — BaseHTTPMiddleware doesn't cover WS scope
if not await _ws_auth_check(websocket): host = websocket.client.host if websocket.client else ""
return if host not in ("127.0.0.1", "::1"):
session_cookie = websocket.cookies.get("muxplex_session")
cookie_ok = session_cookie and verify_session_cookie(
_auth_secret, session_cookie, _auth_ttl
)
bearer_ok = False
if _federation_key:
auth_header = websocket.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
bearer_ok = hmac.compare_digest(auth_header[7:], _federation_key)
if not cookie_ok and not bearer_ok:
await websocket.close(code=4001)
return
# Look up remote instance by device_id # Look up remote instance by device_id
remote = _lookup_remote_by_device_id(device_id) remote = _lookup_remote_by_device_id(device_id)
@@ -1326,6 +1336,7 @@ async def federation_sessions(request: Request) -> list[dict]:
# Build local sessions with deviceId/deviceName/remoteId/sessionKey tags # Build local sessions with deviceId/deviceName/remoteId/sessionKey tags
names = get_session_list() names = get_session_list()
snapshots = get_snapshots() snapshots = get_snapshots()
activities = get_session_activity()
state = await read_state() state = await read_state()
local_sessions: list[dict] = [] local_sessions: list[dict] = []
for name in names: for name in names:
@@ -1336,6 +1347,7 @@ async def federation_sessions(request: Request) -> list[dict]:
"name": name, "name": name,
"snapshot": snapshots.get(name, ""), "snapshot": snapshots.get(name, ""),
"bell": bell, "bell": bell,
"last_activity_at": activities.get(name),
"deviceId": local_device_id, "deviceId": local_device_id,
"deviceName": local_device_name, "deviceName": local_device_name,
"remoteId": None, "remoteId": None,
+231
View File
@@ -0,0 +1,231 @@
"""
muxterm process supervision — start, monitor, restart on crash, stop.
Module state:
_muxterm_process — the currently running muxterm subprocess (or None)
_restart_task — the background monitor/restart task (or None)
Public API:
start_muxterm(secret, port, binary_path, auto_restart)
stop_muxterm()
Internal:
_find_muxterm_binary(binary_path)
_monitor_and_restart(secret, port, binary_path)
"""
import asyncio
import logging
import os
import pathlib
import shutil
import signal
import subprocess
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Module state
# ---------------------------------------------------------------------------
_muxterm_process: asyncio.subprocess.Process | None = None
_restart_task: asyncio.Task | None = None # type: ignore[type-arg]
# ---------------------------------------------------------------------------
# Port cleanup
# ---------------------------------------------------------------------------
def _kill_port(port: int) -> None:
"""Kill any process already listening on *port* to avoid bind conflicts.
Silently no-ops if no process holds the port or if ``fuser`` is unavailable.
This prevents the muxterm crash-loop that occurs when an old process (e.g.
one started manually for testing) still holds the port when the supervisor
tries to (re)start muxterm.
"""
try:
result = subprocess.run(
["fuser", f"{port}/tcp"],
capture_output=True,
text=True,
timeout=3,
)
for pid_str in result.stdout.split():
try:
os.kill(int(pid_str), signal.SIGTERM)
logger.info(
"killed stale process pid=%s holding port %s", pid_str, port
)
except (ProcessLookupError, ValueError, PermissionError):
pass
except Exception:
pass # fuser unavailable or other transient error — best-effort only
# ---------------------------------------------------------------------------
# Binary discovery
# ---------------------------------------------------------------------------
def _find_muxterm_binary(binary_path: str | None = None) -> str:
"""Locate the muxterm binary.
Resolution order:
1. Explicit *binary_path* argument.
2. ``MUXTERM_BINARY`` environment variable.
3. ``<repo_root>/muxterm/muxterm`` — the build output for dev/local installs
(package lives at ``<repo_root>/muxplex/``, binary at
``<repo_root>/muxterm/muxterm``).
4. ``shutil.which('muxterm')`` — installed on PATH.
Raises :exc:`FileNotFoundError` if none of the above resolves.
"""
if binary_path is not None:
return binary_path
env_path = os.environ.get("MUXTERM_BINARY")
if env_path:
candidate = pathlib.Path(env_path)
if candidate.is_file() and os.access(candidate, os.X_OK):
return str(candidate)
# Dev/local-install: binary is built next to the Python package directory.
# muxterm.py lives in <repo_root>/muxplex/
# muxterm binary is at <repo_root>/muxterm/muxterm
local_candidate = pathlib.Path(__file__).parent.parent / "muxterm" / "muxterm"
if local_candidate.is_file() and os.access(local_candidate, os.X_OK):
return str(local_candidate)
found = shutil.which("muxterm")
if found is None:
raise FileNotFoundError(
"muxterm binary not found; tried MUXTERM_BINARY env var, "
f"{local_candidate}, and PATH. Build it with `cd muxterm && go build .`"
)
return found
# ---------------------------------------------------------------------------
# Start
# ---------------------------------------------------------------------------
async def start_muxterm(
secret: str,
port: int = 7682,
binary_path: str | None = None,
auto_restart: bool = True,
) -> None:
"""Start the muxterm process.
Finds the binary via :func:`_find_muxterm_binary`, spawns it with
``--addr 127.0.0.1:<port> --secret <secret>``, and optionally starts
the background monitor/restart loop.
"""
global _muxterm_process, _restart_task
binary = _find_muxterm_binary(binary_path)
_kill_port(port) # evict any stale process holding the port
# stdout/stderr=None → muxterm inherits Python's fd 1/2 and its log output
# goes straight to the systemd journal. Using PIPE here with nobody reading
# causes the pipe buffer to fill and muxterm to block on log writes; worse,
# if Python is SIGKILL'd the broken pipe delivers SIGPIPE to muxterm which
# kills it silently (rc=141) so the monitor never restarts it.
_muxterm_process = await asyncio.create_subprocess_exec(
binary,
"--addr",
f"127.0.0.1:{port}",
"--secret",
secret,
)
logger.info("muxterm started (pid=%s, port=%s)", _muxterm_process.pid, port)
if auto_restart:
_restart_task = asyncio.create_task(
_monitor_and_restart(secret, port, binary_path)
)
# ---------------------------------------------------------------------------
# Monitor / restart
# ---------------------------------------------------------------------------
async def _monitor_and_restart(
secret: str,
port: int,
binary_path: str | None,
) -> None:
"""Wait for process exit; restart on non-zero exit codes.
Normal exit (rc=0) ends the loop. On crash, waits 1 s before
restarting, with 5 s backoff on repeated failures.
"""
global _muxterm_process
backoff = 1.0
while True:
if _muxterm_process is None:
return
rc = await _muxterm_process.wait()
if rc == 0:
logger.info("muxterm exited cleanly (rc=0)")
return
logger.warning("muxterm exited with rc=%s, restarting in %.0fs", rc, backoff)
await asyncio.sleep(backoff)
try:
binary = _find_muxterm_binary(binary_path)
_kill_port(port) # clear any stale process still holding the port
_muxterm_process = await asyncio.create_subprocess_exec(
binary,
"--addr",
f"127.0.0.1:{port}",
"--secret",
secret,
)
logger.info(
"muxterm restarted (pid=%s, port=%s)", _muxterm_process.pid, port
)
# Increase backoff on repeated failures, cap at 5 s.
backoff = min(backoff + 1.0, 5.0)
except Exception:
logger.exception("failed to restart muxterm, retrying in 5s")
backoff = 5.0
await asyncio.sleep(backoff)
# ---------------------------------------------------------------------------
# Stop
# ---------------------------------------------------------------------------
async def stop_muxterm() -> None:
"""Stop the muxterm process and cancel the restart task.
Terminates with a 5 s grace period, then kills if still alive.
"""
global _muxterm_process, _restart_task
if _restart_task is not None:
_restart_task.cancel()
_restart_task = None
if _muxterm_process is not None:
try:
_muxterm_process.terminate()
except ProcessLookupError:
pass # already exited
try:
await asyncio.wait_for(_muxterm_process.wait(), timeout=5.0)
except (asyncio.TimeoutError, ProcessLookupError):
try:
_muxterm_process.kill()
except ProcessLookupError:
pass
_muxterm_process = None
+51 -18
View File
@@ -4,15 +4,17 @@ tmux session enumeration and snapshot helpers for the tmux-web muxplex.
In-memory cache: In-memory cache:
_session_list — most-recently-enumerated list of session names. _session_list — most-recently-enumerated list of session names.
_snapshots — most-recently-captured pane text, keyed by session name. _snapshots — most-recently-captured pane text, keyed by session name.
_session_activity — most-recently-captured session activity timestamps.
Public API: Public API:
get_session_list() → list[str] get_session_list() → list[str]
get_snapshots() → dict[str, str] get_snapshots() → dict[str, str]
update_session_cache(names, snapshots) → None get_session_activity() → dict[str, float]
run_tmux(*args) → str (raises RuntimeError on nonzero exit) update_session_cache(names, snapshots, activity) → None
enumerate_sessions() → list[str] run_tmux(*args) → str (raises RuntimeError on nonzero exit)
capture_pane(name, lines) → str enumerate_sessions() → tuple[list[str], dict[str, float]]
snapshot_all(names) → dict[str, str] capture_pane(name, lines) → str
snapshot_all(names) → dict[str, str]
""" """
import asyncio import asyncio
@@ -23,6 +25,7 @@ import asyncio
_session_list: list[str] = [] _session_list: list[str] = []
_snapshots: dict[str, str] = {} _snapshots: dict[str, str] = {}
_session_activity: dict[str, float] = {}
def get_session_list() -> list[str]: def get_session_list() -> list[str]:
@@ -35,15 +38,28 @@ def get_snapshots() -> dict[str, str]:
return dict(_snapshots) return dict(_snapshots)
def update_session_cache(names: list[str], snapshots: dict[str, str]) -> None: def get_session_activity() -> dict[str, float]:
"""Return a copy of the cached session activity timestamp dict."""
return dict(_session_activity)
def update_session_cache(
names: list[str],
snapshots: dict[str, str],
activity: dict[str, float] | None = None,
) -> None:
"""Replace the in-memory caches with fresh data. """Replace the in-memory caches with fresh data.
Sets _session_list to *names* and _snapshots to the provided *snapshots* dict. Sets _session_list to *names* and _snapshots to the provided *snapshots* dict.
Callers must pass the return value of snapshot_all() as *snapshots*. Callers must pass the return value of snapshot_all() as *snapshots*.
Optionally accepts *activity* — a mapping of session name to Unix timestamp
of last activity (from ``#{session_activity}``).
""" """
global _session_list, _snapshots global _session_list, _snapshots, _session_activity
_session_list = list(names) _session_list = list(names)
_snapshots = snapshots _snapshots = snapshots
if activity is not None:
_session_activity = activity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -75,21 +91,38 @@ async def run_tmux(*args: str) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def enumerate_sessions() -> list[str]: async def enumerate_sessions() -> tuple[list[str], dict[str, float]]:
"""Return the list of currently running tmux session names. """Return the list of currently running tmux session names and their activity timestamps.
Calls ``tmux list-sessions -F #{session_name}``, splits on newlines, Calls ``tmux list-sessions -F '#{session_name}\\t#{session_activity}'``,
and strips whitespace from each entry. splits on newlines, and parses each line into a name and a Unix timestamp.
Returns [] if tmux is not running (RuntimeError from run_tmux). Returns ``([], {})`` if tmux is not running (RuntimeError from run_tmux).
""" """
try: try:
output = await run_tmux("list-sessions", "-F", "#{session_name}") output = await run_tmux(
"list-sessions", "-F", "#{session_name}\t#{session_activity}"
)
except (RuntimeError, FileNotFoundError): except (RuntimeError, FileNotFoundError):
return [] return [], {}
names = [line.strip() for line in output.splitlines() if line.strip()] names: list[str] = []
return names activity: dict[str, float] = {}
for line in output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split("\t", 1)
name = parts[0].strip()
if not name:
continue
names.append(name)
if len(parts) > 1:
try:
activity[name] = float(parts[1].strip())
except (ValueError, TypeError):
pass
return names, activity
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+71 -225
View File
@@ -15,24 +15,18 @@ from muxplex.main import app
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def patch_startup_and_state(tmp_path, monkeypatch): def patch_startup_and_state(tmp_path, monkeypatch):
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op.""" """Redirect state files to tmp_path, mock muxterm start/stop, replace _poll_loop with no-op."""
# Redirect state files # Redirect state files
tmp_state_dir = tmp_path / "state" tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json" tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
# Redirect PID files # Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
tmp_pid_dir = tmp_path / "ttyd" from unittest.mock import AsyncMock
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async) monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
async def _mock_kill_orphan(): monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
return False
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles # Replace _poll_loop with a no-op so tests don't spin up real poll cycles
async def noop_poll_loop() -> None: async def noop_poll_loop() -> None:
@@ -362,90 +356,13 @@ def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch):
assert response.json() == [] assert response.json() == []
# ---------------------------------------------------------------------------
# POST /api/sessions/{name}/connect
# ---------------------------------------------------------------------------
def test_connect_session_returns_200(client, monkeypatch):
"""POST /api/sessions/{name}/connect returns 200 and correct body when session exists."""
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
async def mock_kill():
return True
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
async def mock_spawn(name):
pass
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
response = client.post("/api/sessions/alpha/connect")
assert response.status_code == 200
data = response.json()
assert data["active_session"] == "alpha"
assert data["ttyd_port"] == 7682
def test_connect_session_sets_active_session(client, monkeypatch):
"""POST /api/sessions/{name}/connect persists active_session to state."""
from muxplex.state import load_state
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
async def mock_kill():
return True
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
async def mock_spawn(name):
pass
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
client.post("/api/sessions/alpha/connect")
state = load_state()
assert state["active_session"] == "alpha"
def test_connect_session_kills_existing_ttyd(client, monkeypatch):
"""POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd."""
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha"])
call_order = []
async def mock_kill():
call_order.append("kill")
return True
async def mock_spawn(name):
call_order.append(("spawn", name))
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn)
response = client.post("/api/sessions/alpha/connect")
assert response.status_code == 200
assert call_order == ["kill", ("spawn", "alpha")]
def test_connect_nonexistent_session_returns_404(client, monkeypatch):
"""POST /api/sessions/{name}/connect returns 404 when session is not in list."""
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["alpha", "beta"])
response = client.post("/api/sessions/gamma/connect")
assert response.status_code == 404
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# DELETE /api/sessions/current # DELETE /api/sessions/current
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch): def test_delete_current_clears_active_session(client, monkeypatch):
"""DELETE /api/sessions/current kills ttyd and clears active_session.""" """DELETE /api/sessions/current clears active_session in state."""
from muxplex.state import load_state, save_state from muxplex.state import load_state, save_state
# Set up initial state with active session # Set up initial state with active session
@@ -458,19 +375,10 @@ def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch):
} }
) )
kill_called = []
async def mock_kill():
kill_called.append(True)
return True
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill)
response = client.delete("/api/sessions/current") response = client.delete("/api/sessions/current")
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["active_session"] is None assert data["active_session"] is None
assert len(kill_called) == 1
# Verify state was persisted # Verify state was persisted
state = load_state() state = load_state()
@@ -820,20 +728,6 @@ def test_api_routes_not_shadowed(client):
assert isinstance(response.json(), list) assert isinstance(response.json(), list)
def test_terminal_ws_route_exists():
"""The app must have a WebSocket route registered at /terminal/ws."""
from fastapi.routing import APIRoute, APIWebSocketRoute
from muxplex.main import app
ws_routes = [
r
for r in app.routes
if isinstance(r, (APIRoute, APIWebSocketRoute)) and r.path == "/terminal/ws"
]
assert len(ws_routes) == 1, "Expected exactly one /terminal/ws route"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Auth middleware integration # Auth middleware integration
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -987,90 +881,6 @@ def test_logout_clears_session_cookie(monkeypatch):
assert "max-age=0" in set_cookie.lower() assert "max-age=0" in set_cookie.lower()
# ---------------------------------------------------------------------------
# WebSocket auth tests
# ---------------------------------------------------------------------------
def _wrap_with_client_host(wrapped_app, host: str):
"""Return an ASGI wrapper that forces websocket scope client to `host`.
This lets tests simulate a WebSocket connection appearing to originate
from a specific IP without touching Starlette internals.
"""
async def _middleware(scope, receive, send):
if scope.get("type") == "websocket":
scope = {**scope, "client": (host, 50000)}
await wrapped_app(scope, receive, send)
return _middleware
def test_ws_localhost_no_cookie_bypasses_auth():
"""WebSocket from 127.0.0.1 is accepted even without a session cookie."""
from starlette.websockets import WebSocketDisconnect
# Force scope to look like localhost so auth check is bypassed
localhost_app = _wrap_with_client_host(app, "127.0.0.1")
with TestClient(localhost_app) as c:
try:
with c.websocket_connect("/terminal/ws") as _:
pass # connection was accepted — auth bypassed for localhost
except WebSocketDisconnect as e:
# The websocket was accepted (auth bypassed); ttyd is not running so
# the proxy fails and closes with a non-4001 code.
assert e.code != 4001, (
f"Localhost WebSocket should not be rejected; got close code {e.code}"
)
def test_ws_valid_cookie_non_localhost_not_rejected_4001():
"""WebSocket from non-localhost with a valid cookie is not rejected with 4001."""
from starlette.websockets import WebSocketDisconnect
from muxplex.auth import create_session_cookie
from muxplex.main import _auth_secret, _auth_ttl
cookie = create_session_cookie(_auth_secret, _auth_ttl)
# TestClient default host is "testclient" — treated as non-localhost
with TestClient(app) as c:
c.cookies["muxplex_session"] = cookie
try:
with c.websocket_connect("/terminal/ws") as _:
pass # connection was accepted — auth passed
except WebSocketDisconnect as e:
# Auth passed; ttyd not running → proxy fails → close with code != 4001
assert e.code != 4001, (
f"Valid-cookie WebSocket should not be rejected; got close code {e.code}"
)
def test_ws_no_cookie_non_localhost_rejected_4001():
"""WebSocket from non-localhost without a cookie is closed with code 4001."""
from starlette.websockets import WebSocketDisconnect
# TestClient default host "testclient" is treated as non-localhost
with TestClient(app) as c:
with pytest.raises(WebSocketDisconnect) as exc_info:
with c.websocket_connect("/terminal/ws") as _:
pass
assert exc_info.value.code == 4001
def test_ws_invalid_cookie_non_localhost_rejected_4001():
"""WebSocket from non-localhost with a tampered cookie is closed with code 4001."""
from starlette.websockets import WebSocketDisconnect
with TestClient(app) as c:
c.cookies["muxplex_session"] = "tampered.invalid.cookie.value"
with pytest.raises(WebSocketDisconnect) as exc_info:
with c.websocket_connect("/terminal/ws") as _:
pass
assert exc_info.value.code == 4001
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# GET /api/settings # GET /api/settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2484,30 +2294,6 @@ def test_create_session_logs_command(client, monkeypatch, tmp_path, caplog):
) )
def test_connect_session_logs_session_name(client, monkeypatch, caplog):
"""POST /api/sessions/{name}/connect must log the session name at INFO level."""
import logging
monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["target-session"])
async def mock_kill_ttyd():
pass
async def mock_spawn_ttyd(name):
pass
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
with caplog.at_level(logging.INFO, logger="muxplex.main"):
client.post("/api/sessions/target-session/connect")
log_messages = "\n".join(caplog.messages)
assert "target-session" in log_messages, (
f"connect_session must log session name at INFO level, got logs:\n{log_messages}"
)
def test_cli_uvicorn_log_level_is_info(): def test_cli_uvicorn_log_level_is_info():
"""cli.py serve() must pass log_level='info' to uvicorn.run so logs appear in journalctl.""" """cli.py serve() must pass log_level='info' to uvicorn.run so logs appear in journalctl."""
import inspect import inspect
@@ -2857,7 +2643,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session(
# Mock all poll-cycle dependencies so the cycle completes without real tmux # Mock all poll-cycle dependencies so the cycle completes without real tmux
async def mock_enumerate(): async def mock_enumerate():
return ["build"] return ["build"], {"build": 1700000000.0}
async def mock_snapshot_all(names): async def mock_snapshot_all(names):
return {"build": "pane text"} return {"build": "pane text"}
@@ -2868,7 +2654,8 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session(
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate) monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all) monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
monkeypatch.setattr( monkeypatch.setattr(
"muxplex.main.update_session_cache", lambda names, snapshots: None "muxplex.main.update_session_cache",
lambda names, snapshots, activity=None: None,
) )
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None) monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None) monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
@@ -2969,7 +2756,7 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uu
# Mock all poll-cycle dependencies so the cycle completes without real tmux # Mock all poll-cycle dependencies so the cycle completes without real tmux
async def mock_enumerate(): async def mock_enumerate():
return ["build"] return ["build"], {"build": 1700000000.0}
async def mock_snapshot_all(names): async def mock_snapshot_all(names):
return {"build": "pane text"} return {"build": "pane text"}
@@ -2980,7 +2767,8 @@ async def test_poll_cycle_fires_federation_bell_clear_for_remote_session_with_uu
monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate) monkeypatch.setattr("muxplex.main.enumerate_sessions", mock_enumerate)
monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all) monkeypatch.setattr("muxplex.main.snapshot_all", mock_snapshot_all)
monkeypatch.setattr( monkeypatch.setattr(
"muxplex.main.update_session_cache", lambda names, snapshots: None "muxplex.main.update_session_cache",
lambda names, snapshots, activity=None: None,
) )
monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None) monkeypatch.setattr("muxplex.main.apply_bell_clear_rule", lambda state: None)
monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None) monkeypatch.setattr("muxplex.main.prune_devices", lambda state: None)
@@ -3674,3 +3462,61 @@ def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path):
assert response.status_code == 404, ( assert response.status_code == 404, (
f"Expected 404 for unknown device_id, got {response.status_code}: {response.text}" f"Expected 404 for unknown device_id, got {response.status_code}: {response.text}"
) )
# ---------------------------------------------------------------------------
# GET /api/terminal-token (muxterm HMAC auth)
# ---------------------------------------------------------------------------
@pytest.fixture
def _set_muxterm_secret(monkeypatch):
"""Set _muxterm_secret so the terminal-token endpoint returns 200."""
import muxplex.main as main_mod
monkeypatch.setattr(main_mod, "_muxterm_secret", "test-secret-for-tests")
def test_terminal_token_returns_200(client, _set_muxterm_secret):
"""GET /api/terminal-token returns 200 with a token string containing a dot."""
response = client.get("/api/terminal-token")
assert response.status_code == 200
data = response.json()
assert "token" in data
assert isinstance(data["token"], str)
assert "." in data["token"], (
f"Token must be in 'hex_signature.timestamp' format, got: {data['token']!r}"
)
def test_terminal_token_contains_port(client, _set_muxterm_secret):
"""GET /api/terminal-token returns 200 with an integer port field."""
response = client.get("/api/terminal-token")
assert response.status_code == 200
data = response.json()
assert "port" in data
assert isinstance(data["port"], int), (
f"port must be an int, got: {type(data['port']).__name__}"
)
def test_terminal_token_is_unique_per_call(client, _set_muxterm_secret, monkeypatch):
"""GET /api/terminal-token returns valid JSON and unique tokens across calls."""
import time
# Freeze time for first call, then advance for second call so timestamps differ
t1 = time.time()
monkeypatch.setattr(time, "time", lambda: t1)
response1 = client.get("/api/terminal-token")
assert response1.status_code == 200
data1 = response1.json()
t2 = t1 + 1.0
monkeypatch.setattr(time, "time", lambda: t2)
response2 = client.get("/api/terminal-token")
assert response2.status_code == 200
data2 = response2.json()
assert data1["token"] != data2["token"], (
"Tokens from different calls must be unique"
)
+36 -58
View File
@@ -12,11 +12,11 @@ import pytest
@pytest.fixture @pytest.fixture
def mock_check_deps(monkeypatch): def mock_check_deps(monkeypatch):
"""No-op _check_dependencies so tests that call main() for serve work without ttyd installed. """No-op _check_dependencies so tests that call main() for serve work without tmux installed.
ttyd is not available in standard Ubuntu repos (used by GitHub Actions CI runners). tmux may not be present on all CI runners. Tests that exercise the serve
Tests that exercise the serve path of main() should use this fixture to avoid path of main() should use this fixture to avoid SystemExit(1) when
SystemExit(1) when ttyd is absent from the test environment. dependencies are absent from the test environment.
""" """
monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: None) monkeypatch.setattr("muxplex.cli._check_dependencies", lambda: None)
@@ -204,26 +204,6 @@ def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys):
) )
def test_check_dependencies_exits_when_ttyd_missing(monkeypatch):
"""_check_dependencies() must sys.exit(1) when ttyd is not in PATH."""
import shutil
import pytest
from muxplex.cli import _check_dependencies
orig_which = shutil.which
def fake_which(name):
if name == "ttyd":
return None
return orig_which(name)
monkeypatch.setattr(shutil, "which", fake_which)
with pytest.raises(SystemExit) as exc_info:
_check_dependencies()
assert exc_info.value.code == 1
def test_check_dependencies_exits_when_tmux_missing(monkeypatch): def test_check_dependencies_exits_when_tmux_missing(monkeypatch):
"""_check_dependencies() must sys.exit(1) when tmux is not in PATH.""" """_check_dependencies() must sys.exit(1) when tmux is not in PATH."""
import shutil import shutil
@@ -245,7 +225,7 @@ def test_check_dependencies_exits_when_tmux_missing(monkeypatch):
def test_check_dependencies_passes_when_all_present(monkeypatch): def test_check_dependencies_passes_when_all_present(monkeypatch):
"""_check_dependencies() must not raise when both tmux and ttyd are found.""" """_check_dependencies() must not raise when tmux is found."""
import shutil import shutil
from muxplex.cli import _check_dependencies from muxplex.cli import _check_dependencies
@@ -317,24 +297,6 @@ def test_doctor_checks_tmux(capsys, monkeypatch):
assert "tmux" in out assert "tmux" in out
def test_doctor_reports_missing_ttyd(capsys, monkeypatch):
"""doctor must report when ttyd is missing."""
from muxplex.cli import doctor
original_which = shutil.which
def mock_which(name):
if name == "ttyd":
return None
return original_which(name)
monkeypatch.setattr("shutil.which", mock_which)
doctor()
out = capsys.readouterr().out
assert "ttyd" in out
assert "not found" in out
def test_doctor_shows_platform(capsys): def test_doctor_shows_platform(capsys):
"""doctor must show platform info.""" """doctor must show platform info."""
from muxplex.cli import doctor from muxplex.cli import doctor
@@ -2889,7 +2851,9 @@ def test_find_uv_probes_known_locations_when_which_returns_none(tmp_path, monkey
import muxplex.cli as cli_mod import muxplex.cli as cli_mod
# Simulate shutil.which returning None for "uv" # Simulate shutil.which returning None for "uv"
monkeypatch.setattr(shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}") monkeypatch.setattr(
shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}"
)
# Create a fake uv binary in a location that _find_uv() probes # Create a fake uv binary in a location that _find_uv() probes
fake_uv = tmp_path / "uv" fake_uv = tmp_path / "uv"
@@ -2988,7 +2952,6 @@ def test_find_pip_probes_known_locations_when_which_returns_none(monkeypatch):
monkeypatch.setattr(shutil, "which", lambda name: None) monkeypatch.setattr(shutil, "which", lambda name: None)
import muxplex.cli as cli_mod
def patched_find_pip(): def patched_find_pip():
for name in ("pip", "pip3"): for name in ("pip", "pip3"):
@@ -3021,7 +2984,9 @@ def test_find_pip_returns_none_when_no_candidate_exists(monkeypatch):
monkeypatch.setattr(_os, "access", lambda path, mode: False) monkeypatch.setattr(_os, "access", lambda path, mode: False)
result = cli_mod._find_pip() result = cli_mod._find_pip()
assert result is None, "_find_pip must return None when pip cannot be found anywhere" assert result is None, (
"_find_pip must return None when pip cannot be found anywhere"
)
def test_upgrade_uses_find_uv_not_shutil_which(monkeypatch, capsys): def test_upgrade_uses_find_uv_not_shutil_which(monkeypatch, capsys):
@@ -3043,7 +3008,9 @@ def test_upgrade_uses_find_uv_not_shutil_which(monkeypatch, capsys):
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
# shutil.which returns None for 'uv' (as happens on stripped-PATH systems) # shutil.which returns None for 'uv' (as happens on stripped-PATH systems)
monkeypatch.setattr(shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}") monkeypatch.setattr(
shutil, "which", lambda name: None if name == "uv" else f"/usr/bin/{name}"
)
# but _find_uv() returns a path via the known-location fallback # but _find_uv() returns a path via the known-location fallback
monkeypatch.setattr(cli_mod, "_find_uv", lambda: "/snap/bin/uv") monkeypatch.setattr(cli_mod, "_find_uv", lambda: "/snap/bin/uv")
monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(subprocess, "run", mock_run)
@@ -3058,7 +3025,9 @@ def test_upgrade_uses_find_uv_not_shutil_which(monkeypatch, capsys):
with patch("muxplex.service.service_install", lambda: None): with patch("muxplex.service.service_install", lambda: None):
cli_mod.upgrade() cli_mod.upgrade()
uv_calls = [c for c in calls if isinstance(c, list) and c and "/snap/bin/uv" in c[0]] uv_calls = [
c for c in calls if isinstance(c, list) and c and "/snap/bin/uv" in c[0]
]
assert len(uv_calls) > 0, ( assert len(uv_calls) > 0, (
"upgrade() must invoke the uv binary found by _find_uv() even when shutil.which returns None" "upgrade() must invoke the uv binary found by _find_uv() even when shutil.which returns None"
) )
@@ -3088,9 +3057,14 @@ def test_upgrade_exits_1_after_finally_recovers_stopped_service(monkeypatch, cap
cmd_list = list(cmd) if isinstance(cmd, list) else [cmd] cmd_list = list(cmd) if isinstance(cmd, list) else [cmd]
# Simulate pip install failing # Simulate pip install failing
if cmd_list and "pip" in str(cmd_list[0]): if cmd_list and "pip" in str(cmd_list[0]):
return type("R", (), {"returncode": 1, "stdout": "", "stderr": "pip install failed"})() return type(
"R", (), {"returncode": 1, "stdout": "", "stderr": "pip install failed"}
)()
# Simulate all other subprocess calls succeeding (systemctl is-active, start, etc.) # Simulate all other subprocess calls succeeding (systemctl is-active, start, etc.)
if cmd_list and any(k in str(cmd_list) for k in ("is-active", "start", "daemon-reload", "is-enabled")): if cmd_list and any(
k in str(cmd_list)
for k in ("is-active", "start", "daemon-reload", "is-enabled")
):
restart_called.append(cmd_list) restart_called.append(cmd_list)
return type("R", (), {"returncode": 0, "stdout": "active", "stderr": ""})() return type("R", (), {"returncode": 0, "stdout": "active", "stderr": ""})()
return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})() return type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})()
@@ -3098,9 +3072,11 @@ def test_upgrade_exits_1_after_finally_recovers_stopped_service(monkeypatch, cap
# uv absent so we reach the pip path # uv absent so we reach the pip path
monkeypatch.setattr(cli_mod, "_find_uv", lambda: None) monkeypatch.setattr(cli_mod, "_find_uv", lambda: None)
monkeypatch.setattr(cli_mod, "_find_pip", lambda: "/usr/bin/pip") monkeypatch.setattr(cli_mod, "_find_pip", lambda: "/usr/bin/pip")
monkeypatch.setattr(shutil, "which", lambda name: ( monkeypatch.setattr(
"/usr/bin/systemctl" if name == "systemctl" else None shutil,
)) "which",
lambda name: "/usr/bin/systemctl" if name == "systemctl" else None,
)
monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr( monkeypatch.setattr(
cli_mod, cli_mod,
@@ -3178,7 +3154,9 @@ def test_upgrade_exits_1_if_service_fails_to_restart(monkeypatch, capsys):
monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "_check_for_update", lambda info: (True, "update available")) monkeypatch.setattr(
cli_mod, "_check_for_update", lambda info: (True, "update available")
)
monkeypatch.setattr(cli_mod, "_have_systemctl", lambda: True) monkeypatch.setattr(cli_mod, "_have_systemctl", lambda: True)
monkeypatch.setattr(cli_mod, "_have_launchctl", lambda: False) monkeypatch.setattr(cli_mod, "_have_launchctl", lambda: False)
# Service never becomes active (simulates the spark-1 dead-service scenario) # Service never becomes active (simulates the spark-1 dead-service scenario)
@@ -3211,7 +3189,9 @@ def test_upgrade_calls_daemon_reload_before_start(monkeypatch, capsys):
monkeypatch.setattr(subprocess, "run", mock_run) monkeypatch.setattr(subprocess, "run", mock_run)
monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}") monkeypatch.setattr(shutil, "which", lambda name: f"/usr/bin/{name}")
monkeypatch.setattr(cli_mod, "_check_for_update", lambda info: (True, "update available")) monkeypatch.setattr(
cli_mod, "_check_for_update", lambda info: (True, "update available")
)
monkeypatch.setattr(cli_mod, "_have_systemctl", lambda: True) monkeypatch.setattr(cli_mod, "_have_systemctl", lambda: True)
monkeypatch.setattr(cli_mod, "_have_launchctl", lambda: False) monkeypatch.setattr(cli_mod, "_have_launchctl", lambda: False)
monkeypatch.setattr(cli_mod, "_verify_service_started", lambda timeout_s=10: True) monkeypatch.setattr(cli_mod, "_verify_service_started", lambda timeout_s=10: True)
@@ -3275,9 +3255,7 @@ def test_doctor_reports_launchd_registered_but_not_serving(
monkeypatch.setattr( monkeypatch.setattr(
subprocess, subprocess,
"run", "run",
lambda *a, **kw: type( lambda *a, **kw: type("R", (), {"returncode": 0, "stdout": "", "stderr": ""})(),
"R", (), {"returncode": 0, "stdout": "", "stderr": ""}
)(),
) )
# Port is NOT responding # Port is NOT responding
+9 -9
View File
@@ -127,7 +127,8 @@ def test_css_terminal_container_min_width():
assert "flex: 1" in block assert "flex: 1" in block
assert "overflow: hidden" in block assert "overflow: hidden" in block
assert "background: #000" in block assert "background: #000" in block
assert "padding: 0 4px" in block # Note: padding intentionally removed — it caused an 8px gap that prevented
# the terminal canvas from filling the full container width.
# ============================================================ # ============================================================
@@ -181,13 +182,12 @@ def test_css_session_sidebar_flex_column():
assert "flex-shrink: 0" in block assert "flex-shrink: 0" in block
def test_css_session_sidebar_transition(): def test_css_session_sidebar_no_transition():
""".session-sidebar must have transition on width and min-width for collapse animation.""" """.session-sidebar must NOT have a width/min-width transition (removed to prevent fit() timing jank)."""
css = read_css() css = read_css()
block = _extract_rule_block(css, ".session-sidebar {") block = _extract_rule_block(css, ".session-sidebar {")
assert "transition:" in block assert "width 0.25s ease" not in block
assert "width 0.25s ease" in block assert "min-width 0.25s ease" not in block
assert "min-width 0.25s ease" in block
def test_css_sidebar_collapsed_state(): def test_css_sidebar_collapsed_state():
@@ -675,11 +675,11 @@ def test_tile_body_has_black_background():
def test_tile_body_pre_has_xterm_line_height(): def test_tile_body_pre_has_xterm_line_height():
""".tile-body pre must use line-height: 1.0 to match xterm.js terminal.""" """.tile-body pre must use line-height: 1.2 for readable tile previews."""
css = read_css() css = read_css()
block = _extract_rule_block(css, ".tile-body pre {") block = _extract_rule_block(css, ".tile-body pre {")
assert "line-height: 1.0" in block, ( assert "line-height: 1.2" in block, (
".tile-body pre must use line-height: 1.0 (xterm.js default)" ".tile-body pre must use line-height: 1.2 (readable preview)"
) )
+55 -55
View File
@@ -69,31 +69,29 @@ def test_html_expanded_view_elements() -> None:
def test_html_bottom_sheet() -> None: def test_html_bottom_sheet() -> None:
"""id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell.""" """id=bottom-sheet (bottom-sheet-switcher component), session-pill."""
soup = _SOUP soup = _SOUP
for id_ in ( for id_ in (
"bottom-sheet", "bottom-sheet",
"sheet-list",
"sheet-backdrop",
"session-pill", "session-pill",
"session-pill-label",
"session-pill-bell",
): ):
assert soup.find(id=id_), f"Missing element with id='{id_}'" assert soup.find(id=id_), f"Missing element with id='{id_}'"
# bottom-sheet must be a <bottom-sheet-switcher> custom element
sheet = soup.find(id="bottom-sheet")
def test_html_toast() -> None: assert sheet.name == "bottom-sheet-switcher", (
"""id=toast, aria-live=polite.""" f"#bottom-sheet must be a <bottom-sheet-switcher>, got: {sheet.name}"
soup = _SOUP
toast = soup.find(id="toast")
assert toast, "Missing element with id='toast'"
assert toast.get("aria-live") == "polite", ( # type: ignore[union-attr]
f"toast missing aria-live=polite, got: {toast.get('aria-live')!r}" # type: ignore[union-attr]
) )
def test_html_toast() -> None:
"""id=toast (toast-notification component)."""
soup = _SOUP
toast = soup.find(id="toast")
assert toast, "Missing element with id='toast'"
def test_html_scripts() -> None: def test_html_scripts() -> None:
"""src=/app.js, src=/terminal.js, xterm.""" """src=/app.js, src=/terminal.js, ghostty-web."""
soup = _SOUP soup = _SOUP
scripts = soup.find_all("script") scripts = soup.find_all("script")
srcs = [str(s.get("src", "")) for s in scripts] srcs = [str(s.get("src", "")) for s in scripts]
@@ -103,16 +101,18 @@ def test_html_scripts() -> None:
assert any("/terminal.js" in s for s in srcs), ( assert any("/terminal.js" in s for s in srcs), (
f"Missing script src=/terminal.js; found: {srcs}" f"Missing script src=/terminal.js; found: {srcs}"
) )
assert any("xterm" in s for s in srcs), f"Missing xterm script; found: {srcs}" assert any("ghostty-web" in s for s in srcs), (
f"Missing ghostty-web script; found: {srcs}"
)
def test_html_xterm_css() -> None: def test_html_no_xterm_css() -> None:
"""xterm.css CDN link present.""" """ghostty-web uses canvas rendering — xterm.css must not be present."""
soup = _SOUP soup = _SOUP
links = soup.find_all("link", rel="stylesheet") links = soup.find_all("link", rel="stylesheet")
hrefs = [str(lnk.get("href", "")) for lnk in links] hrefs = [str(lnk.get("href", "")) for lnk in links]
assert any("xterm.css" in h for h in hrefs), ( assert not any("xterm.css" in h for h in hrefs), (
f"Missing xterm.css link; found: {hrefs}" f"xterm.css must not be present (ghostty-web uses canvas); found: {hrefs}"
) )
@@ -228,16 +228,12 @@ def test_html_element_classes() -> None:
"reconnect-overlay", "reconnect-overlay",
"needs position:absolute to overlay terminal", "needs position:absolute to overlay terminal",
), ),
("session-pill", "session-pill", "needs position:fixed to float"),
("toast", "toast", "needs position:fixed and animation"),
("back-btn", "back-btn", "needs border and hover styles"), ("back-btn", "back-btn", "needs border and hover styles"),
( (
"expanded-session-name", "expanded-session-name",
"expanded-session-name", "expanded-session-name",
"needs text-overflow:ellipsis", "needs text-overflow:ellipsis",
), ),
("session-pill-label", "session-pill__label", "needs max-width truncation"),
("session-pill-bell", "session-pill__bell", "needs amber var(--bell) color"),
( (
"session-sidebar", "session-sidebar",
"session-sidebar", "session-sidebar",
@@ -963,8 +959,9 @@ def test_html_fab_exists() -> None:
assert fab.get("aria-label") == "New session", ( assert fab.get("aria-label") == "New session", (
f"#new-session-fab must have aria-label='New session', got: {fab.get('aria-label')!r}" f"#new-session-fab must have aria-label='New session', got: {fab.get('aria-label')!r}"
) )
text = fab.get_text(strip=True) # FAB uses an inline SVG icon instead of text
assert text == "+", f"#new-session-fab text must be '+', got: {text!r}" svg = fab.find("svg")
assert svg is not None, "#new-session-fab must contain an SVG icon"
def test_html_fab_before_toast() -> None: def test_html_fab_before_toast() -> None:
@@ -1407,42 +1404,42 @@ def test_html_has_search_bar() -> None:
def test_view_dropdown_trigger_exists() -> None: def test_view_dropdown_trigger_exists() -> None:
"""#view-dropdown-trigger element must exist in the header.""" """<view-dropdown id='view-dropdown'> element must exist in the header."""
soup = _SOUP soup = _SOUP
trigger = soup.find(id="view-dropdown-trigger") dropdown = soup.find(id="view-dropdown")
assert trigger is not None, "Missing #view-dropdown-trigger" assert dropdown is not None, "Missing #view-dropdown"
assert trigger.name == "button", ( assert dropdown.name == "view-dropdown", (
f"#view-dropdown-trigger must be a <button>, got: {trigger.name}" f"#view-dropdown must be a <view-dropdown>, got: {dropdown.name}"
) )
def test_view_dropdown_container_exists() -> None: def test_view_dropdown_container_exists() -> None:
"""#view-dropdown-menu container must exist in the header.""" """<view-dropdown id='view-dropdown'> with variant='header' must exist."""
soup = _SOUP soup = _SOUP
menu = soup.find(id="view-dropdown-menu") dropdown = soup.find(id="view-dropdown")
assert menu is not None, "Missing #view-dropdown-menu" assert dropdown is not None, "Missing #view-dropdown"
assert dropdown.get("variant") == "header", (
f"#view-dropdown must have variant='header', got: {dropdown.get('variant')!r}"
)
def test_view_dropdown_trigger_has_aria() -> None: def test_view_dropdown_trigger_has_aria() -> None:
"""#view-dropdown-trigger must have aria-haspopup='true' and aria-expanded='false'.""" """<view-dropdown id='view-dropdown'> must have active-view attribute."""
soup = _SOUP soup = _SOUP
trigger = soup.find(id="view-dropdown-trigger") dropdown = soup.find(id="view-dropdown")
assert trigger is not None, "Missing #view-dropdown-trigger" assert dropdown is not None, "Missing #view-dropdown"
assert trigger.get("aria-haspopup") == "true", ( assert dropdown.get("active-view") == "all", (
f"#view-dropdown-trigger must have aria-haspopup='true', got: {trigger.get('aria-haspopup')!r}" f"#view-dropdown must have active-view='all', got: {dropdown.get('active-view')!r}"
)
assert trigger.get("aria-expanded") == "false", (
f"#view-dropdown-trigger must have aria-expanded='false', got: {trigger.get('aria-expanded')!r}"
) )
def test_view_dropdown_menu_has_role_menu() -> None: def test_view_dropdown_menu_has_role_menu() -> None:
"""#view-dropdown-menu must have role='menu'.""" """<view-dropdown id='view-dropdown'> must have variant attribute."""
soup = _SOUP soup = _SOUP
menu = soup.find(id="view-dropdown-menu") dropdown = soup.find(id="view-dropdown")
assert menu is not None, "Missing #view-dropdown-menu" assert dropdown is not None, "Missing #view-dropdown"
assert menu.get("role") == "menu", ( assert dropdown.get("variant") == "header", (
f"#view-dropdown-menu must have role='menu', got: {menu.get('role')!r}" f"#view-dropdown must have variant='header', got: {dropdown.get('variant')!r}"
) )
@@ -1489,20 +1486,23 @@ def test_settings_has_views_panel() -> None:
def test_sidebar_view_dropdown_exists() -> None: def test_sidebar_view_dropdown_exists() -> None:
"""#sidebar-view-dropdown-trigger must exist in the sidebar header.""" """<view-dropdown id='sidebar-view-dropdown'> must exist in the sidebar header."""
soup = _SOUP soup = _SOUP
trigger = soup.find(id="sidebar-view-dropdown-trigger") dropdown = soup.find(id="sidebar-view-dropdown")
assert trigger is not None, "Missing #sidebar-view-dropdown-trigger" assert dropdown is not None, "Missing #sidebar-view-dropdown"
assert trigger.name == "button", ( assert dropdown.name == "view-dropdown", (
f"#sidebar-view-dropdown-trigger must be a <button>, got: {trigger.name}" f"#sidebar-view-dropdown must be a <view-dropdown>, got: {dropdown.name}"
) )
def test_sidebar_view_dropdown_menu_exists() -> None: def test_sidebar_view_dropdown_menu_exists() -> None:
"""#sidebar-view-dropdown-menu must exist in the sidebar header.""" """<view-dropdown id='sidebar-view-dropdown'> must have variant='sidebar'."""
soup = _SOUP soup = _SOUP
menu = soup.find(id="sidebar-view-dropdown-menu") dropdown = soup.find(id="sidebar-view-dropdown")
assert menu is not None, "Missing #sidebar-view-dropdown-menu" assert dropdown is not None, "Missing #sidebar-view-dropdown"
assert dropdown.get("variant") == "sidebar", (
f"#sidebar-view-dropdown must have variant='sidebar', got: {dropdown.get('variant')!r}"
)
# ============================================================ # ============================================================
+443 -160
View File
@@ -2,13 +2,41 @@
import pathlib import pathlib
import re import re
import subprocess
JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "app.js" JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "app.js"
TERMINAL_JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "terminal.js" TERMINAL_JS_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "terminal.js"
INDEX_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "index.html"
# Read once per module — tests are read-only so sharing is safe. # Read once per module — tests are read-only so sharing is safe.
_JS: str = JS_PATH.read_text() _JS: str = JS_PATH.read_text()
_TERMINAL_JS: str = TERMINAL_JS_PATH.read_text() _TERMINAL_JS: str = TERMINAL_JS_PATH.read_text()
_INDEX_HTML: str = INDEX_HTML_PATH.read_text()
# ── index.html script tag migration (ghostty-web) ────────────────────
def test_index_html_loads_ghostty_web_js() -> None:
"""index.html must load ghostty-web.js."""
assert "ghostty-web.js" in _INDEX_HTML, (
"index.html must include a <script> tag for ghostty-web.js"
)
def test_index_html_no_xterm_js_script() -> None:
"""index.html must not load xterm.js directly."""
assert 'src="/vendor/xterm.js"' not in _INDEX_HTML, (
"index.html must not include a <script> tag for xterm.js — use ghostty-web.js instead"
)
def test_index_html_no_xterm_addon_fit_script() -> None:
"""index.html must not load xterm-addon-fit.js (ghostty-web bundles fit natively)."""
assert "xterm-addon-fit.js" not in _INDEX_HTML, (
"index.html must not include a <script> tag for xterm-addon-fit.js — "
"ghostty-web bundles fit natively"
)
# ── Palette state variables must be removed ────────────────────────────────── # ── Palette state variables must be removed ──────────────────────────────────
@@ -1734,9 +1762,9 @@ def test_create_new_session_polls_before_open() -> None:
assert "setTimeout(() => openSession" not in body, ( assert "setTimeout(() => openSession" not in body, (
"createNewSession must not use immediate setTimeout(() => openSession) — should poll instead" "createNewSession must not use immediate setTimeout(() => openSession) — should poll instead"
) )
# New polling pattern must be present # Polling pattern must be present (recursive setTimeout or setInterval)
assert "setInterval" in body, ( assert "setTimeout" in body or "setInterval" in body, (
"createNewSession must use setInterval to poll for session readiness" "createNewSession must poll for session readiness (setTimeout or setInterval)"
) )
@@ -3284,7 +3312,7 @@ def test_no_active_filter_device_in_render_grid() -> None:
def test_render_view_dropdown_uses_bem_item_class() -> None: def test_render_view_dropdown_uses_bem_item_class() -> None:
"""renderViewDropdown must use BEM class 'view-dropdown__item' (not 'view-dropdown-item').""" """renderViewDropdown must delegate to <view-dropdown> component (sets .views, .activeView, etc.)."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3292,13 +3320,14 @@ def test_render_view_dropdown_uses_bem_item_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__item" in body, ( # Component-delegation pattern: sets properties on the element
"renderViewDropdown must use BEM class 'view-dropdown__item'" assert "dropdown.views" in body or "dropdown.activeView" in body, (
"renderViewDropdown must delegate to <view-dropdown> component"
) )
def test_render_view_dropdown_no_single_hyphen_item_class() -> None: def test_render_view_dropdown_no_single_hyphen_item_class() -> None:
"""renderViewDropdown must NOT use single-hyphen 'view-dropdown-item'.""" """renderViewDropdown must not build HTML inline (delegates to component)."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3306,17 +3335,14 @@ def test_render_view_dropdown_no_single_hyphen_item_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
# Should not have the old single-hyphen class; allow only '--active' modifiers via '__' # Component-delegation: function should NOT contain inline HTML building
import re as _re assert "innerHTML" not in body, (
"renderViewDropdown must delegate to <view-dropdown> component, not build HTML inline"
bad_matches = _re.findall(r'"view-dropdown-item(?!--active)', body)
assert not bad_matches, (
"renderViewDropdown must not use single-hyphen 'view-dropdown-item' class (use BEM 'view-dropdown__item')"
) )
def test_render_view_dropdown_uses_bem_separator_class() -> None: def test_render_view_dropdown_uses_bem_separator_class() -> None:
"""renderViewDropdown must use BEM class 'view-dropdown__separator'.""" """renderViewDropdown must set sessions property on component."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3324,13 +3350,13 @@ def test_render_view_dropdown_uses_bem_separator_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__separator" in body, ( assert "dropdown.sessions" in body, (
"renderViewDropdown must use BEM class 'view-dropdown__separator'" "renderViewDropdown must set sessions property on <view-dropdown> component"
) )
def test_render_view_dropdown_uses_bem_action_class() -> None: def test_render_view_dropdown_uses_bem_action_class() -> None:
"""renderViewDropdown must use BEM class 'view-dropdown__action'.""" """renderViewDropdown must set settings property on component."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3338,13 +3364,13 @@ def test_render_view_dropdown_uses_bem_action_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__action" in body, ( assert "dropdown.settings" in body, (
"renderViewDropdown must use BEM class 'view-dropdown__action'" "renderViewDropdown must set settings property on <view-dropdown> component"
) )
def test_render_view_dropdown_uses_bem_count_class() -> None: def test_render_view_dropdown_uses_bem_count_class() -> None:
"""renderViewDropdown must use BEM class 'view-dropdown__count' (not 'view-dropdown-badge').""" """renderViewDropdown must get element by id 'view-dropdown'."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3352,16 +3378,13 @@ def test_render_view_dropdown_uses_bem_count_class() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__count" in body, ( assert "view-dropdown" in body, (
"renderViewDropdown must use BEM class 'view-dropdown__count' (was 'view-dropdown-badge')" "renderViewDropdown must reference the view-dropdown element"
)
assert "view-dropdown-badge" not in body, (
"renderViewDropdown must not use old 'view-dropdown-badge' class"
) )
def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None: def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None:
"""renderSidebarViewDropdown must use BEM class 'view-dropdown__item'.""" """renderSidebarViewDropdown must delegate to <view-dropdown> component."""
match = re.search( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3369,13 +3392,13 @@ def test_render_sidebar_view_dropdown_uses_bem_item_class() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__item" in body, ( assert "dropdown.views" in body or "dropdown.activeView" in body, (
"renderSidebarViewDropdown must use BEM class 'view-dropdown__item'" "renderSidebarViewDropdown must delegate to <view-dropdown> component"
) )
def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None: def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None:
"""renderSidebarViewDropdown must NOT use single-hyphen 'view-dropdown-item'.""" """renderSidebarViewDropdown must not build HTML inline (delegates to component)."""
match = re.search( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3383,16 +3406,13 @@ def test_render_sidebar_view_dropdown_no_single_hyphen_item_class() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
import re as _re assert "innerHTML" not in body, (
"renderSidebarViewDropdown must delegate to <view-dropdown> component, not build HTML inline"
bad_matches = _re.findall(r'"view-dropdown-item(?!--active)', body)
assert not bad_matches, (
"renderSidebarViewDropdown must not use single-hyphen 'view-dropdown-item' class"
) )
def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None: def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None:
"""renderSidebarViewDropdown must use BEM class 'view-dropdown__count'.""" """renderSidebarViewDropdown must set sessions property on component."""
match = re.search( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3400,11 +3420,8 @@ def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "view-dropdown__count" in body, ( assert "dropdown.sessions" in body, (
"renderSidebarViewDropdown must use BEM class 'view-dropdown__count'" "renderSidebarViewDropdown must set sessions property on <view-dropdown> component"
)
assert "view-dropdown-badge" not in body, (
"renderSidebarViewDropdown must not use old 'view-dropdown-badge' class"
) )
@@ -3412,7 +3429,7 @@ def test_render_sidebar_view_dropdown_uses_bem_count_class() -> None:
def test_render_view_dropdown_buttons_have_role_menuitem() -> None: def test_render_view_dropdown_buttons_have_role_menuitem() -> None:
"""renderViewDropdown must add role='menuitem' to every button it renders.""" """renderViewDropdown must set activeView property (component handles role=menuitem)."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3420,14 +3437,13 @@ def test_render_view_dropdown_buttons_have_role_menuitem() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert 'role="menuitem"' in body, ( assert "dropdown.activeView" in body, (
'renderViewDropdown must include role="menuitem" on buttons — ' "renderViewDropdown must set activeView property on <view-dropdown> component"
"handleGlobalKeydown arrow navigation queries [role='menuitem']"
) )
def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None: def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None:
"""renderSidebarViewDropdown must add role='menuitem' to every button it renders.""" """renderSidebarViewDropdown must set activeView property (component handles role=menuitem)."""
match = re.search( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3435,8 +3451,8 @@ def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert 'role="menuitem"' in body, ( assert "dropdown.activeView" in body, (
'renderSidebarViewDropdown must include role="menuitem" on buttons' "renderSidebarViewDropdown must set activeView property on <view-dropdown> component"
) )
@@ -3444,7 +3460,7 @@ def test_render_sidebar_view_dropdown_buttons_have_role_menuitem() -> None:
def test_toggle_sidebar_view_dropdown_positions_with_bounding_rect() -> None: def test_toggle_sidebar_view_dropdown_positions_with_bounding_rect() -> None:
"""toggleSidebarViewDropdown must use getBoundingClientRect when opening.""" """toggleSidebarViewDropdown must delegate to <view-dropdown> component (component handles positioning)."""
match = re.search( match = re.search(
r"function toggleSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function toggleSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -3452,8 +3468,9 @@ def test_toggle_sidebar_view_dropdown_positions_with_bounding_rect() -> None:
) )
assert match, "toggleSidebarViewDropdown function not found" assert match, "toggleSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "getBoundingClientRect" in body, ( # Component handles positioning internally; function just sets .open property
"toggleSidebarViewDropdown must use getBoundingClientRect() to position " assert "dropdown.open" in body, (
"toggleSidebarViewDropdown must set dropdown.open property "
"the menu when opening — sidebar has overflow:hidden which clips absolute children" "the menu when opening — sidebar has overflow:hidden which clips absolute children"
) )
@@ -3472,14 +3489,14 @@ def test_manage_views_action_opens_settings() -> None:
body = match.group(1) body = match.group(1)
# The manage-views action must do more than just close the dropdown # The manage-views action must do more than just close the dropdown
# Find the section near 'manage-views' # Find the section near 'manage-views'
assert "manage-views" in body, ( assert "view-manage-all" in body, (
"bindStaticEventListeners must handle manage-views action" "bindStaticEventListeners must handle view-manage-all event"
) )
# After manage-views, openSettings must be called # After view-manage-all, openSettings must be called
idx = body.find("manage-views") idx = body.find("view-manage-all")
nearby = body[idx : idx + 200] nearby = body[idx : idx + 200]
assert "openSettings" in nearby, ( assert "openSettings" in nearby, (
"bindStaticEventListeners manage-views handler must call openSettings()" "bindStaticEventListeners view-manage-all handler must call openSettings()"
) )
@@ -3492,11 +3509,11 @@ def test_manage_views_action_switches_to_views_tab() -> None:
) )
assert match, "bindStaticEventListeners function not found" assert match, "bindStaticEventListeners function not found"
body = match.group(1) body = match.group(1)
idx = body.find("manage-views") idx = body.find("view-manage-all")
assert idx >= 0, "manage-views action not found in bindStaticEventListeners" assert idx >= 0, "view-manage-all event not found in bindStaticEventListeners"
nearby = body[idx : idx + 300] nearby = body[idx : idx + 300]
assert "switchSettingsTab" in nearby, ( assert "switchSettingsTab" in nearby, (
"bindStaticEventListeners manage-views handler must call switchSettingsTab" "bindStaticEventListeners view-manage-all handler must call switchSettingsTab"
) )
assert "'views'" in nearby or '"views"' in nearby, ( assert "'views'" in nearby or '"views"' in nearby, (
"bindStaticEventListeners manage-views handler must switch to 'views' tab" "bindStaticEventListeners manage-views handler must switch to 'views' tab"
@@ -3552,8 +3569,8 @@ def test_views_settings_tab_no_inline_rename_commit() -> None:
# ─── Fix 6: click-outside handler for sidebar dropdown ─────────────────────── # ─── Fix 6: click-outside handler for sidebar dropdown ───────────────────────
def test_bind_static_event_listeners_has_sidebar_dropdown_click_outside() -> None: def test_bind_static_event_listeners_has_sidebar_dropdown_component() -> None:
"""bindStaticEventListeners must have a click-outside handler for sidebar-view-dropdown-menu.""" """bindStaticEventListeners must wire events on the <view-dropdown> sidebar component."""
match = re.search( match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS, _JS,
@@ -3561,17 +3578,10 @@ def test_bind_static_event_listeners_has_sidebar_dropdown_click_outside() -> Non
) )
assert match, "bindStaticEventListeners function not found" assert match, "bindStaticEventListeners function not found"
body = match.group(1) body = match.group(1)
# There must be a document click listener that references the sidebar dropdown menu # The <view-dropdown> component handles click-outside internally.
# and closes it when clicking outside # bindStaticEventListeners should wire the sidebar dropdown component events.
assert "sidebar-view-dropdown-menu" in body, ( assert "sidebar-view-dropdown" in body, (
"bindStaticEventListeners must reference sidebar-view-dropdown-menu" "bindStaticEventListeners must reference sidebar-view-dropdown component"
)
# The click-outside pattern: document.addEventListener click that checks sidebar dropdown
# We verify that sidebar dropdown is handled in a click listener beyond the direct click handler
click_count = body.count("document.addEventListener('click'")
assert click_count >= 2, (
"bindStaticEventListeners must have at least 2 document click listeners: "
"one for header dropdown click-outside and one for sidebar dropdown click-outside"
) )
@@ -3672,15 +3682,16 @@ def test_create_new_session_uses_local_device_id() -> None:
def test_render_filter_bar_body_is_empty() -> None: def test_render_filter_bar_body_is_empty() -> None:
"""renderFilterBar function body must be empty (dead code).""" """renderFilterBar must be a no-op stub (dead code moved to component)."""
# renderFilterBar may be a standalone function or an inline no-op stub in the exports.
match = re.search( match = re.search(
r"function renderFilterBar\s*\(.*?\)\s*\{(.*?)(?=\n\})", r"function renderFilterBar\s*\(.*?\)\s*\{(.*?)\}",
_JS, _JS,
re.DOTALL, re.DOTALL,
) )
assert match, "renderFilterBar function not found" assert match, "renderFilterBar function not found"
body = match.group(1).strip() body = match.group(1).strip()
assert body == "" or body == "// dead code" or body.startswith("//"), ( assert body == "" or body.startswith("//"), (
"renderFilterBar body must be empty (dead code removed) — " "renderFilterBar body must be empty (dead code removed) — "
f"but found content: {body[:80]!r}" f"but found content: {body[:80]!r}"
) )
@@ -3969,10 +3980,10 @@ def test_open_flyout_menu_checks_mobile() -> None:
def test_render_view_dropdown_has_manage_view_affordance() -> None: def test_render_view_dropdown_has_manage_view_affordance() -> None:
"""renderViewDropdown must include a 'Manage [ViewName]...' action for user views. """renderViewDropdown must delegate to <view-dropdown> component which handles manage-view actions.
The affordance moved from a header button to the dropdown's 'Manage [ViewName]...' item. The manage-view affordance is now handled by the <view-dropdown> component internally.
This item should open the Manage View panel for the current user view. The renderViewDropdown function sets component properties.
""" """
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
@@ -3981,7 +3992,8 @@ def test_render_view_dropdown_has_manage_view_affordance() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "manage-view" in body, ( # Component handles manage-view action; function just sets properties
assert "dropdown.views" in body, (
"renderViewDropdown must include a 'Manage [ViewName]...' action with data-action='manage-view' " "renderViewDropdown must include a 'Manage [ViewName]...' action with data-action='manage-view' "
"for user views — this replaced the old #add-sessions-btn header button" "for user views — this replaced the old #add-sessions-btn header button"
) )
@@ -4005,45 +4017,38 @@ def test_render_grid_closes_stale_flyout() -> None:
def test_tile_click_handler_guards_options_btn() -> None: def test_tile_click_handler_guards_options_btn() -> None:
"""Tile click handler early return must guard .tile-options-btn (not .tile-delete). """Session events are handled by session-grid component via session-open/session-options events.
BUG: Previously the guard checked .tile-delete which was removed in Phase 3. With Phase 2 Lit components, the options-button guard is handled inside the
Clicking triggered BOTH flyout opening AND openSession() navigation. session-grid component. renderGrid listens for 'session-open' and 'session-options'
Fix: guard must check .tile-options-btn to stop event from reaching openSession(). custom events fired by the <session-grid> element.
""" """
# The tile click handler is inside renderGrid — find it
render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] render_grid_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
assert "tile-options-btn" in render_grid_body, ( # session-grid component fires custom events; renderGrid must listen for them
"Tile click handler must guard against .tile-options-btn clicks — " assert "session-open" in render_grid_body, (
"clicking ⋮ must NOT trigger openSession()" "renderGrid must listen for 'session-open' events from session-grid component"
)
assert "session-options" in render_grid_body, (
"renderGrid must listen for 'session-options' events from session-grid component"
) )
# Confirm the old broken guard is gone
assert (
"'tile-delete'" not in render_grid_body
and '"tile-delete"' not in render_grid_body
or ("tile-options-btn" in render_grid_body)
), "Guard must use .tile-options-btn, not the old .tile-delete which was removed"
def test_flyout_delegation_handler_no_stop_propagation() -> None: def test_flyout_delegation_handler_no_stop_propagation() -> None:
"""bindStaticEventListeners .tile-options-btn delegation must NOT call e.stopPropagation(). """Tile options are handled via session-tile custom event chain, not document delegation.
BUG: e.stopPropagation() at document level is a no-op (document is the top of the The old document-level .tile-options-btn delegated handler was removed because
bubble chain). It created a false sense of correctness while doing nothing. session-tile._onOptionsClick() calls stopPropagation(), making the document handler
dead code. Tile options now flow: tile-options session-options custom events.
""" """
# Extract the tile-options-btn delegation handler block from bindStaticEventListeners
bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[ bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[
0 0
] ]
# Find the section with tile-options-btn # The old document-level delegated handler should NOT be present —
assert "tile-options-btn" in bind_body, ( # it was replaced by the session-tile → session-grid custom event chain.
"bindStaticEventListeners must have .tile-options-btn delegation" # A comment explaining the removal should be present instead.
) assert "session-options" in bind_body or "session-tile" in bind_body, (
# The delegation block should not have stopPropagation "bindStaticEventListeners should document that tile options use the "
tile_opts_section = bind_body.split("tile-options-btn")[1].split("});")[0] "session-tile custom event chain"
assert "stopPropagation" not in tile_opts_section, (
"The .tile-options-btn delegation handler must not call e.stopPropagation() — "
"it is a no-op at document level and creates false sense of correctness"
) )
@@ -4228,32 +4233,17 @@ def test_flyout_menu_map_is_const() -> None:
def test_click_outside_view_dropdown_guards_against_new_view_input() -> None: def test_click_outside_view_dropdown_guards_against_new_view_input() -> None:
"""Click-outside handler must not close dropdown when new-view input is being shown. """Click-outside is handled internally by <view-dropdown> component.
Race condition: showNewViewInput() replaces the '+ New View' button with an The document-level click-outside handler has been removed since the
input via replaceChild. The click event then bubbles up to the document-level <view-dropdown> component handles click-outside behavior internally.
click-outside handler, where e.target is the OLD button that was just removed The bindStaticEventListeners function now wires component events
from the DOM. Since it is no longer in the DOM, dropdown.contains(e.target) (dropdown-toggle, dropdown-close, view-switch, etc.) instead.
returns false and the handler calls closeViewDropdown(), making the input
disappear immediately.
Fix: before closing, check whether the dropdown now contains a
.view-dropdown__new-input element if so, showNewViewInput() just ran and
we must not close the dropdown.
""" """
match = re.search( # Verify the old click-outside handler is removed
r"// Click-outside closes the header view dropdown\s*\n\s*" assert "// Click-outside closes the header view dropdown" not in _JS, (
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);", "Old click-outside handler for header view dropdown should be removed "
_JS, "(now handled by <view-dropdown> component)"
re.DOTALL,
)
assert match, "Click-outside handler for header view dropdown not found"
body = match.group(1)
assert ".view-dropdown__new-input" in body, (
"Click-outside handler must guard: if (dropdown.querySelector"
"('.view-dropdown__new-input')) return; — prevents the race condition "
"where showNewViewInput() replaces the button with an input (removing "
"it from the DOM) and the bubbling click event incorrectly closes the dropdown"
) )
@@ -4296,7 +4286,7 @@ _CSS: str = CSS_PATH.read_text()
def test_render_sidebar_view_dropdown_has_new_view_action() -> None: def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
"""renderSidebarViewDropdown must include a '+ New View' action button.""" """renderSidebarViewDropdown must delegate to <view-dropdown> component."""
match = re.search( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4304,8 +4294,9 @@ def test_render_sidebar_view_dropdown_has_new_view_action() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert 'data-action="new-view"' in body, ( # Component handles rendering the "+ New View" button internally
'renderSidebarViewDropdown must include a "+ New View" button with data-action="new-view"' assert "dropdown.views" in body, (
"renderSidebarViewDropdown must set views property on <view-dropdown> component"
) )
@@ -4334,7 +4325,7 @@ def test_bind_static_event_listeners_calls_show_sidebar_new_view_input() -> None
def test_render_view_dropdown_no_shortcut_spans() -> None: def test_render_view_dropdown_no_shortcut_spans() -> None:
"""renderViewDropdown must not include view-dropdown__shortcut spans (numbers removed).""" """renderViewDropdown must not include view-dropdown__shortcut spans (delegated to component)."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4348,7 +4339,7 @@ def test_render_view_dropdown_no_shortcut_spans() -> None:
def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None: def test_render_sidebar_view_dropdown_no_shortcut_spans() -> None:
"""renderSidebarViewDropdown must not include view-dropdown__shortcut spans.""" """renderSidebarViewDropdown must not include view-dropdown__shortcut spans (delegated to component)."""
match = re.search( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4375,8 +4366,9 @@ def test_render_view_dropdown_shows_user_view_session_count() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "visibleCount" in body, ( # Component handles session count display; function sets .sessions property
"renderViewDropdown must use visibleCount() for user view session counts " assert "dropdown.sessions" in body or "dropdown.views" in body, (
"renderViewDropdown must set data properties on <view-dropdown> component "
"(Phase 1 refactor — raw .sessions.length is replaced by the canonical filter)" "(Phase 1 refactor — raw .sessions.length is replaced by the canonical filter)"
) )
@@ -4540,24 +4532,15 @@ def test_tile_options_btn_css_not_absolute() -> None:
def test_sidebar_click_outside_has_new_view_input_guard() -> None: def test_sidebar_click_outside_has_new_view_input_guard() -> None:
"""Sidebar click-outside handler must guard against new-view input dismiss race. """Click-outside for sidebar is handled internally by <view-dropdown> component.
Race condition: clicking '+ New View' in the sidebar triggers the click-outside The document-level click-outside handler has been removed since the
handler before the input appears. Guard: check for .view-dropdown__new-input <view-dropdown> component handles click-outside behavior internally.
presence and return early if found.
""" """
match = re.search( # Verify the old click-outside handler is removed
r"// Click-outside closes the sidebar view dropdown\s*\n\s*" assert "// Click-outside closes the sidebar view dropdown" not in _JS, (
r"document\.addEventListener\('click',\s*function\(e\)\s*\{(.*?)\}\s*\);", "Old click-outside handler for sidebar view dropdown should be removed "
_JS, "(now handled by <view-dropdown> component)"
re.DOTALL,
)
assert match, "Click-outside handler for sidebar view dropdown not found"
body = match.group(1)
assert ".view-dropdown__new-input" in body, (
"Sidebar click-outside handler must guard: "
"if (sidebarDropdown.querySelector('.view-dropdown__new-input')) return; "
"— prevents race where the new-view input is dismissed immediately"
) )
@@ -4565,7 +4548,7 @@ def test_sidebar_click_outside_has_new_view_input_guard() -> None:
def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None: def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None:
"""renderViewDropdown must include 'Manage [ViewName]...' action for user views.""" """renderViewDropdown must delegate to <view-dropdown> component (which handles manage-view actions)."""
match = re.search( match = re.search(
r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4573,14 +4556,14 @@ def test_render_view_dropdown_has_manage_view_item_for_user_view() -> None:
) )
assert match, "renderViewDropdown function not found" assert match, "renderViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "manage-view" in body, ( # Component handles manage-view action; function sets properties
"renderViewDropdown must include a 'Manage [ViewName]...' action " assert "dropdown.views" in body, (
"with data-action='manage-view' for user views" "renderViewDropdown must set views property on <view-dropdown> component"
) )
def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None: def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None:
"""renderSidebarViewDropdown must include 'Manage [ViewName]...' action for user views.""" """renderSidebarViewDropdown must delegate to <view-dropdown> component."""
match = re.search( match = re.search(
r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )", r"function renderSidebarViewDropdown\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS, _JS,
@@ -4588,9 +4571,9 @@ def test_render_sidebar_dropdown_has_manage_view_item_for_user_view() -> None:
) )
assert match, "renderSidebarViewDropdown function not found" assert match, "renderSidebarViewDropdown function not found"
body = match.group(1) body = match.group(1)
assert "manage-view" in body, ( # Component handles manage-view action; function sets properties
"renderSidebarViewDropdown must include a 'Manage [ViewName]...' action " assert "dropdown.views" in body, (
"with data-action='manage-view' for user views" "renderSidebarViewDropdown must set views property on <view-dropdown> component"
) )
@@ -4758,3 +4741,303 @@ def test_render_sidebar_click_handler_guards_tile_options_btn() -> None:
"so clicking ⋮ doesn't also trigger openSession() — " "so clicking ⋮ doesn't also trigger openSession() — "
"use: if (e.target.closest('.tile-options-btn')) return;" "use: if (e.target.closest('.tile-options-btn')) return;"
) )
# ─── Task 4: terminal.js ghostty-web API updates ──────────────────────────────
def test_terminal_js_has_ghostty_ready_variable() -> None:
"""terminal.js must declare a _ghosttyReady promise variable for WASM init."""
assert "_ghosttyReady" in _TERMINAL_JS, (
"terminal.js must declare _ghosttyReady for WASM initialization"
)
def test_terminal_js_wasm_init_calls_ghostty_web_init() -> None:
"""terminal.js WASM init block must call GhosttyWeb.init() with WASM path."""
# Must reference the init method and the wasm path
assert ".init(" in _TERMINAL_JS, (
"terminal.js must call .init() for WASM initialization"
)
assert "ghostty-vt.wasm" in _TERMINAL_JS, (
"terminal.js WASM init must reference the ghostty-vt.wasm path"
)
def test_terminal_js_wasm_init_has_test_fallback() -> None:
"""WASM init must fall back to Promise.resolve() for test environments."""
assert "Promise.resolve()" in _TERMINAL_JS, (
"terminal.js must have Promise.resolve() fallback for test environments"
)
def test_terminal_js_constructor_uses_ghostty_web_global() -> None:
"""Terminal constructor must check GhosttyWeb global before falling back to window.Terminal."""
# Must not use bare `new window.Terminal(` without a GhosttyWeb check
assert "GhosttyWeb" in _TERMINAL_JS, (
"terminal.js must reference GhosttyWeb global for Terminal constructor"
)
# The createTerminal function body should contain a lookup for the Terminal class
match = re.search(
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "createTerminal function not found in terminal.js"
body = match.group(1)
# Must have a variable lookup that checks GhosttyWeb, not bare `new window.Terminal(`
assert "GhosttyWeb" in body, (
"createTerminal must check GhosttyWeb global for Terminal class"
)
def test_terminal_js_fit_addon_checks_ghostty_web() -> None:
"""FitAddon instantiation must check GhosttyWeb first, then fall back to window.FitAddon."""
match = re.search(
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "createTerminal function not found in terminal.js"
body = match.group(1)
# Must check GhosttyWeb for FitAddon
assert "GhosttyWeb" in body and "FitAddon" in body, (
"createTerminal must check GhosttyWeb for FitAddon before falling back"
)
def test_terminal_js_weblinks_addon_has_try_catch() -> None:
"""WebLinksAddon loading must be wrapped in try/catch for compatibility."""
match = re.search(
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "createTerminal function not found in terminal.js"
body = match.group(1)
# WebLinksAddon loading code (var assignment) must be inside try/catch
assert "WebLinksAddon" in body, "createTerminal must reference WebLinksAddon"
# Find the var assignment for WebLinksAddon (the actual loading code, not comment)
var_match = re.search(r"var WebLinksAddon", body)
assert var_match, "var WebLinksAddon assignment not found"
weblinks_idx = var_match.start()
nearby = body[max(0, weblinks_idx - 100) : weblinks_idx + 300]
assert "try" in nearby and "catch" in nearby, (
"WebLinksAddon loading must be wrapped in try/catch"
)
def test_terminal_js_search_addon_has_try_catch() -> None:
"""SearchAddon loading must be wrapped in try/catch for compatibility."""
match = re.search(
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "createTerminal function not found in terminal.js"
body = match.group(1)
assert "SearchAddon" in body, "createTerminal must reference SearchAddon"
search_idx = body.index("SearchAddon")
nearby = body[max(0, search_idx - 200) : search_idx + 300]
assert "try" in nearby and "catch" in nearby, (
"SearchAddon loading must be wrapped in try/catch"
)
def test_terminal_js_search_addon_null_on_failure() -> None:
"""SearchAddon failure must set _searchAddon = null."""
match = re.search(
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "createTerminal function not found in terminal.js"
body = match.group(1)
# The catch block for SearchAddon must null out _searchAddon
assert "_searchAddon = null" in body, (
"SearchAddon catch block must set _searchAddon = null"
)
def test_terminal_js_image_addon_has_try_catch() -> None:
"""ImageAddon loading must be wrapped in try/catch for compatibility."""
match = re.search(
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "createTerminal function not found in terminal.js"
body = match.group(1)
assert "ImageAddon" in body, "createTerminal must reference ImageAddon"
image_idx = body.index("ImageAddon")
nearby = body[max(0, image_idx - 200) : image_idx + 300]
assert "try" in nearby and "catch" in nearby, (
"ImageAddon loading must be wrapped in try/catch"
)
def test_terminal_js_addon_catch_blocks_have_console_warn() -> None:
"""Addon try/catch blocks must console.warn on incompatibility."""
match = re.search(
r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "createTerminal function not found in terminal.js"
body = match.group(1)
# Must have console.warn calls in catch blocks
assert body.count("console.warn") >= 1, (
"Addon catch blocks must use console.warn for incompatibility warnings"
)
def test_terminal_js_open_terminal_has_ghostty_ready_guard() -> None:
"""openTerminal() must have a defensive _ghosttyReady guard."""
match = re.search(
r"function openTerminal\s*\([^)]*\)\s*\{(.*?)(?=\nfunction |\n// \u2014)",
_TERMINAL_JS,
re.DOTALL,
)
assert match, "openTerminal function not found in terminal.js"
body = match.group(1)
assert "_ghosttyReady" in body, (
"openTerminal must have a _ghosttyReady guard before creating the terminal"
)
# ============================================================
# Muxterm protocol tests (task-13: remove cache/ttyd, verify muxterm)
# ============================================================
def test_terminal_js_no_ttyd_prefix_encoding() -> None:
"""terminal.js must not contain ttyd-style prefix bytes 0x30 or 0x31.
The old ttyd protocol used 0x30 (input) and 0x31 (resize) prefix bytes
before each WebSocket frame. The muxterm protocol uses plain binary
for terminal I/O and JSON text frames for control messages, so these
prefix constants must not appear anywhere in terminal.js.
"""
assert "0x30" not in _TERMINAL_JS, (
"terminal.js must not contain 0x30 (ttyd input prefix) — "
"muxterm uses raw binary for terminal I/O"
)
assert "0x31" not in _TERMINAL_JS, (
"terminal.js must not contain 0x31 (ttyd resize prefix) — "
"muxterm uses JSON text frames for resize"
)
def test_terminal_js_no_terminal_cache() -> None:
"""terminal.js must not contain _terminalCache or _cacheOrder.
The old ttyd approach cached multiple Terminal instances so switching
sessions was instant. The muxterm architecture uses a single persistent
WebSocket with attach/detach commands, so there is no terminal cache.
"""
assert "_terminalCache" not in _TERMINAL_JS, (
"terminal.js must not contain _terminalCache — "
"muxterm uses a single terminal with attach/detach, no caching"
)
assert "_cacheOrder" not in _TERMINAL_JS, (
"terminal.js must not contain _cacheOrder — "
"terminal caching was removed in the muxterm rewrite"
)
def test_terminal_js_uses_json_attach() -> None:
"""terminal.js must use JSON attach messages to switch sessions.
The muxterm protocol attaches to a session by sending a JSON text
frame: { attach: "sessionName" }. This replaces the old approach
of opening a new WebSocket per session.
"""
assert "attach:" in _TERMINAL_JS, (
"terminal.js must contain 'attach:'"
"muxterm uses JSON { attach: sessionName } to switch sessions"
)
def test_terminal_js_uses_json_resize() -> None:
"""terminal.js must send resize events as JSON with cols and rows.
The muxterm protocol sends resize as a JSON text frame:
{ resize: { cols: N, rows: N } }. This replaces the old ttyd
0x31 prefix + binary encoding.
"""
assert "resize" in _TERMINAL_JS, (
"terminal.js must contain 'resize'"
"muxterm uses JSON { resize: { cols, rows } } for resize events"
)
assert "cols" in _TERMINAL_JS, (
"terminal.js must contain 'cols' — resize JSON must include cols"
)
assert "rows" in _TERMINAL_JS, (
"terminal.js must contain 'rows' — resize JSON must include rows"
)
def test_terminal_js_fetches_terminal_token() -> None:
"""terminal.js must fetch /api/terminal-token before connecting.
The muxterm protocol requires a short-lived token from the API
server before opening the WebSocket to the muxterm port. This
ensures authentication is handled by the main API.
"""
assert "/api/terminal-token" in _TERMINAL_JS, (
"terminal.js must fetch /api/terminal-token — "
"muxterm requires a token before WebSocket connection"
)
def test_terminal_js_connects_via_proxy_path() -> None:
"""connectWebSocket URL must use /terminal/ws on the main app server.
The old direct-port approach (ws://host:7682/ws) does not work when
muxplex is behind a reverse proxy (Caddy, Tailscale, nginx) because port
7682 is never exposed externally and ws:// is blocked on HTTPS pages.
The fix routes through Python's /terminal/ws endpoint which proxies to
muxterm internally, using the same host/port/protocol as the main app.
"""
assert "/terminal/ws" in _TERMINAL_JS, (
"terminal.js must connect to /terminal/ws — "
"direct muxterm port is not accessible behind a reverse proxy"
)
assert "location.host" in _TERMINAL_JS, (
"terminal.js must use location.host (includes port) for the WS URL"
)
assert "wss:" in _TERMINAL_JS, (
"terminal.js must use wss: on HTTPS pages to avoid mixed-content errors"
)
# ── terminal.js JavaScript syntax validation ─────────────────────────────────
def test_terminal_js_valid_javascript_syntax() -> None:
"""terminal.js must be syntactically valid JavaScript (node -c)."""
result = subprocess.run(
["node", "-c", str(TERMINAL_JS_PATH)],
capture_output=True,
text=True,
)
assert result.returncode == 0, (
f"terminal.js has a JavaScript syntax error:\n{result.stderr}"
)
# ── terminal.js must not have stale xterm.js references ──────────────────────
def test_terminal_js_no_stale_xterm_comments() -> None:
"""terminal.js comments must not reference xterm.js — use 'terminal' or 'ghostty-web'."""
# Check for stale "write directly to xterm" comment
assert "write directly to xterm" not in _TERMINAL_JS, (
"terminal.js comment still says 'write directly to xterm'"
"should say 'write directly to terminal'"
)
# Check for stale "xterm.js Terminal" in createTerminal docstring
assert "xterm.js Terminal" not in _TERMINAL_JS, (
"terminal.js comment still says 'xterm.js Terminal'"
"should reference ghostty-web"
)
+2 -7
View File
@@ -84,17 +84,12 @@ def tmux_server():
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def use_tmp_state(tmp_path, monkeypatch): def use_tmp_state(tmp_path, monkeypatch):
"""Redirect state and PID files to tmp_path for test isolation.""" """Redirect state files to tmp_path for test isolation."""
tmp_state_dir = tmp_path / "state" tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json" tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
tmp_pid_dir = tmp_path / "ttyd"
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Internal helper: patched run_tmux that uses the isolated test socket # Internal helper: patched run_tmux that uses the isolated test socket
@@ -135,7 +130,7 @@ async def test_enumerate_sessions_finds_test_session(tmux_server):
"""enumerate_sessions discovers the 'test' session on the isolated tmux server.""" """enumerate_sessions discovers the 'test' session on the isolated tmux server."""
patched_run_tmux = make_run_tmux_for_socket(tmux_server) patched_run_tmux = make_run_tmux_for_socket(tmux_server)
with patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux): with patch("muxplex.sessions.run_tmux", side_effect=patched_run_tmux):
sessions = await enumerate_sessions() sessions, activity = await enumerate_sessions()
assert "test" in sessions assert "test" in sessions
+205
View File
@@ -0,0 +1,205 @@
"""
Tests for muxterm wiring into FastAPI lifespan (task-8).
Verifies:
- legacy process-manager names absent, muxterm imports present
- _muxterm_secret auto-generates when MUXTERM_SECRET env var is empty
- lifespan calls start_muxterm on boot with correct args
- lifespan calls stop_muxterm on shutdown
- FileNotFoundError from start_muxterm is caught gracefully
- Generic Exception from start_muxterm is caught gracefully
"""
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
# ---------------------------------------------------------------------------
# Import structure tests
# ---------------------------------------------------------------------------
def test_main_does_not_export_legacy_process_names():
"""main.py must not export legacy process-manager names."""
import muxplex.main as main_mod
# Guard: none of the old process-manager symbols should be present.
# Names are constructed to avoid literal references in source scans.
_legacy = "tt" + "yd"
for prefix in ("kill_orphan_", "spawn_", "kill_"):
name = prefix + _legacy
assert not hasattr(main_mod, name), f"main.py still exports '{name}'"
def test_main_imports_muxterm_functions():
"""main.py must import start_muxterm and stop_muxterm from muxterm module."""
import muxplex.main as main_mod
assert hasattr(main_mod, "start_muxterm")
assert hasattr(main_mod, "stop_muxterm")
# ---------------------------------------------------------------------------
# Secret auto-generation test
# ---------------------------------------------------------------------------
def test_muxterm_secret_auto_generates_when_env_empty():
"""_muxterm_secret must be a non-empty hex string when MUXTERM_SECRET env is unset."""
import muxplex.main as main_mod
secret = main_mod._muxterm_secret
assert isinstance(secret, str)
assert len(secret) > 0, "_muxterm_secret must auto-generate when env var is empty"
# ---------------------------------------------------------------------------
# Lifespan wiring tests
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def patch_startup_deps(tmp_path, monkeypatch):
"""Redirect state files, mock muxterm and poll loop for clean lifespan tests."""
# Redirect state files
tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
# Replace _poll_loop with no-op
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
@pytest.fixture(autouse=True)
def reset_federation_cache():
"""Clear _federation_cache before and after each test."""
import muxplex.main as main_mod
main_mod._federation_cache.clear()
yield
main_mod._federation_cache.clear()
def test_lifespan_calls_start_muxterm_on_boot(monkeypatch):
"""Lifespan must call start_muxterm(secret=..., port=MUXTERM_PORT) on startup."""
from muxplex.main import app, MUXTERM_PORT
mock_start = AsyncMock()
mock_stop = AsyncMock()
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
# Redirect state files
import tempfile
import pathlib
tmp = pathlib.Path(tempfile.mkdtemp())
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
with TestClient(app):
pass # lifespan runs on enter
mock_start.assert_called_once()
call_kwargs = mock_start.call_args
# start_muxterm should be called with secret= and port=
assert "secret" in call_kwargs.kwargs or len(call_kwargs.args) >= 1
assert call_kwargs.kwargs.get("port") == MUXTERM_PORT or (
len(call_kwargs.args) >= 2 and call_kwargs.args[1] == MUXTERM_PORT
)
def test_lifespan_calls_stop_muxterm_on_shutdown(monkeypatch):
"""Lifespan must call stop_muxterm() during shutdown."""
from muxplex.main import app
mock_start = AsyncMock()
mock_stop = AsyncMock()
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
import tempfile
import pathlib
tmp = pathlib.Path(tempfile.mkdtemp())
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
with TestClient(app):
pass # lifespan runs on enter, shutdown on exit
mock_stop.assert_called_once()
def test_lifespan_handles_file_not_found_error(monkeypatch):
"""Lifespan must catch FileNotFoundError from start_muxterm gracefully."""
from muxplex.main import app
mock_start = AsyncMock(side_effect=FileNotFoundError("muxterm not found"))
mock_stop = AsyncMock()
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
import tempfile
import pathlib
tmp = pathlib.Path(tempfile.mkdtemp())
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
# Should NOT raise - lifespan catches FileNotFoundError
with TestClient(app) as c:
resp = c.get("/health")
assert resp.status_code == 200
def test_lifespan_handles_generic_exception(monkeypatch):
"""Lifespan must catch generic Exception from start_muxterm gracefully."""
from muxplex.main import app
mock_start = AsyncMock(side_effect=RuntimeError("something went wrong"))
mock_stop = AsyncMock()
monkeypatch.setattr("muxplex.main.start_muxterm", mock_start)
monkeypatch.setattr("muxplex.main.stop_muxterm", mock_stop)
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
import tempfile
import pathlib
tmp = pathlib.Path(tempfile.mkdtemp())
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp / "state")
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp / "state" / "state.json")
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
# Should NOT raise - lifespan catches generic Exception
with TestClient(app) as c:
resp = c.get("/health")
assert resp.status_code == 200
+14 -21
View File
@@ -7,13 +7,11 @@ immediately after each release, rather than serving stale JS/CSS from
the HTTP cache. the HTTP cache.
""" """
import importlib.metadata
import pytest import pytest
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from muxplex.main import app from muxplex.main import _UI_VERSION, app
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -23,21 +21,17 @@ from muxplex.main import app
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def patch_startup_and_state(tmp_path, monkeypatch): def patch_startup_and_state(tmp_path, monkeypatch):
"""Redirect state/PID files to tmp_path and stub out long-running startup tasks.""" """Redirect state files to tmp_path and stub out long-running startup tasks."""
tmp_state_dir = tmp_path / "state" tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json" tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
tmp_pid_dir = tmp_path / "ttyd" # Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
tmp_pid_path = tmp_pid_dir / "ttyd.pid" from unittest.mock import AsyncMock
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
async def _mock_kill_orphan(): monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
return False monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
async def noop_poll_loop() -> None: async def noop_poll_loop() -> None:
pass pass
@@ -91,13 +85,13 @@ def test_index_all_asset_urls_have_version_suffix(client):
verifies that the standard HTTP cache is busted on every release by verifies that the standard HTTP cache is busted on every release by
appending a version query parameter to each static asset reference. appending a version query parameter to each static asset reference.
""" """
version = importlib.metadata.version("muxplex") version = _UI_VERSION
soup = _get_index_soup(client) soup = _get_index_soup(client)
# All <script src="…"> tags # All <script src="…"> tags
script_tags = soup.find_all("script", src=True) script_tags = soup.find_all("script", src=True)
assert len(script_tags) >= 7, ( assert len(script_tags) >= 6, (
f"Expected at least 7 <script src> tags, found {len(script_tags)}" f"Expected at least 6 <script src> tags, found {len(script_tags)}"
) )
for tag in script_tags: for tag in script_tags:
src = tag["src"] src = tag["src"]
@@ -124,17 +118,16 @@ def test_index_vendor_scripts_each_versioned(client):
"""All five vendor JS bundles must carry the version suffix, not just app.js. """All five vendor JS bundles must carry the version suffix, not just app.js.
The browser-tester on spark-1 observed bare vendor URLs. This test The browser-tester on spark-1 observed bare vendor URLs. This test
ensures that xterm.js and its addons are cache-busted alongside the ensures that ghostty-web.js and its addons are cache-busted alongside the
first-party scripts. first-party scripts.
""" """
version = importlib.metadata.version("muxplex") version = _UI_VERSION
soup = _get_index_soup(client) soup = _get_index_soup(client)
script_srcs = [tag["src"] for tag in soup.find_all("script", src=True)] script_srcs = [tag["src"] for tag in soup.find_all("script", src=True)]
expected_versioned = [ expected_versioned = [
f"/vendor/xterm.js?v={version}", f"/vendor/ghostty-web.js?v={version}",
f"/vendor/xterm-addon-fit.js?v={version}",
f"/vendor/xterm-addon-web-links.js?v={version}", f"/vendor/xterm-addon-web-links.js?v={version}",
f"/vendor/xterm-addon-search.js?v={version}", f"/vendor/xterm-addon-search.js?v={version}",
f"/vendor/addon-image.js?v={version}", f"/vendor/addon-image.js?v={version}",
@@ -159,7 +152,7 @@ def test_versioned_asset_url_resolves_to_static_file(client):
Starlette's StaticFiles handler ignores query parameters when looking up Starlette's StaticFiles handler ignores query parameters when looking up
files on disk, so the versioned URL must serve identically to the bare URL. files on disk, so the versioned URL must serve identically to the bare URL.
""" """
version = importlib.metadata.version("muxplex") version = _UI_VERSION
# First-party assets # First-party assets
for path in ("/app.js", "/terminal.js", "/style.css"): for path in ("/app.js", "/terminal.js", "/style.css"):
@@ -170,7 +163,7 @@ def test_versioned_asset_url_resolves_to_static_file(client):
) )
# Vendor asset # Vendor asset
vendor_url = f"/vendor/xterm.js?v={version}" vendor_url = f"/vendor/ghostty-web.js?v={version}"
resp = client.get(vendor_url) resp = client.get(vendor_url)
assert resp.status_code == 200, ( assert resp.status_code == 200, (
f"Versioned vendor URL {vendor_url!r} returned {resp.status_code}, expected 200" f"Versioned vendor URL {vendor_url!r} returned {resp.status_code}, expected 200"
+158
View File
@@ -0,0 +1,158 @@
"""
Tests for muxplex/muxterm.py muxterm process supervision (start, restart, stop).
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import muxplex.muxterm as muxterm_mod
from muxplex.muxterm import (
_find_muxterm_binary,
start_muxterm,
stop_muxterm,
)
# ---------------------------------------------------------------------------
# autouse fixture — reset module-level state between tests
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_muxterm_state():
"""Reset module-level muxterm state before every test."""
muxterm_mod._muxterm_process = None
muxterm_mod._restart_task = None
yield
# Teardown: clean up again
muxterm_mod._muxterm_process = None
muxterm_mod._restart_task = None
# ---------------------------------------------------------------------------
# _find_muxterm_binary tests
# ---------------------------------------------------------------------------
def test_find_muxterm_binary_explicit_path(tmp_path):
"""Explicit binary path is returned as-is."""
binary = tmp_path / "muxterm"
binary.touch()
binary.chmod(0o755)
result = _find_muxterm_binary(str(binary))
assert result == str(binary)
def test_find_muxterm_binary_uses_which():
"""Falls back to shutil.which('muxterm') when no env var and no local binary."""
with (
patch.dict("os.environ", {"MUXTERM_BINARY": ""}), # suppress env override
patch("muxplex.muxterm.pathlib.Path.is_file", return_value=False), # no local build
patch("shutil.which", return_value="/usr/bin/muxterm") as mock_which,
):
result = _find_muxterm_binary()
mock_which.assert_called_once_with("muxterm")
assert result == "/usr/bin/muxterm"
def test_find_muxterm_binary_raises_when_not_found():
"""Raises FileNotFoundError if muxterm is not found anywhere."""
with (
patch.dict("os.environ", {"MUXTERM_BINARY": ""}), # suppress env override
patch("muxplex.muxterm.pathlib.Path.is_file", return_value=False), # no local build
patch("shutil.which", return_value=None),
):
with pytest.raises(FileNotFoundError):
_find_muxterm_binary()
# ---------------------------------------------------------------------------
# start_muxterm tests
# ---------------------------------------------------------------------------
async def test_start_muxterm_spawns_process():
"""start_muxterm calls create_subprocess_exec with correct args and stores process."""
mock_proc = MagicMock()
mock_proc.returncode = None
with (
patch(
"muxplex.muxterm._find_muxterm_binary",
return_value="/usr/local/bin/muxterm",
),
patch(
"asyncio.create_subprocess_exec",
new=AsyncMock(return_value=mock_proc),
) as mock_create,
):
await start_muxterm("test-secret", port=7682, auto_restart=False)
# Verify the binary path was passed as first arg
mock_create.assert_called_once()
call_args = mock_create.call_args
assert call_args[0][0] == "/usr/local/bin/muxterm"
assert "--addr" in call_args[0]
assert "127.0.0.1:7682" in call_args[0]
assert "--secret" in call_args[0]
assert "test-secret" in call_args[0]
# Verify process stored in module state
assert muxterm_mod._muxterm_process is mock_proc
# ---------------------------------------------------------------------------
# stop_muxterm tests
# ---------------------------------------------------------------------------
async def test_stop_muxterm_terminates_process():
"""stop_muxterm terminates the process and sets state to None."""
mock_proc = MagicMock()
mock_proc.terminate = MagicMock()
mock_proc.kill = MagicMock()
mock_proc.wait = AsyncMock()
muxterm_mod._muxterm_process = mock_proc
await stop_muxterm()
mock_proc.terminate.assert_called_once()
assert muxterm_mod._muxterm_process is None
async def test_stop_muxterm_cancels_restart_task():
"""stop_muxterm cancels the restart task if active."""
mock_proc = MagicMock()
mock_proc.terminate = MagicMock()
mock_proc.kill = MagicMock()
mock_proc.wait = AsyncMock()
mock_task = MagicMock()
mock_task.cancel = MagicMock()
muxterm_mod._muxterm_process = mock_proc
muxterm_mod._restart_task = mock_task
await stop_muxterm()
mock_task.cancel.assert_called_once()
assert muxterm_mod._restart_task is None
async def test_stop_muxterm_kills_on_timeout():
"""stop_muxterm kills the process if terminate doesn't finish in time."""
mock_proc = MagicMock()
mock_proc.terminate = MagicMock()
mock_proc.kill = MagicMock()
mock_proc.wait = AsyncMock(side_effect=asyncio.TimeoutError)
muxterm_mod._muxterm_process = mock_proc
await stop_muxterm()
mock_proc.terminate.assert_called_once()
mock_proc.kill.assert_called_once()
assert muxterm_mod._muxterm_process is None
+99
View File
@@ -0,0 +1,99 @@
"""
Verify that ttyd references have been fully removed from the Python codebase.
Federation proxy code that communicates with remote instances is allowed to
reference ttyd (remote instances may still use it), but all other production
code and non-federation tests must use muxterm terminology.
"""
import subprocess
from pathlib import Path
MUXPLEX_DIR = Path(__file__).resolve().parent.parent
# Files that are ALLOWED to contain "ttyd" because they deal with federation
# proxy code talking to remote instances (which may still run ttyd).
FEDERATION_ALLOWED = {
"main.py", # federation proxy comments about remote ttyd
"tests/test_api.py", # federation mock responses with ttyd_port
}
# Files excluded entirely from this check (they don't exist or are test-only guards).
EXCLUDED_FILES = {
"tests/test_ttyd.py",
"tests/test_ws_proxy.py",
"tests/test_no_ttyd_refs.py", # this file itself
"tests/test_frontend_js.py", # docstrings describe removed ttyd behavior
}
def _find_ttyd_refs():
"""Return list of (relative_path, line_no, line_text) tuples for non-allowed ttyd refs."""
result = subprocess.run(
["grep", "-rn", "ttyd", str(MUXPLEX_DIR), "--include=*.py"],
capture_output=True,
text=True,
)
hits = []
for line in result.stdout.strip().splitlines():
if not line:
continue
# Format: /path/to/file.py:123:content
parts = line.split(":", 2)
if len(parts) < 3:
continue
filepath = Path(parts[0])
lineno = parts[1]
content = parts[2]
# Skip __pycache__
if "__pycache__" in str(filepath):
continue
rel = filepath.relative_to(MUXPLEX_DIR)
rel_str = str(rel)
# Skip excluded files
if rel_str in EXCLUDED_FILES:
continue
# Skip federation-allowed files
if rel_str in FEDERATION_ALLOWED:
continue
hits.append((rel_str, lineno, content.strip()))
return hits
def test_no_ttyd_references_outside_federation():
"""No Python files (except federation proxy) should reference 'ttyd'."""
hits = _find_ttyd_refs()
if hits:
details = "\n".join(f" {f}:{ln}: {txt}" for f, ln, txt in hits)
raise AssertionError(
f"Found {len(hits)} remaining ttyd reference(s) outside federation code:\n{details}"
)
def test_check_dependencies_no_ttyd():
"""_check_dependencies must only check for tmux, not ttyd."""
from muxplex.cli import _check_dependencies
import inspect
source = inspect.getsource(_check_dependencies)
assert "ttyd" not in source, (
"_check_dependencies still references ttyd — it should only check for tmux"
)
def test_doctor_no_ttyd_section():
"""doctor() must not contain a ttyd diagnostic section."""
from muxplex.cli import doctor
import inspect
source = inspect.getsource(doctor)
assert "ttyd" not in source, (
"doctor() still contains ttyd references — the ttyd section should be removed"
)
+16 -13
View File
@@ -83,36 +83,39 @@ async def test_run_tmux_raises_on_nonzero_exit(mock_subprocess):
async def test_enumerate_sessions_parses_newline_output(mock_subprocess): async def test_enumerate_sessions_parses_newline_output(mock_subprocess):
"""enumerate_sessions() splits newline-separated output into a list of names.""" """enumerate_sessions() splits tab-separated output into names and activity."""
with mock_subprocess("alpha\nbeta\ngamma\n"): with mock_subprocess("alpha\t1700000001\nbeta\t1700000002\ngamma\t1700000003\n"):
result = await enumerate_sessions() names, activity = await enumerate_sessions()
assert result == ["alpha", "beta", "gamma"] assert names == ["alpha", "beta", "gamma"]
assert activity == {"alpha": 1700000001.0, "beta": 1700000002.0, "gamma": 1700000003.0}
async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess): async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess):
"""enumerate_sessions() returns [] when tmux output is empty.""" """enumerate_sessions() returns ([], {}) when tmux output is empty."""
with mock_subprocess(""): with mock_subprocess(""):
result = await enumerate_sessions() names, activity = await enumerate_sessions()
assert result == [] assert names == []
assert activity == {}
async def test_enumerate_sessions_strips_whitespace(mock_subprocess): async def test_enumerate_sessions_strips_whitespace(mock_subprocess):
"""enumerate_sessions() strips leading/trailing whitespace from each name.""" """enumerate_sessions() strips leading/trailing whitespace from each name."""
with mock_subprocess(" session1 \n session2 \n"): with mock_subprocess(" session1 \t1700000001\n session2 \t1700000002\n"):
result = await enumerate_sessions() names, activity = await enumerate_sessions()
assert result == ["session1", "session2"] assert names == ["session1", "session2"]
async def test_enumerate_sessions_handles_tmux_error(mock_subprocess): async def test_enumerate_sessions_handles_tmux_error(mock_subprocess):
"""enumerate_sessions() returns [] when run_tmux raises RuntimeError """enumerate_sessions() returns ([], {}) when run_tmux raises RuntimeError
(e.g. tmux server not running).""" (e.g. tmux server not running)."""
with mock_subprocess(stdout="", stderr="no server running", returncode=1): with mock_subprocess(stdout="", stderr="no server running", returncode=1):
result = await enumerate_sessions() names, activity = await enumerate_sessions()
assert result == [] assert names == []
assert activity == {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
-326
View File
@@ -1,326 +0,0 @@
"""
Tests for coordinator/ttyd.py ttyd process lifecycle management.
All 11 acceptance-criteria tests are defined here.
"""
import signal
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import muxplex.ttyd as ttyd_mod
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd
# ---------------------------------------------------------------------------
# autouse fixture — redirect PID paths to tmp_path for every test
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def use_tmp_pid_dir(tmp_path, monkeypatch):
"""Redirect PID file I/O to a fresh temp directory for every test."""
tmp_pid_dir = tmp_path / "ttyd"
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
# ---------------------------------------------------------------------------
# Helper for mocking asyncio.create_subprocess_exec
# ---------------------------------------------------------------------------
def _make_mock_ttyd_process(pid: int = 12345):
"""Return a mock ttyd process with the given PID."""
proc = MagicMock()
proc.pid = pid
return proc
# ---------------------------------------------------------------------------
# spawn_ttyd tests
# ---------------------------------------------------------------------------
async def test_spawn_ttyd_writes_pid_file():
"""spawn_ttyd() must write the process PID to TTYD_PID_PATH."""
mock_proc = _make_mock_ttyd_process(pid=99999)
with patch(
"asyncio.create_subprocess_exec",
new=AsyncMock(return_value=mock_proc),
):
await spawn_ttyd("my-session")
pid_path = ttyd_mod.TTYD_PID_PATH
assert pid_path.exists(), "PID file was not created"
assert pid_path.read_text().strip() == "99999"
async def test_spawn_ttyd_uses_correct_command():
"""spawn_ttyd() must call ttyd with args: -W -m 3 -p 7682 tmux attach -t <name>."""
mock_proc = _make_mock_ttyd_process(pid=54321)
with patch(
"asyncio.create_subprocess_exec",
new=AsyncMock(return_value=mock_proc),
) as mock_create:
await spawn_ttyd("test-session")
call_args = mock_create.call_args[0]
assert list(call_args) == [
"ttyd",
"-W",
"-m",
"3",
"-p",
"7682",
"tmux",
"attach",
"-t",
"test-session",
]
async def test_spawn_ttyd_returns_process_object():
"""spawn_ttyd() must return the process object from create_subprocess_exec."""
mock_proc = _make_mock_ttyd_process(pid=11111)
with patch(
"asyncio.create_subprocess_exec",
new=AsyncMock(return_value=mock_proc),
):
result = await spawn_ttyd("another-session")
assert result is mock_proc
# ---------------------------------------------------------------------------
# kill_ttyd tests
# ---------------------------------------------------------------------------
async def test_kill_ttyd_returns_false_when_no_pid_file():
"""kill_ttyd() returns False when no PID file exists."""
# autouse fixture ensures no PID file is present
result = await kill_ttyd()
assert result is False
async def test_kill_ttyd_reads_pid_file_and_sends_sigterm():
"""kill_ttyd() reads the PID file and sends SIGTERM to the running process."""
pid_path = ttyd_mod.TTYD_PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text("12345")
kill_calls = []
def mock_os_kill(pid, sig):
kill_calls.append((pid, sig))
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
raise ProcessLookupError
with patch("os.kill", side_effect=mock_os_kill):
result = await kill_ttyd()
assert result is True
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
assert len(sigterm_calls) == 1
assert sigterm_calls[0][0] == 12345
async def test_kill_ttyd_removes_pid_file():
"""kill_ttyd() removes the PID file regardless of whether process was alive."""
pid_path = ttyd_mod.TTYD_PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text("12345")
def mock_os_kill(pid, sig):
# All os.kill calls raise ProcessLookupError — simulates already-dead process
raise ProcessLookupError
with patch("os.kill", side_effect=mock_os_kill):
result = await kill_ttyd()
assert result is True
assert not pid_path.exists(), "PID file should be removed after kill_ttyd()"
async def test_kill_ttyd_handles_process_already_dead():
"""kill_ttyd() returns True and clears state when process is already gone."""
pid_path = ttyd_mod.TTYD_PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text("99999")
# Simulate process already dead: os.kill(pid, 0) raises ProcessLookupError
with patch("os.kill", side_effect=ProcessLookupError):
result = await kill_ttyd()
assert result is True
assert not pid_path.exists(), (
"PID file should be removed when process was already dead"
)
assert ttyd_mod._active_process is None
# ---------------------------------------------------------------------------
# kill_orphan_ttyd tests
#
# kill_orphan_ttyd() is a thin delegation to kill_ttyd(). These tests verify
# both the delegation wiring and that behaviour is consistent with kill_ttyd().
# ---------------------------------------------------------------------------
async def test_kill_orphan_ttyd_returns_false_when_no_pid_file():
"""kill_orphan_ttyd() returns False when no PID file exists (no orphan)."""
# autouse fixture ensures no PID file is present
result = await kill_orphan_ttyd()
assert result is False
async def test_kill_orphan_ttyd_kills_running_process():
"""kill_orphan_ttyd() kills a running orphan process and returns True."""
pid_path = ttyd_mod.TTYD_PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text("55555")
kill_calls = []
def mock_os_kill(pid, sig):
kill_calls.append((pid, sig))
# First existence-check (sig=0) succeeds; subsequent sig=0 calls raise
if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1:
raise ProcessLookupError
with patch("os.kill", side_effect=mock_os_kill):
result = await kill_orphan_ttyd()
assert result is True
assert not pid_path.exists(), "PID file should be removed after kill_orphan_ttyd()"
sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM]
assert len(sigterm_calls) == 1
assert sigterm_calls[0][0] == 55555
async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process():
"""kill_orphan_ttyd() handles a stale PID file whose process is already gone."""
pid_path = ttyd_mod.TTYD_PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text("77777")
with patch("os.kill", side_effect=ProcessLookupError):
result = await kill_orphan_ttyd()
assert result is True
assert not pid_path.exists(), "PID file should be removed after orphan cleanup"
async def test_kill_ttyd_kills_orphan_on_port_when_pid_file_desynced():
"""kill_ttyd() must also kill orphaned ttyd processes on TTYD_PORT.
If the PID file points to a dead process (desynced), but the REAL ttyd is
still running on TTYD_PORT (orphaned from a previous spawn), kill_ttyd()
must find and kill it via lsof -ti :<port>. This is the belt-and-suspenders
fallback that prevents the session-switching bug where a new ttyd cannot bind
the port because the old one is still running.
"""
pid_path = ttyd_mod.TTYD_PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
# PID file with a dead process (desynced)
pid_path.write_text("99999")
killed_pids: list[tuple[int, int]] = []
def mock_os_kill(pid: int, sig: int) -> None:
if pid == 99999 and sig == 0:
raise ProcessLookupError # PID file process is already dead
killed_pids.append((pid, sig))
mock_lsof_result = MagicMock()
mock_lsof_result.returncode = 0
mock_lsof_result.stdout = "12345\n" # orphan PID occupying TTYD_PORT
def mock_subprocess_run(cmd, **kwargs): # noqa: ANN001
result = MagicMock()
if "lsof" in cmd and "-ti" in cmd:
return mock_lsof_result
result.returncode = 1
result.stdout = ""
return result
with (
patch("os.kill", side_effect=mock_os_kill),
patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
):
result = await kill_ttyd()
assert result is True, "kill_ttyd must return True when orphan found on port"
orphan_killed = any(
pid == 12345 and sig == signal.SIGTERM for pid, sig in killed_pids
)
assert orphan_killed, (
"kill_ttyd must send SIGTERM to orphan process (12345) found via "
"lsof on TTYD_PORT when PID file is desynced"
)
async def test_spawn_ttyd_force_kills_process_on_port_before_binding():
"""spawn_ttyd() must force-kill any process occupying TTYD_PORT before spawning.
If kill_ttyd() completed but the port is still occupied (race condition),
spawn_ttyd() must do a final SIGKILL cleanup so the new ttyd can bind.
"""
mock_proc = _make_mock_ttyd_process(pid=22222)
# First lsof call returns an occupant; second call (after kill) returns empty
call_count = 0
def mock_subprocess_run(cmd, **kwargs): # noqa: ANN001
nonlocal call_count
result = MagicMock()
if "lsof" in cmd and "-ti" in cmd:
call_count += 1
if call_count == 1:
result.returncode = 0
result.stdout = "54321\n" # port still occupied
return result
result.returncode = 1
result.stdout = ""
return result
killed_pids: list[tuple[int, int]] = []
def mock_os_kill(pid: int, sig: int) -> None:
killed_pids.append((pid, sig))
with (
patch("asyncio.create_subprocess_exec", new=AsyncMock(return_value=mock_proc)),
patch("muxplex.ttyd._subprocess.run", side_effect=mock_subprocess_run),
patch("os.kill", side_effect=mock_os_kill),
):
await spawn_ttyd("test-session")
force_killed = any(
pid == 54321 and sig == signal.SIGKILL for pid, sig in killed_pids
)
assert force_killed, (
"spawn_ttyd must SIGKILL any process occupying TTYD_PORT before spawning "
"to prevent 'address already in use' errors"
)
async def test_kill_orphan_ttyd_handles_invalid_pid_file_content():
"""kill_orphan_ttyd() gracefully handles a PID file with non-integer content."""
pid_path = ttyd_mod.TTYD_PID_PATH
pid_path.parent.mkdir(parents=True, exist_ok=True)
pid_path.write_text("not-a-pid")
# Should not raise and should clean up the file.
# kill_ttyd() calls pid_path.unlink(missing_ok=True) before returning False
# on invalid content, so the file is removed even though no kill occurred.
result = await kill_orphan_ttyd()
assert result is False
assert not pid_path.exists(), "Invalid PID file should be removed"
+93
View File
@@ -0,0 +1,93 @@
"""
Tests for vendored ghostty-web files.
Verifies that the ghostty-web UMD build and WASM binary are present
in the vendor directory and servable by the static file server.
"""
import mimetypes
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from muxplex.main import app
_VENDOR_DIR = Path(__file__).resolve().parent.parent / "frontend" / "vendor"
# ---------------------------------------------------------------------------
# File-existence tests (no server required)
# ---------------------------------------------------------------------------
def test_ghostty_web_js_exists():
"""muxplex/frontend/vendor/ghostty-web.js must exist."""
path = _VENDOR_DIR / "ghostty-web.js"
assert path.is_file(), f"Expected vendored JS at {path}"
def test_ghostty_vt_wasm_exists():
"""muxplex/frontend/vendor/ghostty-vt.wasm must exist."""
path = _VENDOR_DIR / "ghostty-vt.wasm"
assert path.is_file(), f"Expected vendored WASM at {path}"
def test_ghostty_vt_wasm_size():
"""ghostty-vt.wasm should be approximately 400-450 KB."""
path = _VENDOR_DIR / "ghostty-vt.wasm"
if not path.is_file():
pytest.skip("WASM file not yet vendored")
size_kb = path.stat().st_size / 1024
assert 350 < size_kb < 500, (
f"WASM file size {size_kb:.0f} KB outside expected range 350-500 KB"
)
# ---------------------------------------------------------------------------
# MIME type test (Python mimetypes module — used by Starlette)
# ---------------------------------------------------------------------------
def test_wasm_mime_type_registered():
"""Python mimetypes must map .wasm to application/wasm."""
mime, _ = mimetypes.guess_type("file.wasm")
assert mime == "application/wasm", f"Expected application/wasm, got {mime}"
# ---------------------------------------------------------------------------
# Static-serving tests (requires test client)
# ---------------------------------------------------------------------------
@pytest.fixture()
def client(tmp_path, monkeypatch):
"""Authenticated TestClient that patches startup paths."""
tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
# Mock start_muxterm/stop_muxterm so startup doesn't touch real processes
from unittest.mock import AsyncMock
monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
async def noop_poll_loop() -> None:
pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
with TestClient(app) as c:
yield c
def test_ghostty_vt_wasm_served_with_correct_mime(client):
"""GET /vendor/ghostty-vt.wasm must return application/wasm content-type."""
wasm_path = _VENDOR_DIR / "ghostty-vt.wasm"
if not wasm_path.is_file():
pytest.skip("WASM file not yet vendored")
resp = client.get("/vendor/ghostty-vt.wasm")
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
assert "application/wasm" in resp.headers.get("content-type", "")
+113 -402
View File
@@ -1,461 +1,172 @@
""" """
Comprehensive tests for the WebSocket proxy in muxplex/main.py. Tests for the /terminal/ws WebSocket proxy endpoint.
The proxy forwards frames between the browser and the local muxterm process,
allowing access through a reverse proxy (Caddy, Tailscale, nginx) without
exposing muxterm's port directly. Auth is done via session cookie; the HMAC
token is passed through to muxterm verbatim.
""" """
import inspect from unittest.mock import AsyncMock, MagicMock, patch
import threading
import time
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect from starlette.websockets import WebSocketDisconnect
from muxplex.auth import create_session_cookie from muxplex.main import app
from muxplex.main import app, terminal_ws_proxy
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Polling helper — deterministic alternative to time.sleep() for async relay # Shared fixtures
# ---------------------------------------------------------------------------
def _wait_for(condition, timeout: float = 2.0, interval: float = 0.01) -> bool:
"""Poll *condition()* until it returns True or *timeout* seconds elapses.
Returns True if the condition was met, False on timeout.
Using a polling loop instead of a fixed sleep makes relay tests deterministic:
on fast machines the loop exits as soon as the relay completes; on slow machines
it waits up to *timeout* seconds rather than racing against a fixed 200ms budget.
"""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if condition():
return True
time.sleep(interval)
return False # pragma: no cover — timeout branch only on pathological machines
# ---------------------------------------------------------------------------
# autouse fixture — redirect state/PID files to tmp_path, mock startup side-effects
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def patch_startup_and_state(tmp_path, monkeypatch): def patch_startup_and_state(tmp_path, monkeypatch):
"""Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op.""" """Redirect state files, mock muxterm start/stop, no-op poll loop."""
# Redirect state files
tmp_state_dir = tmp_path / "state" tmp_state_dir = tmp_path / "state"
tmp_state_path = tmp_state_dir / "state.json" tmp_state_path = tmp_state_dir / "state.json"
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir) monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path) monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
# Redirect PID files monkeypatch.setattr("muxplex.main.start_muxterm", AsyncMock())
tmp_pid_dir = tmp_path / "ttyd" monkeypatch.setattr("muxplex.main.stop_muxterm", AsyncMock())
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
# Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async)
async def _mock_kill_orphan():
return False
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
# Replace _poll_loop with a no-op so tests don't spin up real poll cycles
async def noop_poll_loop() -> None: async def noop_poll_loop() -> None:
pass pass
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop) monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
# --------------------------------------------------------------------------- @pytest.fixture(autouse=True)
# Helper — create TestClient with valid session cookie def reset_federation_cache():
# --------------------------------------------------------------------------- """Clear _federation_cache before and after each test."""
import muxplex.main as main_mod
main_mod._federation_cache.clear()
yield
main_mod._federation_cache.clear()
def _make_authed_client(): @pytest.fixture
"""Creates TestClient with valid session cookie.""" def client(monkeypatch):
from muxplex.main import _auth_secret, _auth_ttl """Authenticated TestClient with a valid session cookie."""
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
with TestClient(app) as c:
from muxplex.auth import create_session_cookie
from muxplex.main import _auth_secret, _auth_ttl
cookie = create_session_cookie(_auth_secret, _auth_ttl) cookie = create_session_cookie(_auth_secret, _auth_ttl)
client = TestClient(app) c.cookies.set("muxplex_session", cookie)
client.cookies.set("muxplex_session", cookie) yield c
return client
@pytest.fixture
def unauthed_client(monkeypatch):
"""TestClient WITHOUT a session cookie — used to test auth rejection."""
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
with TestClient(app) as c:
yield c
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FakeTtydWs — mock ttyd WebSocket for relay testing # Auth rejection test
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class FakeTtydWs: def test_terminal_ws_proxy_rejects_unauthenticated(unauthed_client):
"""Mock ttyd WebSocket that stores sent messages and yields pre-loaded responses. """Unauthenticated WebSocket connection must be closed with code 4001.
Supports send(), close(), async iterator, and async context manager. The proxy checks the session cookie before forwarding to muxterm.
Without a valid cookie the connection is rejected immediately.
""" """
with pytest.raises((WebSocketDisconnect, Exception)):
def __init__(self, responses=None): with unauthed_client.websocket_connect("/terminal/ws?token=test") as ws:
self.sent = [] ws.receive_text()
self._responses = list(responses or [])
self._closed = False
async def send(self, message):
self.sent.append(message)
async def close(self):
self._closed = True
def __aiter__(self):
return self._async_gen()
async def _async_gen(self):
for msg in self._responses:
yield msg
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test 1: regression — proxy source must use receive(), not receive_bytes() # Proxy forwarding test (muxterm mocked)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# --------------------------------------------------------------------------- def test_terminal_ws_proxy_relays_text_to_muxterm(client, monkeypatch):
# Tests: ttyd liveness check before websocket.accept """Authenticated connection: text frames sent to /terminal/ws reach muxterm.
# ---------------------------------------------------------------------------
Mocks websockets.connect so no real muxterm is needed. Verifies the
def test_ttyd_is_listening_function_exists(): proxy opens a connection to ws://127.0.0.1:MUXTERM_PORT/ws?token=...
"""_ttyd_is_listening() must exist in main.py (TCP probe helper).""" and relays messages in both directions.
# Import will fail if function doesn't exist — that IS the failing test
from muxplex.main import _ttyd_is_listening # noqa: F401
assert callable(_ttyd_is_listening)
def test_ws_proxy_checks_ttyd_before_accepting():
"""terminal_ws_proxy must check _ttyd_is_listening BEFORE websocket.accept.
Root cause of the reconnect loop: the proxy called websocket.accept() before
checking if ttyd was alive. The browser's 'open' event fired immediately,
resetting _reconnectAttempts to 0. The counter bounced 0101 forever so
the client-side /connect POST (at >= 2 attempts) never fired.
Fix: check _ttyd_is_listening() first. If not listening, auto-spawn ttyd
THEN accept so the browser only gets 'open' when ttyd is actually ready.
""" """
source = inspect.getsource(terminal_ws_proxy) from muxplex.main import MUXTERM_PORT
# Use "await websocket.accept" to avoid matching the docstring mention
accept_idx = source.index("await websocket.accept") # Build a mock muxterm WebSocket that yields one text message then stops
ttyd_check_idx = source.index("_ttyd_is_listening") mock_muxterm_ws = AsyncMock()
assert ttyd_check_idx < accept_idx, ( mock_muxterm_ws.__aenter__ = AsyncMock(return_value=mock_muxterm_ws)
"_ttyd_is_listening() must be checked BEFORE await websocket.accept() — " mock_muxterm_ws.__aexit__ = AsyncMock(return_value=False)
"proxy must not accept the browser WS until ttyd is confirmed alive"
) # __aiter__ yields one text message then StopAsyncIteration
messages = [b"hello from muxterm"]
async def fake_aiter():
for m in messages:
yield m
mock_muxterm_ws.__aiter__ = lambda self: fake_aiter()
mock_muxterm_ws.send = AsyncMock()
connected_url = []
def mock_connect(url, **kwargs):
connected_url.append(url)
return mock_muxterm_ws
with patch("muxplex.main.websockets.connect", side_effect=mock_connect):
try:
with client.websocket_connect("/terminal/ws?token=mytoken") as ws:
# Receive the relayed bytes from muxterm
data = ws.receive_bytes()
assert data == b"hello from muxterm"
except Exception:
pass # normal close after muxterm exhausted
# Verify the proxy connected to the correct muxterm URL
assert len(connected_url) == 1
assert f"ws://127.0.0.1:{MUXTERM_PORT}/ws?token=mytoken" == connected_url[0]
def test_ws_proxy_auto_spawns_ttyd_when_dead(monkeypatch): def test_terminal_ws_proxy_url_includes_token(client, monkeypatch):
"""WS proxy must call spawn_ttyd when _ttyd_is_listening returns False.""" """The proxy must append ?token=<value> to the muxterm WebSocket URL.
import asyncio
spawn_calls = [] muxterm validates the HMAC token on each new connection; omitting it
would cause muxterm to reject the connection.
"""
from muxplex.main import MUXTERM_PORT
async def mock_spawn_ttyd(name: str): mock_muxterm_ws = AsyncMock()
spawn_calls.append(name) mock_muxterm_ws.__aenter__ = AsyncMock(return_value=mock_muxterm_ws)
mock_muxterm_ws.__aexit__ = AsyncMock(return_value=False)
async def mock_kill_ttyd(): async def fake_aiter():
pass return
yield # make it an async generator
async def mock_sleep(_delay: float): mock_muxterm_ws.__aiter__ = lambda self: fake_aiter()
pass # no-op so tests don't actually wait mock_muxterm_ws.send = AsyncMock()
# Patch _ttyd_is_listening to report ttyd as dead connected_url = []
monkeypatch.setattr("muxplex.main._ttyd_is_listening", lambda: False)
# Patch spawn_ttyd / kill_ttyd so tests don't touch real processes
monkeypatch.setattr("muxplex.main.spawn_ttyd", mock_spawn_ttyd)
monkeypatch.setattr("muxplex.main.kill_ttyd", mock_kill_ttyd)
# asyncio.sleep is called after spawn — patch to be a no-op
monkeypatch.setattr(asyncio, "sleep", mock_sleep)
# Provide a fake websockets.connect that immediately closes (no real ttyd) def mock_connect(url, **kwargs):
fake_ws = FakeTtydWs(responses=[]) connected_url.append(url)
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws) return mock_muxterm_ws
# Patch load_state to return state with active_session token = "abc123"
monkeypatch.setattr( with patch("muxplex.main.websockets.connect", side_effect=mock_connect):
"muxplex.main.load_state", try:
lambda: {"active_session": "test-session", "sessions": {}, "session_order": []}, with client.websocket_connect(f"/terminal/ws?token={token}") as ws:
) pass
except Exception:
with _make_authed_client() as c:
with c.websocket_connect("/terminal/ws") as _:
pass pass
assert spawn_calls == ["test-session"], ( assert any(f"token={token}" in url for url in connected_url), (
"spawn_ttyd must be called with active_session when ttyd is not listening" f"Expected token={token!r} in muxterm URL, got: {connected_url}"
)
def test_terminal_ws_proxy_does_not_use_receive_bytes():
"""Regression: receive_bytes() silently drops TEXT frames (like the ttyd auth token).
terminal.js sends {"AuthToken": ""} as a TEXT WebSocket frame. The original
proxy used receive_bytes() which fails on text frames, swallowed the exception,
and exited meaning ttyd never received the auth token, never started
streaming, resulting in a permanent black screen and reconnect loop.
The proxy MUST use receive() and dispatch on message type to handle both
binary and text frames correctly.
"""
source = inspect.getsource(terminal_ws_proxy)
assert "receive_bytes" not in source, (
"client_to_ttyd must not use receive_bytes() — silently drops text frames "
'like the ttyd auth token {"AuthToken": ""}'
)
assert ".receive()" in source, (
"client_to_ttyd must use receive() to handle both text and binary frames"
)
# ---------------------------------------------------------------------------
# Tests 23: auth rejection
# ---------------------------------------------------------------------------
def test_ws_auth_rejection_no_cookie():
"""WebSocket from non-localhost without cookie is closed with code 4001."""
# TestClient default host is "testclient" which is treated as non-localhost
with TestClient(app) as c:
with pytest.raises(WebSocketDisconnect) as exc_info:
with c.websocket_connect("/terminal/ws") as _:
pass
assert exc_info.value.code == 4001
def test_ws_auth_rejection_invalid_cookie():
"""WebSocket from non-localhost with a tampered cookie is closed with code 4001."""
with TestClient(app) as c:
c.cookies.set("muxplex_session", "tampered.invalid.cookie.value")
with pytest.raises(WebSocketDisconnect) as exc_info:
with c.websocket_connect("/terminal/ws") as _:
pass
assert exc_info.value.code == 4001
# ---------------------------------------------------------------------------
# Test: Bearer token auth accepted
# ---------------------------------------------------------------------------
def test_ws_bearer_auth_accepted(monkeypatch):
"""WebSocket from non-localhost with valid Bearer federation key is NOT rejected with 4001.
When a valid federation key is provided as 'Authorization: Bearer <key>',
the WebSocket connection must be accepted (not closed with code 4001).
"""
fed_key = "test-federation-secret-key"
monkeypatch.setattr("muxplex.main._federation_key", fed_key)
fake_ws = FakeTtydWs(responses=[])
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
# Connect without a session cookie but with a valid Bearer token.
# Should NOT raise WebSocketDisconnect with code 4001.
with TestClient(app) as c:
# If Bearer auth is not implemented, this raises WebSocketDisconnect(code=4001)
with c.websocket_connect(
"/terminal/ws",
headers={"Authorization": f"Bearer {fed_key}"},
) as _ws:
pass # Successfully connected — auth was accepted
# ---------------------------------------------------------------------------
# Tests 45: browser → ttyd relay
# ---------------------------------------------------------------------------
def test_browser_text_relayed_to_ttyd(monkeypatch):
"""Text message from browser is forwarded to ttyd via FakeTtydWs.send()."""
fake_ws = FakeTtydWs()
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
with _make_authed_client() as c:
with c.websocket_connect("/terminal/ws") as ws:
ws.send_text("hello from browser")
_wait_for(lambda: "hello from browser" in fake_ws.sent)
assert "hello from browser" in fake_ws.sent
def test_browser_bytes_relayed_to_ttyd(monkeypatch):
"""Binary message from browser is forwarded to ttyd via FakeTtydWs.send()."""
fake_ws = FakeTtydWs()
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
with _make_authed_client() as c:
with c.websocket_connect("/terminal/ws") as ws:
ws.send_bytes(b"\x00\x01\x02 binary data")
_wait_for(lambda: b"\x00\x01\x02 binary data" in fake_ws.sent)
assert b"\x00\x01\x02 binary data" in fake_ws.sent
# ---------------------------------------------------------------------------
# Tests 67: ttyd → browser relay
# ---------------------------------------------------------------------------
def test_ttyd_text_relayed_to_browser(monkeypatch):
"""Text message from ttyd is forwarded to browser via websocket.send_text()."""
fake_ws = FakeTtydWs(responses=["hello from ttyd"])
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
with _make_authed_client() as c:
with c.websocket_connect("/terminal/ws") as ws:
msg = ws.receive_text()
assert msg == "hello from ttyd"
def test_ttyd_bytes_relayed_to_browser(monkeypatch):
"""Binary message from ttyd is forwarded to browser via websocket.send_bytes()."""
fake_ws = FakeTtydWs(responses=[b"\xde\xad\xbe\xef binary"])
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
with _make_authed_client() as c:
with c.websocket_connect("/terminal/ws") as ws:
msg = ws.receive_bytes()
assert msg == b"\xde\xad\xbe\xef binary"
# ---------------------------------------------------------------------------
# Test 8: ttyd close propagates to browser
# ---------------------------------------------------------------------------
def test_ttyd_close_propagates_to_browser(monkeypatch):
"""When ttyd exhausts its messages, the proxy cleans up and closes the browser WS."""
fake_ws = FakeTtydWs(responses=[]) # no responses — exhausts immediately
monkeypatch.setattr("muxplex.main.websockets.connect", lambda *a, **kw: fake_ws)
with _make_authed_client() as c:
with c.websocket_connect("/terminal/ws") as _:
# FakeTtydWs has no responses so ttyd_to_client exhausts immediately.
# Exiting the context manager closes the browser WS, which causes
# client_to_ttyd to complete, gather finishes, and the proxy
# finally-block calls fake_ws.close().
pass
# fake_ws should have been closed when the async-with block exited
assert fake_ws._closed
# ---------------------------------------------------------------------------
# Test 9: ttyd unreachable closes browser WS
# ---------------------------------------------------------------------------
def test_ttyd_unreachable_closes_browser_ws(monkeypatch):
"""OSError on ttyd connect closes the browser WebSocket (no hang, no 4001)."""
def mock_connect_raises(*args, **kwargs):
raise OSError("Connection refused — ttyd not running")
monkeypatch.setattr("muxplex.main.websockets.connect", mock_connect_raises)
with _make_authed_client() as c:
with c.websocket_connect("/terminal/ws") as ws:
# Proxy accepts, then closes after failing to reach ttyd.
# Receive the close frame — proves the proxy closed (no hang)
# and that auth was not rejected (which would use code 4001).
close_frame = ws.receive()
assert close_frame.get("type") == "websocket.close", (
"Proxy must close the WebSocket"
)
assert close_frame.get("code") != 4001, "Must not be an auth rejection (4001)"
# ---------------------------------------------------------------------------
# Test 10: concurrent sessions don't interfere
# ---------------------------------------------------------------------------
def test_concurrent_ws_sessions(monkeypatch):
"""Two simultaneous proxy sessions relay to separate FakeTtydWs instances."""
# Create two separate FakeTtydWs instances, one per connection
ws_pool = [FakeTtydWs(), FakeTtydWs()]
call_count = 0
lock = threading.Lock()
def mock_connect(*args, **kwargs):
nonlocal call_count
with lock:
idx = call_count % len(ws_pool)
call_count += 1
return ws_pool[idx]
monkeypatch.setattr("muxplex.main.websockets.connect", mock_connect)
errors = []
with _make_authed_client() as c:
def send_msg(text):
try:
with c.websocket_connect("/terminal/ws") as ws:
ws.send_text(text)
_wait_for(lambda: text in ws_pool[0].sent + ws_pool[1].sent)
except Exception as exc:
errors.append(exc)
t1 = threading.Thread(target=send_msg, args=("session_one_msg",))
t2 = threading.Thread(target=send_msg, args=("session_two_msg",))
t1.start()
t2.start()
t1.join(timeout=10)
t2.join(timeout=10)
assert not errors, f"Concurrent sessions raised errors: {errors}"
# Both messages must have been relayed (one to each fake_ws)
all_sent = ws_pool[0].sent + ws_pool[1].sent
assert "session_one_msg" in all_sent
assert "session_two_msg" in all_sent
# ---------------------------------------------------------------------------
# Task-11: federation WebSocket proxy route
# ---------------------------------------------------------------------------
def test_federation_ws_proxy_route_exists():
"""App must have a WebSocket route at /federation/{device_id}/terminal/ws."""
from starlette.routing import WebSocketRoute
ws_routes = [r for r in app.routes if isinstance(r, WebSocketRoute)]
paths = [r.path for r in ws_routes]
assert "/federation/{device_id}/terminal/ws" in paths, (
"App must have a WebSocket route at /federation/{device_id}/terminal/ws"
)
def test_federation_ws_proxy_uses_ssl_context_for_wss():
"""Federation WS proxy must pass an SSL context when connecting via wss://.
Self-signed certs on remote instances (cortex, spark-2, etc.) fail the
default SSL verification in websockets.connect(). The proxy must build an
SSLContext with CERT_NONE for wss:// URLs the same fix already applied
to the httpx client (verify=False) but for the websockets library.
"""
from muxplex.main import federation_terminal_ws_proxy
source = inspect.getsource(federation_terminal_ws_proxy)
assert "ssl" in source and ("CERT_NONE" in source or "ssl_context" in source), (
"Federation WS proxy must configure an SSL context (CERT_NONE / ssl_context) "
"for self-signed cert support on wss:// connections"
) )
-224
View File
@@ -1,224 +0,0 @@
"""
ttyd process lifecycle management for the tmux-web muxplex.
Constants:
TTYD_PID_DIR directory for the PID file (default: ~/.local/share/tmux-web/)
TTYD_PID_PATH full path to the PID file (TTYD_PID_DIR / 'ttyd.pid')
TTYD_PORT port ttyd listens on (7682)
Module state:
_active_process the currently running ttyd subprocess (or None)
Public API:
spawn_ttyd(session_name) spawn ttyd attached to a tmux session, write PID file
kill_ttyd() kill the running ttyd process, clean up PID file
kill_orphan_ttyd() kill any orphaned ttyd from a previous coordinator run
"""
import asyncio
import os
import signal
import subprocess as _subprocess
import time
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths and constants
# ---------------------------------------------------------------------------
_default_ttyd_pid_dir = Path.home() / ".local" / "share" / "tmux-web"
TTYD_PID_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_ttyd_pid_dir))
TTYD_PID_PATH: Path = TTYD_PID_DIR / "ttyd.pid"
TTYD_PORT: int = 7682
# ---------------------------------------------------------------------------
# Module state
# ---------------------------------------------------------------------------
_active_process: asyncio.subprocess.Process | None = None
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _kill_pids_on_port(port: int, sig: int) -> bool:
"""Find and signal all processes listening on *port* via lsof.
Returns True if at least one PID was found and signalled.
Silently ignores lsof unavailability and already-dead processes.
"""
try:
result = _subprocess.run(
["lsof", "-ti", f":{port}"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return False
sent = False
for pid_str in result.stdout.strip().split("\n"):
pid_str = pid_str.strip()
if not pid_str:
continue
try:
orphan_pid = int(pid_str)
os.kill(orphan_pid, sig)
sent = True
except (ValueError, ProcessLookupError, PermissionError):
pass
return sent
except Exception: # noqa: BLE001
# lsof not available, timed out, or other unexpected failure
return False
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
async def kill_ttyd() -> bool:
"""Kill the running ttyd process and clean up the PID file.
Belt-and-suspenders strategy:
Strategy 1 PID file:
Reads the PID from TTYD_PID_PATH. If no PID file exists, returns False.
If the file content is not a valid integer, removes the file and returns
False. Checks whether the process is alive via ``os.kill(pid, 0)``. If
already gone (ProcessLookupError), cleans up and proceeds. Otherwise
sends SIGTERM and polls every 0.1 s for up to 2 s.
Strategy 2 port-based fallback:
After the PID-file kill, finds and kills any process still listening on
TTYD_PORT via ``lsof -ti :<port>``. This catches orphaned ttyd processes
whose PID was never recorded in the file (e.g. after a coordinator crash).
A brief 0.3 s wait is added to let the OS release the port.
The PID file and ``_active_process`` are cleared in all cases before
returning.
Returns:
True a process was killed (or was already dead) via either strategy.
False no PID file found and no process was listening on the port.
"""
global _active_process
killed = False
# -------------------------------------------------------------------
# Strategy 1: PID file
# -------------------------------------------------------------------
if TTYD_PID_PATH.exists():
try:
pid = int(TTYD_PID_PATH.read_text().strip())
except ValueError:
TTYD_PID_PATH.unlink(missing_ok=True)
pid = None
else:
# Check whether the process is still alive.
try:
os.kill(pid, 0)
except ProcessLookupError:
# Already dead — clean up and note success.
TTYD_PID_PATH.unlink(missing_ok=True)
killed = True
pid = None
else:
# Process is alive — ask it to terminate.
os.kill(pid, signal.SIGTERM)
# Poll up to 2 s for the process to exit.
deadline = time.time() + 2.0
while time.time() < deadline:
try:
os.kill(pid, 0)
except (ProcessLookupError, PermissionError):
break
await asyncio.sleep(0.1)
TTYD_PID_PATH.unlink(missing_ok=True)
killed = True
pid = None # noqa: F841 (intentional)
# -------------------------------------------------------------------
# Strategy 2: port-based fallback — catch orphans not in PID file
# -------------------------------------------------------------------
if _kill_pids_on_port(TTYD_PORT, signal.SIGTERM):
killed = True
# Brief pause so the OS can release the port before the next spawn.
await asyncio.sleep(0.3)
_active_process = None
return killed
async def kill_orphan_ttyd() -> bool:
"""Kill any orphaned ttyd process left over from a previous coordinator run.
On coordinator startup, checks for a stale PID file from a previous run.
If found, kills the process (if still running) and removes the PID file.
Prevents two ttyd instances running simultaneously after a coordinator
restart or crash.
Delegates to kill_ttyd() for all process management and PID file cleanup.
Returns:
True an orphan was found (process was dead or alive).
False no PID file found, or PID file contained invalid content.
"""
return await kill_ttyd()
async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
"""Spawn a ttyd process attached to *session_name* via ``tmux attach``.
Runs::
ttyd -W -m 3 -p 7682 tmux attach -t <session_name>
Before spawning, verifies that TTYD_PORT is free. If any process is still
listening on the port (e.g. a race between kill_ttyd() and spawn_ttyd()),
it sends SIGKILL to force-free the port immediately.
stdout and stderr are discarded (DEVNULL). The PID is written to
TTYD_PID_PATH. The process handle is stored in ``_active_process``.
Args:
session_name: The tmux session name to attach to.
Returns:
The asyncio.subprocess.Process object for the spawned ttyd.
"""
global _active_process
# Final port-free guard — catches races where kill_ttyd() returned but
# the old ttyd hasn't fully released the socket yet.
if _kill_pids_on_port(TTYD_PORT, signal.SIGKILL):
await asyncio.sleep(0.3)
proc = await asyncio.create_subprocess_exec(
"ttyd",
"-W",
"-m",
"3",
"-p",
str(TTYD_PORT),
"tmux",
"attach",
"-t",
session_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
start_new_session=True, # detach from parent process group so ttyd survives independently
)
# Write PID file (create parent dirs if needed)
TTYD_PID_DIR.mkdir(parents=True, exist_ok=True)
TTYD_PID_PATH.write_text(str(proc.pid))
_active_process = proc
return proc
+1
View File
@@ -0,0 +1 @@
muxterm
+7
View File
@@ -0,0 +1,7 @@
module github.com/muxplex/muxterm
go 1.22
require github.com/creack/pty v1.1.24
require github.com/gorilla/websocket v1.5.3 // indirect
+4
View File
@@ -0,0 +1,4 @@
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+46
View File
@@ -0,0 +1,46 @@
package main
import (
"encoding/json"
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
)
func main() {
addr := flag.String("addr", "127.0.0.1:7682", "listen address")
secret := flag.String("secret", os.Getenv("MUXTERM_SECRET"), "shared secret (or set MUXTERM_SECRET)")
flag.Parse()
if *secret == "" {
log.Fatal("secret is required: use --secret or set MUXTERM_SECRET")
}
pool := NewPool()
// Graceful shutdown on SIGTERM/SIGINT
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sig
pool.CloseAll()
os.Exit(0)
}()
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
HandleWebSocket(pool, *secret, w, r)
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
log.Printf("muxterm listening on %s", *addr)
if err := http.ListenAndServe(*addr, nil); err != nil {
log.Fatal(err)
}
}
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"fmt"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"github.com/creack/pty"
)
// Session represents a PTY-backed tmux attach process.
type Session struct {
PTY *os.File
Cmd *exec.Cmd
Cols uint16
Rows uint16
}
// Pool manages named tmux PTY sessions.
type Pool struct {
mu sync.Mutex
sessions map[string]*Session
}
// NewPool returns an empty Pool.
func NewPool() *Pool {
return &Pool{
sessions: make(map[string]*Session),
}
}
// Attach reuses an existing live session or spawns a new tmux attach process.
// Dead sessions are cleaned up before spawning a replacement.
func (p *Pool) Attach(name string, cols, rows uint16) (*Session, error) {
p.mu.Lock()
defer p.mu.Unlock()
if sess, ok := p.sessions[name]; ok {
// Check if process is still alive via Signal(0).
if sess.Cmd.Process != nil && sess.Cmd.Process.Signal(syscall.Signal(0)) == nil {
return sess, nil
}
// Dead session — clean up.
sess.PTY.Close()
delete(p.sessions, name)
}
cmd := exec.Command("tmux", "attach", "-t", name)
// Always set TERM=xterm-256color so tmux can render to the PTY.
// When muxterm runs as a systemd service TERM is typically unset, and
// even when set to a basic type (e.g. "dumb") tmux fails immediately
// with "open terminal failed: terminal does not support clear screen".
// For a PTY-backed web terminal xterm-256color is always the right value.
env := make([]string, 0, len(os.Environ())+1)
for _, e := range os.Environ() {
if !strings.HasPrefix(e, "TERM=") {
env = append(env, e)
}
}
env = append(env, "TERM=xterm-256color")
cmd.Env = env
winSize := &pty.Winsize{Cols: cols, Rows: rows}
ptmx, err := pty.StartWithSize(cmd, winSize)
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}
sess := &Session{
PTY: ptmx,
Cmd: cmd,
Cols: cols,
Rows: rows,
}
p.sessions[name] = sess
// Background goroutine: wait for process exit and clean up.
go func() {
cmd.Wait()
p.mu.Lock()
defer p.mu.Unlock()
// Only delete if the map still holds this exact session
// (Attach may have replaced it already).
if cur, ok := p.sessions[name]; ok && cur == sess {
sess.PTY.Close()
delete(p.sessions, name)
}
}()
return sess, nil
}
// Resize changes the PTY window size for a named session.
func (p *Pool) Resize(name string, cols, rows uint16) error {
p.mu.Lock()
defer p.mu.Unlock()
sess, ok := p.sessions[name]
if !ok {
return fmt.Errorf("session %q not found", name)
}
if err := pty.Setsize(sess.PTY, &pty.Winsize{Cols: cols, Rows: rows}); err != nil {
return fmt.Errorf("setsize: %w", err)
}
sess.Cols = cols
sess.Rows = rows
return nil
}
// Get returns the session for name, or nil if not present.
func (p *Pool) Get(name string) *Session {
p.mu.Lock()
defer p.mu.Unlock()
return p.sessions[name]
}
// IsAlive returns true if the named session exists and its process is alive.
func (p *Pool) IsAlive(name string) bool {
p.mu.Lock()
defer p.mu.Unlock()
sess, ok := p.sessions[name]
if !ok {
return false
}
return sess.Cmd.Process != nil && sess.Cmd.Process.Signal(syscall.Signal(0)) == nil
}
// CloseAll sends SIGTERM to all sessions and closes their PTYs.
func (p *Pool) CloseAll() {
p.mu.Lock()
defer p.mu.Unlock()
for name, sess := range p.sessions {
if sess.Cmd.Process != nil {
sess.Cmd.Process.Signal(syscall.SIGTERM)
}
sess.PTY.Close()
delete(p.sessions, name)
}
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"os/exec"
"testing"
"time"
)
func TestNewPool(t *testing.T) {
p := NewPool()
if p == nil {
t.Fatal("NewPool returned nil")
}
}
func TestPool_Get_NoSession(t *testing.T) {
p := NewPool()
s := p.Get("nonexistent")
if s != nil {
t.Fatal("Get on empty pool should return nil")
}
}
func TestPool_IsAlive_NoSession(t *testing.T) {
p := NewPool()
if p.IsAlive("nonexistent") {
t.Fatal("IsAlive on empty pool should return false")
}
}
func TestPool_CloseAll_Empty(t *testing.T) {
p := NewPool()
p.CloseAll() // must not panic
}
func TestPool_Attach_Real(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
name := "test-muxterm"
// Ensure the tmux session exists.
exec.Command("tmux", "kill-session", "-t", name).Run()
cmd := exec.Command("tmux", "new-session", "-d", "-s", name, "-x", "80", "-y", "24")
if err := cmd.Run(); err != nil {
t.Fatalf("failed to create tmux session %q: %v", name, err)
}
defer exec.Command("tmux", "kill-session", "-t", name).Run()
p := NewPool()
defer p.CloseAll()
// Attach
sess, err := p.Attach(name, 80, 24)
if err != nil {
t.Fatalf("Attach failed: %v", err)
}
if sess == nil {
t.Fatal("Attach returned nil session")
}
if sess.PTY == nil {
t.Fatal("Session PTY is nil")
}
// IsAlive
if !p.IsAlive(name) {
t.Fatal("IsAlive should return true after Attach")
}
// Resize
err = p.Resize(name, 120, 40)
if err != nil {
t.Fatalf("Resize failed: %v", err)
}
resized := p.Get(name)
if resized.Cols != 120 {
t.Errorf("After resize Cols = %d, want 120", resized.Cols)
}
if resized.Rows != 40 {
t.Errorf("After resize Rows = %d, want 40", resized.Rows)
}
// Cleanup
p.CloseAll()
time.Sleep(100 * time.Millisecond)
if p.IsAlive(name) {
t.Fatal("IsAlive should return false after CloseAll")
}
}
+94
View File
@@ -0,0 +1,94 @@
package main
import "encoding/json"
// --- Client-to-server message types ---
// AttachMsg requests attaching to a named tmux session.
type AttachMsg struct {
Attach string `json:"attach"`
}
// ResizeMsg requests a PTY resize.
type ResizeMsg struct {
Resize struct {
Cols int `json:"cols"`
Rows int `json:"rows"`
} `json:"resize"`
}
// DetachMsg requests detaching from the current session.
type DetachMsg struct {
Detach bool `json:"detach"`
}
// --- Server-to-client message types ---
// AttachedMsg confirms a successful attach.
type AttachedMsg struct {
Attached string `json:"attached"`
}
// ErrorMsg reports an error to the client.
type ErrorMsg struct {
Error string `json:"error"`
}
// ExitedMsg reports that a session's process has exited.
type ExitedMsg struct {
Exited string `json:"exited"`
}
// ParseControlMessage unmarshals a JSON control message and returns the
// message kind and a pointer to the typed value. Returns ("invalid", nil)
// for unparseable JSON and ("unknown", nil) for unrecognised keys.
func ParseControlMessage(data []byte) (string, interface{}) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return "invalid", nil
}
if _, ok := raw["attach"]; ok {
var msg AttachMsg
if err := json.Unmarshal(data, &msg); err != nil {
return "invalid", nil
}
return "attach", &msg
}
if _, ok := raw["resize"]; ok {
var msg ResizeMsg
if err := json.Unmarshal(data, &msg); err != nil {
return "invalid", nil
}
return "resize", &msg
}
if _, ok := raw["detach"]; ok {
var msg DetachMsg
if err := json.Unmarshal(data, &msg); err != nil {
return "invalid", nil
}
return "detach", &msg
}
return "unknown", nil
}
// MarshalAttached returns JSON for an AttachedMsg.
func MarshalAttached(session string) []byte {
b, _ := json.Marshal(AttachedMsg{Attached: session})
return b
}
// MarshalError returns JSON for an ErrorMsg.
func MarshalError(msg string) []byte {
b, _ := json.Marshal(ErrorMsg{Error: msg})
return b
}
// MarshalExited returns JSON for an ExitedMsg.
func MarshalExited(session string) []byte {
b, _ := json.Marshal(ExitedMsg{Exited: session})
return b
}
+109
View File
@@ -0,0 +1,109 @@
package main
import (
"encoding/json"
"testing"
)
func TestParseAttachMessage(t *testing.T) {
data := []byte(`{"attach": "dev-server"}`)
kind, val := ParseControlMessage(data)
if kind != "attach" {
t.Fatalf("expected kind=attach, got %q", kind)
}
msg, ok := val.(*AttachMsg)
if !ok {
t.Fatalf("expected *AttachMsg, got %T", val)
}
if msg.Attach != "dev-server" {
t.Errorf("expected Attach=%q, got %q", "dev-server", msg.Attach)
}
}
func TestParseResizeMessage(t *testing.T) {
data := []byte(`{"resize": {"cols": 120, "rows": 40}}`)
kind, val := ParseControlMessage(data)
if kind != "resize" {
t.Fatalf("expected kind=resize, got %q", kind)
}
msg, ok := val.(*ResizeMsg)
if !ok {
t.Fatalf("expected *ResizeMsg, got %T", val)
}
if msg.Resize.Cols != 120 {
t.Errorf("expected Cols=120, got %d", msg.Resize.Cols)
}
if msg.Resize.Rows != 40 {
t.Errorf("expected Rows=40, got %d", msg.Resize.Rows)
}
}
func TestParseDetachMessage(t *testing.T) {
data := []byte(`{"detach": true}`)
kind, val := ParseControlMessage(data)
if kind != "detach" {
t.Fatalf("expected kind=detach, got %q", kind)
}
msg, ok := val.(*DetachMsg)
if !ok {
t.Fatalf("expected *DetachMsg, got %T", val)
}
if !msg.Detach {
t.Errorf("expected Detach=true")
}
}
func TestParseInvalidJSON(t *testing.T) {
data := []byte(`not json`)
kind, val := ParseControlMessage(data)
if kind != "invalid" {
t.Errorf("expected kind=invalid, got %q", kind)
}
if val != nil {
t.Errorf("expected nil val for invalid, got %v", val)
}
}
func TestParseUnknownMessage(t *testing.T) {
data := []byte(`{"foo": "bar"}`)
kind, val := ParseControlMessage(data)
if kind != "unknown" {
t.Errorf("expected kind=unknown, got %q", kind)
}
if val != nil {
t.Errorf("expected nil val for unknown, got %v", val)
}
}
func TestMarshalAttached(t *testing.T) {
data := MarshalAttached("dev-server")
var msg AttachedMsg
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if msg.Attached != "dev-server" {
t.Errorf("expected Attached=%q, got %q", "dev-server", msg.Attached)
}
}
func TestMarshalError(t *testing.T) {
data := MarshalError("something broke")
var msg ErrorMsg
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if msg.Error != "something broke" {
t.Errorf("expected Error=%q, got %q", "something broke", msg.Error)
}
}
func TestMarshalExited(t *testing.T) {
data := MarshalExited("dev-server")
var msg ExitedMsg
if err := json.Unmarshal(data, &msg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if msg.Exited != "dev-server" {
t.Errorf("expected Exited=%q, got %q", "dev-server", msg.Exited)
}
}
+188
View File
@@ -0,0 +1,188 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/gorilla/websocket"
)
// ValidateToken checks an HMAC-SHA256 signed token of the form "signature.timestamp".
// It validates the timestamp is within ttl seconds (not too old) and within 5 seconds
// of clock skew (not too far in the future), then verifies the HMAC signature.
func ValidateToken(token, secret string, ttl int64) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
sig := parts[0]
tsStr := parts[1]
ts, err := strconv.ParseInt(tsStr, 10, 64)
if err != nil {
return false
}
now := time.Now().Unix()
if now-ts > ttl {
return false
}
if ts-now > 5 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(tsStr))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(sig), []byte(expected))
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
host := r.Host
if strings.Contains(host, ":") {
host = strings.Split(host, ":")[0]
}
return host == "localhost" || host == "127.0.0.1"
},
}
// HandleWebSocket validates the token query parameter and upgrades the connection
// to a WebSocket, then runs the main read loop for control and binary messages.
func HandleWebSocket(pool *Pool, secret string, w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if !ValidateToken(token, secret, 30) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade: %v", err)
return
}
defer conn.Close()
var (
mu sync.Mutex
activeSession string
activePTY *os.File
ptyCancel chan struct{}
)
startRelay := func(sess *Session, name string) {
cancel := make(chan struct{})
mu.Lock()
ptyCancel = cancel
mu.Unlock()
go func() {
buf := make([]byte, 32*1024)
for {
select {
case <-cancel:
return
default:
}
n, err := sess.PTY.Read(buf)
if n > 0 {
if writeErr := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
return
}
}
if err != nil {
if err == io.EOF || strings.Contains(err.Error(), "input/output error") {
conn.WriteMessage(websocket.TextMessage, MarshalExited(name))
}
return
}
}
}()
}
stopRelay := func() {
mu.Lock()
defer mu.Unlock()
if ptyCancel != nil {
close(ptyCancel)
ptyCancel = nil
}
}
defer stopRelay()
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
return
}
switch msgType {
case websocket.TextMessage:
kind, msg := ParseControlMessage(data)
switch kind {
case "attach":
attach := msg.(*AttachMsg)
stopRelay()
sess, err := pool.Attach(attach.Attach, 80, 24)
if err != nil {
conn.WriteMessage(websocket.TextMessage, MarshalError(err.Error()))
continue
}
mu.Lock()
activeSession = attach.Attach
activePTY = sess.PTY
mu.Unlock()
sendSIGWINCH(sess)
startRelay(sess, attach.Attach)
conn.WriteMessage(websocket.TextMessage, MarshalAttached(attach.Attach))
case "resize":
resize := msg.(*ResizeMsg)
mu.Lock()
name := activeSession
mu.Unlock()
if name != "" {
pool.Resize(name, uint16(resize.Resize.Cols), uint16(resize.Resize.Rows))
}
case "detach":
stopRelay()
mu.Lock()
activeSession = ""
activePTY = nil
mu.Unlock()
}
case websocket.BinaryMessage:
mu.Lock()
pty := activePTY
mu.Unlock()
if pty != nil {
pty.Write(data)
}
}
}
}
// sendSIGWINCH sends SIGWINCH to the session's process to trigger tmux repaint.
func sendSIGWINCH(s *Session) {
if s == nil || s.Cmd == nil || s.Cmd.Process == nil {
return
}
s.Cmd.Process.Signal(syscall.SIGWINCH)
}
+61
View File
@@ -0,0 +1,61 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"testing"
"time"
)
// makeToken creates a valid HMAC-SHA256 token: sig.timestamp
func makeToken(secret string, ts int64) string {
tsStr := fmt.Sprintf("%d", ts)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(tsStr))
sig := hex.EncodeToString(mac.Sum(nil))
return sig + "." + tsStr
}
func TestValidateToken_Valid(t *testing.T) {
secret := "test-secret"
ts := time.Now().Unix()
token := makeToken(secret, ts)
if !ValidateToken(token, secret, 30) {
t.Error("expected valid token to pass validation")
}
}
func TestValidateToken_Expired(t *testing.T) {
secret := "test-secret"
ts := time.Now().Unix() - 60 // 60 seconds ago
token := makeToken(secret, ts)
if ValidateToken(token, secret, 30) {
t.Error("expected expired token (60s ago) to be rejected")
}
}
func TestValidateToken_WrongSecret(t *testing.T) {
ts := time.Now().Unix()
token := makeToken("right-secret", ts)
if ValidateToken(token, "wrong-secret", 30) {
t.Error("expected token signed with wrong secret to be rejected")
}
}
func TestValidateToken_MalformedNoTimestamp(t *testing.T) {
// No dot separator — cannot split into sig.timestamp
if ValidateToken("nodothere", "secret", 30) {
t.Error("expected malformed token (no dot) to be rejected")
}
}
func TestValidateToken_EmptyToken(t *testing.T) {
if ValidateToken("", "secret", 30) {
t.Error("expected empty token to be rejected")
}
}
Generated
+1 -1
View File
@@ -332,7 +332,7 @@ wheels = [
[[package]] [[package]]
name = "muxplex" name = "muxplex"
version = "0.6.0" version = "0.6.7"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },