';
}).join('');
list.querySelectorAll('.palette-item').forEach(function(item) {
item.addEventListener('click', function() {
var name = item.dataset.session;
closePalette();
openSession(name);
});
});
}
function highlightPaletteItem(index) {
var items = $('palette-list') ? $('palette-list').querySelectorAll('.palette-item') : [];
for (var i = 0; i < items.length; i++) {
items[i].classList.toggle('palette-item--selected', i === index);
}
}
function handlePaletteKeydown(e) {
var list = $('palette-list');
var items = list ? Array.prototype.slice.call(list.querySelectorAll('.palette-item')) : [];
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
_paletteSelectedIndex = Math.min(_paletteSelectedIndex + 1, items.length - 1);
highlightPaletteItem(_paletteSelectedIndex);
break;
case 'ArrowUp':
e.preventDefault();
_paletteSelectedIndex = Math.max(_paletteSelectedIndex - 1, 0);
highlightPaletteItem(_paletteSelectedIndex);
break;
case 'Enter':
e.preventDefault();
var selected = items[_paletteSelectedIndex];
if (selected) { closePalette(); openSession(selected.dataset.session); }
break;
case 'Escape':
e.preventDefault();
closePalette();
break;
case 'g':
case 'G':
e.preventDefault();
closePalette();
closeSession();
break;
default:
// Number keys 1-9 jump directly to session
if (/^[1-9]$/.test(e.key)) {
e.preventDefault();
var item = items[parseInt(e.key, 10) - 1];
if (item) { closePalette(); openSession(item.dataset.session); }
}
}
}
// ── Global keyboard handler ────────────────────────────────────────────────────
function handleGlobalKeydown(e) {
var palette = $('command-palette');
var paletteOpen = palette && !palette.classList.contains('hidden');
if (paletteOpen) {
handlePaletteKeydown(e);
return;
}
if (_viewMode === 'fullscreen') {
// Backtick ` or Ctrl+K → open palette
if (e.key === '`' || (e.ctrlKey && e.key === 'k')) {
e.preventDefault();
openPalette();
return;
}
// Escape → back to grid (when palette is closed)
if (e.key === 'Escape') {
closeSession();
return;
}
}
}
// ── Static event listener binding ─────────────────────────────────────────────
// Called once from DOMContentLoaded after all functions are defined.
function bindStaticEventListeners() {
// Back button (← in expanded header)
on($('back-btn'), 'click', function() { closeSession(); });
// ⌘K button in expanded header
on($('palette-trigger'), 'click', function() { openPalette(); });
// Palette backdrop — click outside to close
on($('palette-backdrop'), 'click', function() { closePalette(); });
// Global keyboard shortcuts
document.addEventListener('keydown', handleGlobalKeydown);
// Session pill → open bottom sheet (Task 15 adds body of openBottomSheet)
on($('session-pill'), 'click', function() { openBottomSheet(); });
// Bottom sheet backdrop
on($('sheet-backdrop'), 'click', function() { closeBottomSheet(); });
}
```
---
### Step 2: Run Node.js tests — verify still passing
```
node --test frontend/tests/test_app.mjs
```
Expected: `# pass 25 # fail 0`
---
### Step 3: Manual browser verification
Open `http://localhost:8000`. Open a session by clicking a tile. Then:
1. Press Ctrl+K or `` ` `` — command palette dialog opens
2. Type a session name fragment — list filters in real time
3. Arrow Down/Up — highlighted item moves
4. Press `2` — second session opens directly
5. Press Escape — palette closes, stays in expanded view
6. Press Ctrl+K again to reopen, press `G` — returns to grid view
7. In expanded view, press Escape (palette closed) — returns to grid view
---
### Step 4: Commit
```
git add frontend/app.js
git commit -m "feat: add command palette — Ctrl+K, keyboard navigation, filter"
```
---
## Task 12: `terminal.js` — xterm.js Terminal + FitAddon initialization
**Files:**
- Modify: `frontend/terminal.js` (replace stub entirely with xterm.js init)
No unit tests — requires xterm.js CDN (browser-only). Manual verification below.
---
### Step 1: Replace `frontend/terminal.js` with xterm.js init code
**Replace the entire file** with:
```javascript
/* terminal.js — xterm.js terminal management for tmux-web
*
* Loaded after app.js (see index.html script order). Exposes openTerminal and
* closeTerminal to app.js via window._ to avoid circular dependencies.
*
* Dependencies: xterm@5.3.0 + xterm-addon-fit@0.8.0, both loaded from CDN
* in index.html before this script runs. They are available as window.Terminal
* and window.FitAddon.FitAddon.
*/
var SCROLLBACK_MOBILE = 500;
var SCROLLBACK_DESKTOP = 5000;
var MOBILE_THRESHOLD = 600;
var _term = null;
var _fitAddon = null;
var _ws = null;
var _reconnectTimer = null;
var _currentSession = null;
var _vpHandler = null;
function isMobileTerm() { return window.innerWidth < MOBILE_THRESHOLD; }
// ── createTerminal ────────────────────────────────────────────────────────────
// Disposes any existing terminal, creates a fresh xterm.js Terminal instance.
function createTerminal() {
if (_term) {
try { _term.dispose(); } catch (e) {}
_term = null;
_fitAddon = null;
}
var Terminal = window.Terminal;
var FitAddonClass = window.FitAddon && window.FitAddon.FitAddon;
if (!Terminal) {
console.error('terminal.js: xterm.js not loaded from CDN');
return;
}
_term = new Terminal({
cursorBlink: true,
fontSize: isMobileTerm() ? 12 : 14,
fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
theme: {
background: '#000000',
foreground: '#c9d1d9',
cursor: '#58a6ff',
selectionBackground: 'rgba(88, 166, 255, 0.3)',
},
scrollback: isMobileTerm() ? SCROLLBACK_MOBILE : SCROLLBACK_DESKTOP,
allowProposedApi: true,
});
if (FitAddonClass) {
_fitAddon = new FitAddonClass();
_term.loadAddon(_fitAddon);
}
}
// ── openTerminal ──────────────────────────────────────────────────────────────
// Called by app.js openSession() after the expanded view is visible.
function openTerminal(sessionName) {
_currentSession = sessionName;
var container = document.getElementById('terminal-container');
if (!container) {
console.error('terminal.js: #terminal-container not found');
return;
}
createTerminal();
if (!_term) return;
_term.open(container);
if (_fitAddon) {
try { _fitAddon.fit(); } catch (e) {}
}
connectWebSocket(sessionName);
initVisualViewport();
}
// ── closeTerminal ─────────────────────────────────────────────────────────────
// Called by app.js closeSession().
function closeTerminal() {
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
if (_ws) { try { _ws.close(); } catch (e) {} _ws = null; }
if (_term) { try { _term.dispose(); } catch (e) {} _term = null; }
if (_vpHandler && window.visualViewport) {
window.visualViewport.removeEventListener('resize', _vpHandler);
_vpHandler = null;
}
_currentSession = null;
// Clear the container so stale canvas doesn't flash on next open
var container = document.getElementById('terminal-container');
if (container) container.innerHTML = '';
}
// ── connectWebSocket and initVisualViewport are defined in Tasks 13 and 14 ──
// Forward stubs so closeTerminal above compiles without reference errors.
function connectWebSocket(sessionName) {}
function initVisualViewport() {}
// ── Expose to app.js via window ───────────────────────────────────────────────
// app.js checks window._openTerminal and window._closeTerminal before calling.
window._openTerminal = openTerminal;
window._closeTerminal = closeTerminal;
```
---
### Step 2: Run Node.js tests — verify still passing
```
node --test frontend/tests/test_app.mjs
```
Expected: `# pass 25 # fail 0` (terminal.js is not loaded by Node.js tests)
---
### Step 3: Manual browser verification
Open `http://localhost:8000`. Click a session tile. In the expanded view:
1. DevTools Console — no `xterm.js not loaded` error
2. `#terminal-container` has a `canvas` element inside it (xterm.js rendered)
3. Terminal cursor is visible and blinking
4. The terminal is sized to fill the container (FitAddon working)
5. Note: typing produces no output yet — WebSocket is stubbed until Task 13
---
### Step 4: Commit
```
git add frontend/terminal.js
git commit -m "feat: add terminal.js — xterm.js Terminal and FitAddon initialization"
```
---
## Task 13: WebSocket connection to ttyd via `/terminal/`
**Files:**
- Modify: `frontend/terminal.js` (replace `connectWebSocket` stub with real implementation)
No unit tests — requires WebSocket + browser. Manual verification below.
---
### Step 1: Replace the `connectWebSocket` stub in `frontend/terminal.js`
Find this line in `terminal.js`:
```javascript
function connectWebSocket(sessionName) {}
```
Replace it with:
```javascript
// ── WebSocket to ttyd ─────────────────────────────────────────────────────────
// ttyd runs on port 7682; Caddy proxies /terminal/ → ws://localhost:7682
function connectWebSocket(sessionName) {
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/terminal/';
var reconnectOverlay = document.getElementById('reconnect-overlay');
function connect() {
if (!_currentSession) return; // session was closed before connect resolved
_ws = new WebSocket(url);
_ws.binaryType = 'arraybuffer';
_ws.addEventListener('open', function() {
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
});
_ws.addEventListener('message', function(e) {
if (!_term) return;
if (e.data instanceof ArrayBuffer) {
_term.write(new Uint8Array(e.data));
} else {
_term.write(e.data);
}
});
_ws.addEventListener('close', function() {
if (!_currentSession) return; // intentional close via closeTerminal()
if (reconnectOverlay) reconnectOverlay.classList.remove('hidden');
_reconnectTimer = setTimeout(connect, 2000);
});
_ws.addEventListener('error', function(e) {
console.warn('terminal.js: WebSocket error', e);
});
// Forward terminal keystrokes → ttyd
if (_term) {
_term.onData(function(data) {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(data);
}
});
}
// Notify ttyd of terminal size changes
if (_term) {
_term.onResize(function(size) {
if (_ws && _ws.readyState === WebSocket.OPEN) {
// ttyd accepts resize as a JSON message
_ws.send(JSON.stringify({ columns: size.cols, rows: size.rows }));
}
});
}
}
connect();
}
```
---
### Step 2: Run Node.js tests — verify still passing
```
node --test frontend/tests/test_app.mjs
```
Expected: `# pass 25 # fail 0`
---
### Step 3: Manual browser verification
Open `http://localhost:8000`. Click a session tile. In the expanded view:
1. Terminal renders and shows the tmux session output
2. Type commands — they execute in the real tmux session
3. Close browser tab (or manually close the WebSocket via DevTools Network → WS → X)
- "Reconnecting…" overlay appears on `#reconnect-overlay`
- After 2 seconds, it automatically reconnects and overlay disappears
4. Navigate back to grid (← button), then click the same session again
- Terminal re-opens cleanly with no stale canvas
---
### Step 4: Commit
```
git add frontend/terminal.js
git commit -m "feat: add WebSocket connection to ttyd via /terminal/"
```
---
## Task 14: `visualViewport` mobile keyboard resize
**Files:**
- Modify: `frontend/terminal.js` (replace `initVisualViewport` stub with real implementation)
No unit tests — requires browser + mobile emulation. Manual verification below.
---
### Step 1: Replace the `initVisualViewport` stub in `frontend/terminal.js`
Find this line in `terminal.js`:
```javascript
function initVisualViewport() {}
```
Replace it with:
```javascript
// ── visualViewport keyboard resize ────────────────────────────────────────────
// On mobile, when the software keyboard appears, window.innerHeight shrinks but
// the layout doesn't reflow automatically. We use window.visualViewport to detect
// the actual visible height and resize #terminal-container to fit above the keyboard.
function initVisualViewport() {
if (!window.visualViewport) return;
// Remove any previous listener to avoid stacking on reconnect
if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);
_vpHandler = function() {
if (!_term || !_fitAddon) return;
var container = document.getElementById('terminal-container');
if (!container) return;
var vvh = window.visualViewport.height;
var headerHeight = 44; // matches CSS --header-height var
var termHeight = Math.max(100, vvh - headerHeight);
container.style.height = termHeight + 'px';
try { _fitAddon.fit(); } catch (e) {}
};
window.visualViewport.addEventListener('resize', _vpHandler);
// Fire immediately to handle the case where the keyboard was already open
_vpHandler();
}
```
---
### Step 2: Run Node.js tests — verify still passing
```
node --test frontend/tests/test_app.mjs
```
Expected: `# pass 25 # fail 0`
---
### Step 3: Manual browser verification
1. Open `http://localhost:8000` in Chrome DevTools → Toggle device toolbar (set to iPhone-size, < 600px wide)
2. Click a session tile to open expanded view
3. Tap in the terminal area — software keyboard appears
4. Verify terminal is still visible above the keyboard (container height adjusted)
5. Type a command — it executes
Without this fix, the terminal would be partially hidden behind the keyboard on mobile.
---
### Step 4: Commit
```
git add frontend/terminal.js
git commit -m "feat: add visualViewport keyboard resize for mobile terminal"
```
---
## Task 15: Mobile bottom sheet + session pill
**Files:**
- Modify: `frontend/app.js` (add `openBottomSheet`, `closeBottomSheet`, `renderSheetList`)
No unit tests — requires DOM. Manual verification below.
---
### Step 1: Add bottom sheet functions to `frontend/app.js`
Add this block **below `bindStaticEventListeners`** (above the conditional export block):
```javascript
// ── Mobile bottom sheet ────────────────────────────────────────────────────────
// Displayed when the user taps the session pill while in expanded view.
// Shows all sessions ordered by priority. Tapping switches to that session.
function openBottomSheet() {
var sheet = $('bottom-sheet');
if (!sheet) return;
renderSheetList();
sheet.classList.remove('hidden');
}
function closeBottomSheet() {
var sheet = $('bottom-sheet');
if (sheet) sheet.classList.add('hidden');
}
function renderSheetList() {
var list = $('sheet-list');
if (!list) return;
var sorted = sortByPriority(_currentSessions);
list.innerHTML = sorted.map(function(s) {
var hasBell = s.bell && s.bell.unseen_count > 0;
var isActive = s.name === _viewingSession;
return '
';
}).join('');
list.querySelectorAll('.sheet-item').forEach(function(item) {
item.addEventListener('click', function() {
var name = item.dataset.session;
closeBottomSheet();
if (name !== _viewingSession) {
// Close current session first, then open new one
closeSession().then(function() { openSession(name); });
}
});
});
}
```
---
### Step 2: Run all Node.js tests — final check
```
node --test frontend/tests/test_app.mjs
```
Expected:
```
# tests 25
# pass 25
# fail 0
```
---
### Step 3: Run all Python tests — verify nothing broke
```
cd /home/bkrabach/dev/web-tmux
python -m pytest coordinator/tests/ --ignore=coordinator/tests/test_integration.py -q
```
Expected: **118 passed, 0 failed**
---
### Step 4: Manual browser verification (mobile)
1. Open `http://localhost:8000` in DevTools mobile mode (< 600px wide)
2. Click a session tile — expanded view opens, floating pill appears at bottom with session name
3. Tap the pill — bottom sheet slides up showing all sessions, sorted (bell first)
4. Active session has `.sheet-item--active` class (visually distinguished)
5. Tap a different session — sheet closes, new session opens
On desktop (> 600px wide):
- Pill is hidden (`.hidden` class stays on `#session-pill`)
- Bottom sheet does not appear
---
### Step 5: Commit
```
git add frontend/app.js
git commit -m "feat: add mobile bottom sheet and session pill"
```
---
## Final Verification Checklist
After all 15 tasks are complete, run this full verification sequence:
### Check 1: Node.js unit tests
```
node --test frontend/tests/test_app.mjs
```
Expected: `# tests 25 # pass 25 # fail 0`
### Check 2: Python test suite
```
cd /home/bkrabach/dev/web-tmux
python -m pytest coordinator/tests/ --ignore=coordinator/tests/test_integration.py -q
```
Expected: `118 passed, 0 failed`
### Check 3: Ruff linter
```
ruff check coordinator/ && ruff format --check coordinator/
```
Expected: `All checks passed.`
### Check 4: File existence
```
ls -la frontend/app.js frontend/terminal.js frontend/tests/test_app.mjs
```
All three files must exist and be non-empty.
### Check 5: Behavior smoke test
```
uvicorn coordinator.main:app --port 8000
```
Then open `http://localhost:8000` and verify:
- Session tiles render
- `●` connection status shown
- Clicking a tile opens expanded view with working terminal
- Ctrl+K opens command palette
- Back button returns to grid
---
## Scope Boundaries — Phase 2b Does NOT Include
| Deferred to | What |
|---|---|
| Phase 3 | Caddy configuration (`/terminal/` WebSocket proxy) |
| Phase 3 | systemd service unit |
| Phase 3 | Service worker / offline PWA support |
| Phase 3 | Browser-tester E2E (Playwright) |
| Phase 3 | Push notifications beyond Browser Notifications API |
| v2 | Session creation / renaming UI |
| v2 | Drag-to-reorder tiles |
> **Note on Caddy:** Until Caddy is configured (Phase 3), the WebSocket to `/terminal/` will fail in production. In development, run ttyd manually on port 7682 and add a `--proxy-headers` Caddy route, OR test via the uvicorn dev server with a manually-started ttyd process attached to a tmux session by name.