Compare commits

..

2 Commits

Author SHA1 Message Date
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
12 changed files with 893 additions and 133 deletions
+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) |
+42 -23
View File
@@ -1,5 +1,20 @@
// Phase 2b implementation — app.js
/** Inline SVG icon strings used throughout the UI. */
var _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>',
};
/**
* Format a Unix timestamp (seconds) into a relative time string.
* @param {number|null|undefined} ts - Unix timestamp in seconds
@@ -324,13 +339,13 @@ function setConnectionStatus(level) {
const el = $('connection-status');
if (!el) return;
const map = {
ok: { text: '●', cls: 'connection-status--ok' },
warn: { text: ' slow', cls: 'connection-status--warn' },
err: { text: ' offline', cls: 'connection-status--err' },
ok: { text: _icons.statusOk, cls: 'connection-status--ok' },
warn: { text: _icons.statusWarn + ' slow', cls: 'connection-status--warn' },
err: { text: _icons.statusErr + ' offline', cls: 'connection-status--err' },
};
const s = map[level];
if (!s) return;
el.textContent = s.text;
el.innerHTML = s.text;
el.className = s.cls;
}
@@ -543,7 +558,7 @@ function buildTileHTML(session, index, mobile) {
`<span class="tile-name">${escapeHtml(name)}</span>` +
`${badgeHtml}` +
`<span class="tile-meta">${escapeHtml(timeStr)}</span>` +
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">&#8942;</button>` +
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
`</div>` +
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
`</article>`
@@ -601,7 +616,7 @@ function buildSidebarHTML(session, currentSession, currentRemoteId) {
`<div class="sidebar-item-header">` +
`<span class="sidebar-item-name">${escapedName}</span>` +
badgeHtml +
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">&#8942;</button>` +
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">${_icons.kebab}</button>` +
`</div>` +
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
`</article>`
@@ -959,7 +974,7 @@ function toggleSidebar() {
patchServerSetting('sidebarOpen', isOpen);
var collapseBtn = $('sidebar-collapse-btn');
if (collapseBtn) collapseBtn.textContent = isOpen ? '\u2039' : '\u203a';
if (collapseBtn) collapseBtn.innerHTML = isOpen ? _icons.chevronLeft : _icons.chevronRight;
}
/**
@@ -1437,7 +1452,7 @@ function renderViewsSettingsTab() {
// Up button
var upBtn = document.createElement('button');
upBtn.className = 'views-settings-btn';
upBtn.textContent = '\u25b2';
upBtn.innerHTML = _icons.chevronUp;
upBtn.title = 'Move up';
upBtn.setAttribute('data-action', 'move-up');
upBtn.setAttribute('data-idx', String(idx));
@@ -1446,7 +1461,7 @@ function renderViewsSettingsTab() {
// Down button
var downBtn = document.createElement('button');
downBtn.className = 'views-settings-btn';
downBtn.textContent = '\u25bc';
downBtn.innerHTML = _icons.chevronDown;
downBtn.title = 'Move down';
downBtn.setAttribute('data-action', 'move-down');
downBtn.setAttribute('data-idx', String(idx));
@@ -2038,7 +2053,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
var v = views[i];
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
html += '<button class="flyout-sheet__item" role="menuitem" data-view-index="' + i + '">';
html += '<span style="margin-right:8px">' + (isIn ? '\u2713' : '\u00a0\u00a0') + '</span>';
html += '<span style="margin-right:8px">' + (isIn ? _icons.check : '\u00a0\u00a0') + '</span>';
html += escapeHtml(v.name);
html += '</button>';
}
@@ -2082,7 +2097,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
// Update checkmark immediately for responsiveness
var checkEl = viewBtn.querySelector('span');
if (checkEl) checkEl.textContent = nowIn ? '\u2713' : '\u00a0\u00a0';
if (checkEl) checkEl.innerHTML = nowIn ? _icons.check : '\u00a0\u00a0';
api('PATCH', '/api/settings', patch)
.then(function() {
@@ -2095,7 +2110,7 @@ function _openMobileViewPicker(sessionKey, sessionName, unhideFirst) {
.catch(function(err) {
showToast('Couldn\u2019t save \u2014 try again');
// Revert checkmark
if (checkEl) checkEl.textContent = nowIn ? '\u00a0\u00a0' : '\u2713';
if (checkEl) checkEl.innerHTML = nowIn ? '\u00a0\u00a0' : _icons.check;
console.warn('[_openMobileViewPicker] PATCH failed:', err);
});
});
@@ -2170,7 +2185,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
var v = views[i];
var isIn = (v.sessions || []).indexOf(sessionKey) !== -1;
html += '<button class="flyout-submenu__item" role="menuitem" data-view-index="' + i + '">';
html += '<span class="flyout-submenu__check">' + (isIn ? '\u2713' : '') + '</span>';
html += '<span class="flyout-submenu__check">' + (isIn ? _icons.check : '') + '</span>';
html += escapeHtml(v.name);
html += '</button>';
}
@@ -2283,7 +2298,7 @@ function _openFlyoutSubmenu(triggerItem, unhideFirst) {
var checkEl = checkItems[ci].querySelector('.flyout-submenu__check');
var updViews = (_serverSettings && _serverSettings.views) || [];
if (checkEl && updViews[vi]) {
checkEl.textContent = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? '\u2713' : '';
checkEl.innerHTML = (updViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? _icons.check : '';
}
}
}
@@ -2474,7 +2489,7 @@ function openManageViewPanel() {
nameRow.innerHTML =
'<h2 id="manage-view-name" class="manage-view-panel__name">' + escapeHtml(_activeView) + '</h2>' +
'<button id="manage-view-delete-btn" class="manage-view-panel__delete-btn" ' +
'title="Delete this view" aria-label="Delete view">\u2715</button>';
'title="Delete this view" aria-label="Delete view">' + _icons.close + '</button>';
}
renderManageViewList();
@@ -3193,7 +3208,7 @@ function _buildRemoteInstanceRow(url, name, key) {
keyInput.setAttribute('aria-label', 'Federation key for remote instance');
var removeBtn = document.createElement('button');
removeBtn.className = 'settings-remote-remove';
removeBtn.textContent = '\u00d7';
removeBtn.innerHTML = _icons.close;
removeBtn.setAttribute('aria-label', 'Remove remote instance');
row.appendChild(urlInput);
row.appendChild(nameInput);
@@ -3731,7 +3746,7 @@ function renderSheetList() {
return '<li class="sheet-item' + (isActive ? ' sheet-item--active' : '') + '"' +
' data-session="' + escapedName + '"' + remoteIdAttr + ' role="option">' +
'<span class="sheet-item__name">' + escapedName + '</span>' +
(hasBell ? '<span class="sheet-item__bell">\uD83D\uDD14</span>' : '') +
(hasBell ? '<span class="sheet-item__bell">' + _icons.bell + '</span>' : '') +
'<span class="sheet-item__time">' + formatTimestamp(s.bell && s.bell.last_fired_at) + '</span>' +
'</li>';
}).join('');
@@ -4013,26 +4028,30 @@ async function createNewSession(name, remoteId) {
// Compute expectedKey: for remote sessions, use 'deviceId:sessionName' (sessionKey format)
var expectedKey = deviceId ? (deviceId + ':' + sessionName) : sessionName;
// Poll until the session appears in _currentSessions (max 30s, every 2s)
// Poll until the session appears in _currentSessions.
// Check immediately first (session may already be in cache), then retry
// at short intervals. This eliminates the old 2-second dead-wait.
var attempts = 0;
var maxAttempts = 15;
var pollForSession = setInterval(async function() {
var maxAttempts = 30;
async function checkForSession() {
attempts++;
await pollSessions();
var found = _currentSessions && _currentSessions.find(function(s) {
return (s.sessionKey || s.name) === expectedKey;
});
if (found) {
clearInterval(pollForSession);
removeLoadingTile();
showToast('Session \'' + sessionName + '\' ready');
openSession(sessionName, { remoteId: deviceId });
} else if (attempts >= maxAttempts) {
clearInterval(pollForSession);
removeLoadingTile();
showToast('Session \'' + sessionName + '\' is taking longer than expected');
} else {
// Short retry: 500ms for first few attempts, then 2s
setTimeout(checkForSession, attempts <= 5 ? 500 : 2000);
}
}, 2000);
}
checkForSession();
} catch (err) {
showToast(err.message || 'Failed to create session');
}
+32 -15
View File
@@ -22,14 +22,14 @@
<div class="view-dropdown" id="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>
<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>
<div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
</div>
<div class="header-actions">
<button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
<button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">&#9638;</button>
<button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</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"><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"><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>
</div>
</header>
@@ -41,10 +41,10 @@
<!-- ── Expanded (terminal) view ──────────────────────────────────────── -->
<div id="view-expanded" class="view hidden">
<header class="expanded-header">
<button id="back-btn" class="back-btn" aria-label="Back">&#8592;</button>
<button id="sidebar-toggle-btn" class="sidebar-toggle-btn" aria-label="Toggle session list">&#9776;</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"><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>
<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>
<div class="view-body">
<div id="session-sidebar" class="session-sidebar">
@@ -52,11 +52,11 @@
<div class="sidebar-view-dropdown" id="sidebar-view-dropdown">
<button id="sidebar-view-dropdown-trigger" class="sidebar-view-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="sidebar-view-dropdown-menu">
<span id="sidebar-view-label">All Sessions</span>
<span class="view-dropdown__caret" aria-hidden="true">&#9662;</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>
<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>
<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>
</div>
<div id="sidebar-list" class="sidebar-list"></div>
<div class="sidebar-footer">
@@ -67,11 +67,28 @@
<div id="terminal-search-bar" class="terminal-search-bar hidden">
<input id="terminal-search-input" type="text" class="terminal-search-input" placeholder="Find..." />
<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-next" class="terminal-search-btn" title="Next (Enter)">&#9660;</button>
<button id="terminal-search-close" class="terminal-search-btn" title="Close (Escape)">&times;</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)"><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)"><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 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 id="reconnect-overlay" class="reconnect-overlay hidden" aria-live="polite">Reconnecting&hellip;</div>
@@ -106,11 +123,11 @@
<!-- ── Session pill (persistent overlay button) ────────────────────────── -->
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
<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>
<span id="session-pill-bell" class="session-pill__bell hidden" 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>
<!-- ── 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>
<!-- ── Toast notification ──────────────────────────────────────────────── -->
<div id="toast" class="toast hidden" role="status" aria-live="polite" aria-atomic="true"></div>
@@ -120,7 +137,7 @@
<dialog id="settings-dialog" class="settings-dialog">
<div class="settings-dialog-header">
<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 class="settings-layout">
<nav class="settings-tabs">
+102 -2
View File
@@ -53,6 +53,23 @@
--font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace;
}
/* 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 */
*,
*::before,
@@ -752,9 +769,13 @@ body {
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 {
overflow-y: hidden !important;
scrollbar-width: none;
}
.xterm .xterm-viewport::-webkit-scrollbar {
display: none;
}
.reconnect-overlay {
@@ -2056,6 +2077,13 @@ body {
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
============================================================ */
@@ -2539,3 +2567,75 @@ body {
margin: 4px 0;
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);
}
+132 -59
View File
@@ -11,6 +11,8 @@ let _vpHandler = null;
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
let _searchAddon = null;
let _resizeObserver = null;
let _ctrlActive = false; // mobile toolbar: Ctrl modifier toggle (one-shot)
let _altActive = false; // mobile toolbar: Alt modifier toggle (one-shot)
// ─── Module-level encoding helpers ──────────────────────────────────────────
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
@@ -76,8 +78,24 @@ function connectWebSocket(name, remoteId) {
if (_term) {
_term.onData(function(data) {
if (_ws && _ws.readyState === WebSocket.OPEN) {
var outData = data;
// Mobile toolbar modifier keys (one-shot: auto-deactivate after use)
if (_ctrlActive && data.length === 1) {
var code = data.toUpperCase().charCodeAt(0);
if (code >= 65 && code <= 90) { // A-Z → Ctrl+letter
outData = String.fromCharCode(code - 64);
}
_ctrlActive = false;
var cb = document.querySelector('[data-modifier="ctrl"]');
if (cb) cb.classList.remove('mobile-toolbar__key--active');
} else if (_altActive && data.length === 1) {
outData = '\x1b' + data; // Alt = ESC prefix
_altActive = false;
var ab = document.querySelector('[data-modifier="alt"]');
if (ab) ab.classList.remove('mobile-toolbar__key--active');
}
// ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
_ws.send(encodePayload(0x30, data));
_ws.send(encodePayload(0x30, outData));
}
});
_term.onResize(function(size) {
@@ -206,12 +224,19 @@ function initVisualViewport() {
var container = document.getElementById('terminal-container');
if (!container) return;
// Resize container to fill visual viewport above keyboard
// Resize container to fill visual viewport above keyboard.
// Account for header + mobile toolbar (if visible).
var headerHeight = 44; // matches --header-height CSS custom property
var toolbar = document.getElementById('mobile-toolbar');
var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0;
var vvh = window.visualViewport.height;
var termHeight = Math.max(100, vvh - headerHeight);
var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight);
container.style.height = termHeight + 'px';
// Ensure terminal viewport stays pinned — prevent the page from
// shifting upward when the soft keyboard opens on mobile.
window.scrollTo(0, 0);
// Refit xterm.js to new container size
try { _fitAddon.fit(); } catch (_) {}
};
@@ -501,6 +526,10 @@ function openTerminal(sessionName, remoteId, fontSize) {
connectWebSocket(sessionName, remoteId);
initVisualViewport(); /* defined in Task 14 */
// --- Mobile enhancements ---
_initAndroidIMEFix(container);
_initMobileToolbar();
}
/**
@@ -524,6 +553,12 @@ function closeTerminal() {
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
// Clean up mobile enhancements
_ctrlActive = false;
_altActive = false;
var mobileToolbar = document.getElementById('mobile-toolbar');
if (mobileToolbar) mobileToolbar.classList.add('hidden');
if (_term) {
_term.dispose();
_term = null;
@@ -563,71 +598,109 @@ function setTerminalFontSize(size) {
window._setTerminalFontSize = setTerminalFontSize;
// ---------------------------------------------------------------------------
// Mobile touch scroll — rAF-batched WheelEvent dispatch
// Mobile devices batch touchmove events irregularly; dispatching one WheelEvent
// per frame (via requestAnimationFrame) smooths over burst delivery.
// Applies to Android, iOS, and iPadOS touch devices.
// Android IME fix — prevent double-space-to-period from duplicating input
// Android keyboards use insertReplacementText beforeinput events to perform
// auto-corrections (e.g. " " → ". "). xterm.js's hidden textarea gets confused
// by these replacements, causing the entire composition buffer to replay.
// ---------------------------------------------------------------------------
;(function initMobileTerminalScroll() {
var isTouchDevice = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
function _initAndroidIMEFix(container) {
if (!/Android/i.test(navigator.userAgent)) return;
// Wait a tick for xterm.js to create its hidden textarea
setTimeout(function() {
var ta = container.querySelector('.xterm-helper-textarea');
if (!ta) return;
ta.addEventListener('beforeinput', function(e) {
if (e.inputType === 'insertReplacementText') {
e.preventDefault();
e.stopImmediatePropagation();
var data = e.data || '';
if (data && _ws && _ws.readyState === WebSocket.OPEN) {
// Send backspace (delete the char being replaced) + replacement text
_ws.send(_encodePayload(0x30, '\x08' + data));
}
// Clear textarea to prevent stale IME state
ta.value = '';
}
}, true); // capture phase — run before xterm.js handlers
}, 100);
}
// ---------------------------------------------------------------------------
// Mobile toolbar — virtual keys for Esc, Tab, Ctrl, Alt, arrows, specials
// Ctrl and Alt are one-shot modifier toggles: tap Ctrl, then type a letter
// on the soft keyboard → sends Ctrl+letter, modifier auto-deactivates.
// ---------------------------------------------------------------------------
function _initMobileToolbar() {
var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (!isTouchDevice) return;
var container = document.getElementById('terminal-container');
if (!container) return;
var toolbar = document.getElementById('mobile-toolbar');
if (!toolbar) return;
var _lastY = 0;
var _accumulated = 0; // pixel debt between rAF ticks
var _rafId = null;
var SCROLL_PX = 20; // pixels of touch movement = one WheelEvent dispatch
toolbar.classList.remove('hidden');
_ctrlActive = false;
_altActive = false;
function flushScroll() {
_rafId = null;
if (!_term || Math.abs(_accumulated) < SCROLL_PX) return;
var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]');
var altBtn = toolbar.querySelector('[data-modifier="alt"]');
var viewport = container.querySelector('.xterm-viewport');
if (!viewport) { _accumulated = 0; return; }
// Single delegated handler for all toolbar keys.
// Use 'pointerdown' + preventDefault to avoid focusing xterm's hidden textarea
// (which brings up the soft keyboard). Modifier toggles (Ctrl/Alt) DO focus
// intentionally so the user can type the next character on the soft keyboard.
toolbar.addEventListener('pointerdown', function(e) {
var btn = e.target.closest('.mobile-toolbar__key');
if (!btn) return;
// One WheelEvent per frame — dir * 120 = one standard scroll click
var dir = _accumulated > 0 ? 1 : -1;
viewport.dispatchEvent(new WheelEvent('wheel', {
deltaY: dir * 120,
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
bubbles: true,
cancelable: true,
}));
_accumulated -= dir * SCROLL_PX;
// Self-schedule until remainder is consumed
if (Math.abs(_accumulated) >= SCROLL_PX) {
_rafId = requestAnimationFrame(flushScroll);
// --- Modifier toggles: focus terminal to bring up keyboard ---
var modifier = btn.dataset.modifier;
if (modifier === 'ctrl') {
e.preventDefault();
_ctrlActive = !_ctrlActive;
btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive);
if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); }
// Focus terminal so soft keyboard appears for the next character
if (_ctrlActive && _term) _term.focus();
return;
}
}
container.addEventListener('touchstart', function (e) {
_lastY = e.touches[0].clientY;
_accumulated = 0;
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
}, { passive: true });
container.addEventListener('touchmove', function (e) {
if (!_term) return;
e.preventDefault(); // block outer-container scroll
var y = e.touches[0].clientY;
_accumulated += _lastY - y; // positive = swipe up = newer content
_lastY = y;
if (!_rafId) {
_rafId = requestAnimationFrame(flushScroll);
if (modifier === 'alt') {
e.preventDefault();
_altActive = !_altActive;
btn.classList.toggle('mobile-toolbar__key--active', _altActive);
if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); }
// Focus terminal so soft keyboard appears for the next character
if (_altActive && _term) _term.focus();
return;
}
}, { passive: false }); // passive:false required for preventDefault
container.addEventListener('touchend', function () {
_lastY = 0;
_accumulated = 0;
if (_rafId) { cancelAnimationFrame(_rafId); _rafId = null; }
}, { passive: true });
})();
// --- Direct key presses: send sequence WITHOUT focusing (no keyboard popup) ---
e.preventDefault(); // prevent focus transfer to xterm textarea
var key = btn.dataset.key;
var input = btn.dataset.input;
var seq = '';
if (key) {
switch (key) {
case 'Escape': seq = '\x1b'; break;
case 'Tab': seq = '\t'; break;
case 'ArrowUp': seq = '\x1b[A'; break;
case 'ArrowDown': seq = '\x1b[B'; break;
case 'ArrowRight': seq = '\x1b[C'; break;
case 'ArrowLeft': seq = '\x1b[D'; break;
}
} else if (input) {
seq = input;
}
if (seq && _ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(_encodePayload(0x30, seq));
}
// Do NOT focus terminal — keeps soft keyboard hidden
});
}
+27 -4
View File
@@ -20,6 +20,7 @@ import pwd
import re
import socket
import ssl
import shlex
import shutil
import subprocess
import sys
@@ -374,7 +375,9 @@ async def lifespan(app: FastAPI):
"set-hook",
"-g",
"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:
pass # tmux not running at startup is OK; hook will be set on first poll
@@ -635,7 +638,7 @@ async def create_session(payload: CreateSessionPayload) -> dict:
"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)
try:
proc = await asyncio.create_subprocess_shell(
@@ -692,6 +695,16 @@ async def create_session(payload: CreateSessionPayload) -> dict:
status_code=500,
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 = await enumerate_sessions()
fresh_snapshots = await snapshot_all(fresh_names)
update_session_cache(fresh_names, fresh_snapshots)
except Exception:
pass # non-fatal; poll loop will catch up
return {"name": name, "ok": True}
@@ -713,6 +726,14 @@ async def connect_session(name: str) -> dict:
await kill_ttyd()
await spawn_ttyd(name)
# Wait for ttyd to actually bind its port before returning.
# This eliminates the 0.8s blind sleep in the WebSocket proxy path —
# the client can connect immediately when this endpoint responds.
for _attempt in range(20): # up to ~1s (20 × 50ms)
if _ttyd_is_listening():
break
await asyncio.sleep(0.05)
async with state_lock:
state = load_state()
state["active_session"] = name
@@ -758,7 +779,7 @@ async def delete_session(name: str) -> dict:
settings = load_settings()
command = settings.get(
"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)
try:
@@ -858,7 +879,9 @@ async def setup_hooks() -> dict:
"set-hook",
"-g",
"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}
except Exception as e:
+3 -2
View File
@@ -963,8 +963,9 @@ def test_html_fab_exists() -> None:
assert fab.get("aria-label") == "New session", (
f"#new-session-fab must have aria-label='New session', got: {fab.get('aria-label')!r}"
)
text = fab.get_text(strip=True)
assert text == "+", f"#new-session-fab text must be '+', got: {text!r}"
# FAB uses an inline SVG icon instead of text
svg = fab.find("svg")
assert svg is not None, "#new-session-fab must contain an SVG icon"
def test_html_fab_before_toast() -> None:
+3 -3
View File
@@ -1734,9 +1734,9 @@ def test_create_new_session_polls_before_open() -> None:
assert "setTimeout(() => openSession" not in body, (
"createNewSession must not use immediate setTimeout(() => openSession) — should poll instead"
)
# New polling pattern must be present
assert "setInterval" in body, (
"createNewSession must use setInterval to poll for session readiness"
# Polling pattern must be present (recursive setTimeout or setInterval)
assert "setTimeout" in body or "setInterval" in body, (
"createNewSession must poll for session readiness (setTimeout or setInterval)"
)
+122 -2
View File
@@ -1,6 +1,8 @@
"""
Tests for coordinator/ttyd.py ttyd process lifecycle management.
All 11 acceptance-criteria tests are defined here.
All 11 acceptance-criteria tests are defined here, plus 5 tests for the
_pids_on_port() lsoffuserss fallback chain (the root-cause guard for systems
where lsof is not installed).
"""
import signal
@@ -9,7 +11,13 @@ 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
from muxplex.ttyd import (
_kill_pids_on_port,
_pids_on_port,
kill_orphan_ttyd,
kill_ttyd,
spawn_ttyd,
)
# ---------------------------------------------------------------------------
@@ -311,6 +319,118 @@ async def test_spawn_ttyd_force_kills_process_on_port_before_binding():
)
# ---------------------------------------------------------------------------
# _pids_on_port fallback chain tests
#
# These cover the exact production failure: lsof not installed → all kill
# strategies silently returned False → stale ttyd survived restarts.
# ---------------------------------------------------------------------------
def _make_subprocess_result(returncode: int, stdout: str) -> MagicMock:
r = MagicMock()
r.returncode = returncode
r.stdout = stdout
return r
def test_pids_on_port_uses_lsof_when_available():
"""_pids_on_port() returns PID list from lsof output when lsof is available."""
def mock_run(cmd, **kwargs): # noqa: ANN001
if cmd[0] == "lsof":
return _make_subprocess_result(0, "3555095\n")
return _make_subprocess_result(1, "")
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
result = _pids_on_port(7682)
assert result == [3555095], "Should return PID from lsof"
def test_pids_on_port_falls_back_to_fuser_when_lsof_missing():
"""_pids_on_port() uses fuser when lsof is not installed (FileNotFoundError).
This is the exact failure mode that caused the yazi-stuck-ttyd bug:
lsof was absent, _kill_pids_on_port silently returned False, and the
stale ttyd was never killed.
"""
def mock_run(cmd, **kwargs): # noqa: ANN001
if cmd[0] == "lsof":
raise FileNotFoundError("lsof not found")
if cmd[0] == "fuser":
return _make_subprocess_result(0, " 3555095")
return _make_subprocess_result(1, "")
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
result = _pids_on_port(7682)
assert result == [3555095], "Should fall back to fuser when lsof is missing"
def test_pids_on_port_falls_back_to_ss_when_lsof_and_fuser_missing():
"""_pids_on_port() uses ss when both lsof and fuser are unavailable."""
ss_output = (
'LISTEN 0 128 0.0.0.0:7682 0.0.0.0:* users:(("ttyd",pid=3555095,fd=12))\n'
)
def mock_run(cmd, **kwargs): # noqa: ANN001
if cmd[0] in ("lsof", "fuser"):
raise FileNotFoundError(f"{cmd[0]} not found")
if cmd[0] == "ss":
return _make_subprocess_result(0, ss_output)
return _make_subprocess_result(1, "")
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
result = _pids_on_port(7682)
assert result == [3555095], "Should fall back to ss when lsof and fuser missing"
def test_pids_on_port_returns_empty_when_all_tools_fail():
"""_pids_on_port() returns [] when no discovery tool is available."""
def mock_run(cmd, **kwargs): # noqa: ANN001
raise FileNotFoundError(f"{cmd[0]} not found")
with patch("muxplex.ttyd._subprocess.run", side_effect=mock_run):
result = _pids_on_port(7682)
assert result == [], "Should return empty list when all tools fail"
def test_kill_pids_on_port_sends_signal_via_fuser_fallback():
"""_kill_pids_on_port() sends the correct signal using the fuser fallback.
End-to-end regression guard: verifies that the full chain from
_kill_pids_on_port _pids_on_port fuser os.kill works correctly
so that stale ttyd processes are killed even when lsof is absent.
"""
killed: list[tuple[int, int]] = []
def mock_run(cmd, **kwargs): # noqa: ANN001
if cmd[0] == "lsof":
raise FileNotFoundError("lsof not found")
if cmd[0] == "fuser":
return _make_subprocess_result(0, " 3555095")
return _make_subprocess_result(1, "")
def mock_kill(pid: int, sig: int) -> None:
killed.append((pid, sig))
with (
patch("muxplex.ttyd._subprocess.run", side_effect=mock_run),
patch("os.kill", side_effect=mock_kill),
):
result = _kill_pids_on_port(7682, signal.SIGKILL)
assert result is True, "_kill_pids_on_port must return True when fuser finds a PID"
assert (3555095, signal.SIGKILL) in killed, (
"Must send SIGKILL to PID 3555095 discovered via fuser fallback"
)
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
+89 -22
View File
@@ -17,6 +17,7 @@ Public API:
import asyncio
import os
import re as _re
import signal
import subprocess as _subprocess
import time
@@ -43,12 +44,21 @@ _active_process: asyncio.subprocess.Process | None = None
# ---------------------------------------------------------------------------
def _kill_pids_on_port(port: int, sig: int) -> bool:
"""Find and signal all processes listening on *port* via lsof.
def _pids_on_port(port: int) -> list[int]:
"""Return PIDs of all processes listening on *port*.
Returns True if at least one PID was found and signalled.
Silently ignores lsof unavailability and already-dead processes.
Tries three tools in order, stopping at the first that returns results:
1. ``lsof -ti :<port>`` one PID per line, most widely available.
2. ``fuser <port>/tcp`` space-separated PIDs on stdout (psmisc).
3. ``ss -Hnltp`` parses ``pid=N`` from users field (iproute2).
Returns an empty list if none of the tools are available or find anything.
Silently swallows all errors so callers always get a list.
"""
pids: list[int] = []
# --- Tool 1: lsof ---
try:
result = _subprocess.run(
["lsof", "-ti", f":{port}"],
@@ -56,23 +66,79 @@ def _kill_pids_on_port(port: int, sig: int) -> bool:
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
if result.returncode == 0 and result.stdout.strip():
for tok in result.stdout.split():
try:
pids.append(int(tok))
except ValueError:
pass
except Exception: # noqa: BLE001
# lsof not available, timed out, or other unexpected failure
pass
if pids:
return pids
# --- Tool 2: fuser (psmisc) ---
try:
result = _subprocess.run(
["fuser", f"{port}/tcp"],
capture_output=True,
text=True,
timeout=5,
)
# fuser writes the port label to stderr and PIDs to stdout.
if result.stdout.strip():
for tok in result.stdout.split():
try:
pids.append(int(tok))
except ValueError:
pass
except Exception: # noqa: BLE001
pass
if pids:
return pids
# --- Tool 3: ss (iproute2) ---
try:
result = _subprocess.run(
["ss", "-Hnltp", f"sport = :{port}"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
for match in _re.finditer(r"pid=(\d+)", result.stdout):
try:
pids.append(int(match.group(1)))
except ValueError:
pass
except Exception: # noqa: BLE001
pass
return pids
def _kill_pids_on_port(port: int, sig: int) -> bool:
"""Find and signal all processes listening on *port*.
Uses :func:`_pids_on_port` (lsof fuser ss) to locate PIDs, then
signals each with *sig*.
Returns True if at least one PID was found and signalled.
Silently ignores unavailable tools and already-dead processes.
"""
pids = _pids_on_port(port)
if not pids:
return False
sent = False
for pid in pids:
try:
os.kill(pid, sig)
sent = True
except (ProcessLookupError, PermissionError):
pass
return sent
# ---------------------------------------------------------------------------
@@ -94,9 +160,10 @@ async def kill_ttyd() -> bool:
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.
TTYD_PORT via ``_pids_on_port()`` (lsof fuser ss). 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.
Generated
+1 -1
View File
@@ -332,7 +332,7 @@ wheels = [
[[package]]
name = "muxplex"
version = "0.6.0"
version = "0.6.7"
source = { editable = "." }
dependencies = [
{ name = "aiofiles" },