diff --git a/.gitignore b/.gitignore index 74465e5..84010ba 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ Thumbs.db # PWA client/dev-dist/ + +# Test results +e2e/test-results/ diff --git a/CLAUDE.md b/CLAUDE.md index 22c8a75..22355a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +123,59 @@ Environment variables or `server/resources/config.edn`: | `CLAUDE_SESSIONS_DIR` | ~/.claude/projects | Claude sessions | | `OPENCODE_CMD` | opencode | OpenCode binary | +## Testing Changes + +Always test changes before considering them complete. Choose the appropriate testing method: + +### 1. E2E Tests (Playwright) +For UI changes, user flows, and integration between frontend/backend: +```bash +cd e2e && npm test # Run all E2E tests +cd e2e && npm run test:headed # Visible browser for debugging +cd e2e && npm test -- -g "test name" # Run specific test +``` + +### 2. Unit Tests (Clojure) +For backend logic, pure functions, and isolated components: +```bash +cd server && clj -M:test # Run all unit tests +``` + +### 3. REPL Testing +For quick one-off validation of Clojure code during development: +```clojure +;; In REPL (clj -M:dev, then (go)) +(require '[spiceflow.some-ns :as ns]) +(ns/some-function arg1 arg2) ; Test function directly +``` + +### 4. Manual API Testing +For testing HTTP endpoints directly: +```bash +# Health check +curl http://localhost:3000/api/health + +# List sessions +curl http://localhost:3000/api/sessions + +# Create session +curl -X POST http://localhost:3000/api/sessions \ + -H "Content-Type: application/json" \ + -d '{"provider": "claude"}' + +# Get session +curl http://localhost:3000/api/sessions/:id +``` + +### When to Use Each +| Change Type | Recommended Testing | +|-------------|---------------------| +| UI component | E2E tests | +| API endpoint | Manual API + unit tests | +| Business logic | Unit tests + REPL | +| User flow | E2E tests | +| Bug fix | Add regression test + manual verify | + ## Subdirectory CLAUDE.md Files Each directory has specific details: diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index ada8717..0dabcf4 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -204,13 +204,19 @@ class ApiClient { }); } - async resizeTerminal(sessionId: string, mode: 'desktop' | 'landscape' | 'portrait'): Promise<{ status: string; mode: string }> { + async resizeTerminal(sessionId: string, mode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait'): Promise<{ status: string; mode: string }> { return this.request<{ status: string; mode: string }>(`/sessions/${sessionId}/terminal/resize`, { method: 'POST', body: JSON.stringify({ mode }) }); } + async ejectSession(sessionId: string): Promise<{ status: string; oldName: string; newName: string; message: string }> { + return this.request<{ status: string; oldName: string; newName: string; message: string }>(`/sessions/${sessionId}/eject`, { + method: 'POST' + }); + } + // External tmux sessions async getExternalTmuxSessions(): Promise { return this.request('/tmux/external'); diff --git a/client/src/lib/components/MessageList.svelte b/client/src/lib/components/MessageList.svelte index c44ddde..0fbf1a2 100644 --- a/client/src/lib/components/MessageList.svelte +++ b/client/src/lib/components/MessageList.svelte @@ -23,6 +23,7 @@ let container: HTMLDivElement; let collapsedMessages: Set = new Set(); let hasScrolledInitially = false; + let lastMessageCount = 0; const COLLAPSE_THRESHOLD = 5; // show toggle if more than this many lines @@ -32,6 +33,14 @@ } } + // Reset scroll flag when messages are cleared (navigating to new session) + $: if (messages.length === 0 && lastMessageCount > 0) { + hasScrolledInitially = false; + } + + // Track message count for detecting session changes + $: lastMessageCount = messages.length; + // Scroll to bottom when messages first load $: if (messages.length > 0 && container && !hasScrolledInitially) { hasScrolledInitially = true; diff --git a/client/src/lib/components/SessionSettings.svelte b/client/src/lib/components/SessionSettings.svelte index b1ed125..78ec67e 100644 --- a/client/src/lib/components/SessionSettings.svelte +++ b/client/src/lib/components/SessionSettings.svelte @@ -9,6 +9,8 @@ toggleAutoAccept: boolean; toggleAutoScroll: boolean; condenseAll: void; + refresh: void; + eject: void; }>(); function handleToggleAutoScroll() { @@ -27,6 +29,16 @@ open = false; } + function handleRefresh() { + dispatch('refresh'); + open = false; + } + + function handleEject() { + dispatch('eject'); + open = false; + } + function handleClickOutside(event: MouseEvent) { const target = event.target as HTMLElement; if (!target.closest('.settings-dropdown')) { @@ -86,6 +98,17 @@ + + + {#if provider !== 'tmux'} + {/if} {/if} diff --git a/client/src/lib/components/TerminalView.svelte b/client/src/lib/components/TerminalView.svelte index c4c9bda..73b77df 100644 --- a/client/src/lib/components/TerminalView.svelte +++ b/client/src/lib/components/TerminalView.svelte @@ -35,8 +35,17 @@ let inputBuffer = ''; let batchTimeout: ReturnType | null = null; let isSending = false; - let fontScale = 1; // 0.75, 0.875, 1, 1.125, 1.25, 1.5 - const fontScales = [0.75, 0.875, 1, 1.125, 1.25, 1.5]; + let fontScale = 1; // 50% to 150% in 5% increments + const fontScales = [0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1, 1.05, 1.1, 1.15, 1.2, 1.25, 1.3, 1.35, 1.4, 1.45, 1.5]; + + // Consistent button styling for quick action bar + const btnBase = 'px-2.5 py-1.5 rounded-sm text-sm font-mono transition-colors disabled:opacity-50'; + const btnDefault = `${btnBase} bg-zinc-700 hover:bg-zinc-600 text-zinc-200`; + const btnRed = `${btnBase} bg-red-600/80 hover:bg-red-600 text-white`; + const btnGreen = `${btnBase} bg-green-600/80 hover:bg-green-600 text-white`; + const btnAmber = `${btnBase} bg-amber-600/80 hover:bg-amber-600 text-white`; + const btnCyan = `${btnBase} bg-cyan-600/80 hover:bg-cyan-600 text-white`; + const btnViolet = `${btnBase} bg-violet-600/80 hover:bg-violet-600 text-white`; function zoomIn() { const idx = fontScales.indexOf(fontScale); @@ -191,17 +200,45 @@ } } - function scrollToBottom() { - if (autoScroll && terminalElement) { + function scrollToBottom(force = false) { + if ((force || autoScroll) && terminalElement) { terminalElement.scrollTop = terminalElement.scrollHeight; } } + async function pasteFromClipboard() { + try { + const text = await navigator.clipboard.readText(); + if (text) { + await sendInput(text); + } + } catch (e) { + console.error('Failed to read clipboard:', e); + } + } + + function copySelection() { + const selection = window.getSelection(); + const text = selection?.toString(); + if (text) { + navigator.clipboard.writeText(text).catch(e => { + console.error('Failed to copy:', e); + }); + } + } + async function handleKeydown(event: KeyboardEvent) { // Ctrl+Down scrolls to bottom (don't send to tmux) if (event.ctrlKey && event.key === 'ArrowDown') { event.preventDefault(); - scrollToBottom(); + scrollToBottom(true); + return; + } + + // Ctrl+Shift+V pastes from clipboard + if (event.ctrlKey && event.shiftKey && event.key === 'V') { + event.preventDefault(); + await pasteFromClipboard(); return; } @@ -253,6 +290,9 @@ } async function sendInput(input: string) { + // Scroll to bottom when sending input (force regardless of autoScroll) + scrollToBottom(true); + // Buffer the input for batching inputBuffer += input; @@ -296,7 +336,7 @@ await sendInput(key); } - async function resizeScreen(mode: 'desktop' | 'landscape' | 'portrait') { + async function resizeScreen(mode: 'fullscreen' | 'desktop' | 'landscape' | 'portrait') { if (resizing) return; resizing = true; try { @@ -318,7 +358,7 @@ if (event.diff) { const changed = applyDiff(event.diff); if (changed) { - tick().then(scrollToBottom); + tick().then(() => scrollToBottom()); } } else if (event.content !== undefined) { // Fallback: full content replacement (no frame ID available) @@ -326,7 +366,7 @@ const newLines = newContent ? newContent.split('\n') : []; if (newLines.join('\n') !== terminalLines.join('\n')) { terminalLines = newLines; - tick().then(scrollToBottom); + tick().then(() => scrollToBottom()); } } } @@ -380,104 +420,117 @@
 terminalInput?.focus()}
 			class="flex-1 min-h-0 overflow-auto p-3 font-mono text-green-400 whitespace-pre-wrap break-words leading-relaxed terminal-content cursor-text"
 			style="font-size: {fontScale * 0.875}rem;"
 		>{@html terminalHtml}
-
+
- + - + {#each ['1', '2', '3', '4'] as num} {/each} - + + + + - + - {/if}
diff --git a/e2e/tests/tmux-terminal.spec.ts b/e2e/tests/tmux-terminal.spec.ts index b863a6d..1211b6e 100644 --- a/e2e/tests/tmux-terminal.spec.ts +++ b/e2e/tests/tmux-terminal.spec.ts @@ -1,31 +1,20 @@ import { test, expect } from '@playwright/test'; import { E2E_BACKEND_URL } from '../playwright.config'; -// Helper to clean up any existing tmux sessions via UI -async function cleanupTmuxSessions(page: import('@playwright/test').Page) { - await page.goto('/'); - - // Delete all tmux session cards - while (true) { - const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') }); - const count = await tmuxCards.count(); - if (count === 0) break; - - // Set up one-time dialog handler for this deletion - page.once('dialog', async (dialog) => { - await dialog.accept(); - }); - - const deleteButton = tmuxCards.first().locator('button[title="Delete session"]'); - await deleteButton.click(); - await page.waitForTimeout(500); // Wait for deletion to process +// Helper to delete a specific session by ID via API +async function deleteSession(request: import('@playwright/test').APIRequestContext, sessionId: string) { + try { + await request.delete(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(sessionId)}`); + console.log(`[Cleanup] Deleted session: ${sessionId}`); + } catch (e) { + console.log(`[Cleanup] Failed to delete session ${sessionId}:`, e); } } test.describe('Tmux Terminal Session', () => { - test('create tmux session and run shell commands', async ({ page }) => { - // Clean up any stale tmux sessions first - await cleanupTmuxSessions(page); + test('create tmux session and run shell commands', async ({ page, request }) => { + // Track session ID for cleanup + let createdSessionId: string | null = null; // Enable console logging for debugging page.on('console', (msg) => { @@ -61,7 +50,7 @@ test.describe('Tmux Terminal Session', () => { // 4. Wait for navigation to session page await page.waitForURL(/\/session\/.+/); const sessionUrl = page.url(); - const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]); + createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]); console.log('[Test] Navigated to session page:', sessionUrl); // 5. Wait for terminal view to load (should see the terminal pre element) @@ -78,14 +67,14 @@ test.describe('Tmux Terminal Session', () => { const terminalBadge = page.locator('text=TERMINAL'); await expect(terminalBadge).toBeVisible(); - // 8. Find the command input - const commandInput = page.locator('input[placeholder*="Type command"]'); - await expect(commandInput).toBeVisible(); + // 8. Find the command input (hidden input for keyboard capture) + const commandInput = page.locator('input[aria-label="Terminal input"]'); await expect(commandInput).toBeEnabled(); - // 9. Run `pwd` command - await commandInput.fill('pwd'); - await commandInput.press('Enter'); + // 9. Run `pwd` command - use type() to trigger keydown events + await commandInput.focus(); + await page.keyboard.type('pwd'); + await page.keyboard.press('Enter'); console.log('[Test] Sent pwd command'); // 10. Wait for output and verify it contains a path (starts with /) @@ -101,8 +90,9 @@ test.describe('Tmux Terminal Session', () => { console.log('[Test] pwd output verified - contains path'); // 11. Run `ls -al` command - await commandInput.fill('ls -al'); - await commandInput.press('Enter'); + await commandInput.focus(); + await page.keyboard.type('ls -al'); + await page.keyboard.press('Enter'); console.log('[Test] Sent ls -al command'); // 12. Wait for output and verify it contains typical ls -al output @@ -131,30 +121,16 @@ test.describe('Tmux Terminal Session', () => { console.log('[Test] Tmux terminal test completed successfully'); - // 15. Cleanup: Delete the session via UI - await page.goto('/'); - await expect(page).toHaveTitle(/Spiceflow/i); - - // Find the tmux session card and delete it - const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') }); - await expect(tmuxCards).toHaveCount(1); - - // Set up one-time dialog handler - page.once('dialog', async (dialog) => { - await dialog.accept(); - }); - - const deleteButton = tmuxCards.first().locator('button[title="Delete session"]'); - await deleteButton.click(); - - // Wait for no tmux cards to remain - await expect(tmuxCards).toHaveCount(0, { timeout: 5000 }); - console.log('[Test] Cleanup: Session deleted'); + // 15. Cleanup: Delete only the session we created + if (createdSessionId) { + await deleteSession(request, createdSessionId); + } + console.log('[Test] Cleanup: Test session deleted'); }); test('deleting tmux session kills the tmux process', async ({ page, request }) => { - // Clean up any stale tmux sessions first - await cleanupTmuxSessions(page); + // Track session ID for cleanup + let createdSessionId: string | null = null; // 1. Navigate to homepage and create a tmux session await page.goto('/'); @@ -170,55 +146,147 @@ test.describe('Tmux Terminal Session', () => { await page.waitForURL(/\/session\/.+/); const sessionUrl = page.url(); // URL decode the session ID since it contains special characters - const sessionId = decodeURIComponent(sessionUrl.split('/session/')[1]); - console.log('[Test] Created session:', sessionId); + createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]); + console.log('[Test] Created session:', createdSessionId); // 2. Wait for terminal to load and session to be active const terminalOutput = page.locator('pre.text-green-400'); await expect(terminalOutput).toBeVisible({ timeout: 10000 }); // 3. For ephemeral tmux sessions, the session ID IS the tmux session name - const tmuxSessionName = sessionId; + const tmuxSessionName = createdSessionId; console.log('[Test] Tmux session name:', tmuxSessionName); // 4. Verify the tmux session exists by running a command - const commandInput = page.locator('input[placeholder*="Type command"]'); - await commandInput.fill('echo "session-alive"'); - await commandInput.press('Enter'); + const commandInput = page.locator('input[aria-label="Terminal input"]'); + await expect(commandInput).toBeEnabled({ timeout: 5000 }); + await commandInput.focus(); + await page.keyboard.type('echo "session-alive"'); + await page.keyboard.press('Enter'); await page.waitForTimeout(500); - // 5. Go back to home page - await page.goto('/'); - await expect(page).toHaveTitle(/Spiceflow/i); + // 5. Delete the session we created via API + await deleteSession(request, createdSessionId!); + console.log('[Test] Test session deleted from API'); - // 6. Find and delete the session by provider badge (more generic than session ID) - const tmuxCards = page.locator('a.card').filter({ has: page.locator('span:text-is("tmux")') }); - await expect(tmuxCards).toHaveCount(1); - - // Set up one-time dialog handler to accept the confirmation - page.once('dialog', async (dialog) => { - console.log('[Test] Dialog appeared:', dialog.message()); - await dialog.accept(); - }); - - // Click the delete button (X icon) on the session card - const deleteButton = tmuxCards.first().locator('button[title="Delete session"]'); - await deleteButton.click(); - - // 7. Wait for session to be deleted (no more tmux cards) - await expect(tmuxCards).toHaveCount(0, { timeout: 5000 }); - console.log('[Test] Session deleted from UI'); - - // 8. Verify the tmux session was killed by checking the API + // 6. Verify the tmux session was killed by checking the API // The session should no longer exist - const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}`); + const sessionResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(createdSessionId!)}`); expect(sessionResponse.status()).toBe(404); console.log('[Test] Session no longer exists in API'); - // 9. Verify the tmux session is no longer alive + // 7. Verify the tmux session is no longer alive // We can check this by trying to get terminal content - it should fail - const terminalCheckResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${sessionId}/terminal`); + const terminalCheckResponse = await request.get(`${E2E_BACKEND_URL}/api/sessions/${encodeURIComponent(createdSessionId!)}/terminal`); expect(terminalCheckResponse.status()).toBe(404); console.log('[Test] Tmux session properly cleaned up'); }); + + test('eject tmux session removes from spiceflow but keeps tmux running', async ({ page, request }) => { + // Track session ID for cleanup + let createdSessionId: string | null = null; + let ejectedSessionName: string | null = null; + + // 1. Navigate to homepage and create a tmux session + await page.goto('/'); + await expect(page).toHaveTitle(/Spiceflow/i); + + const createButton = page.locator('button[title="New Session"]'); + await createButton.click(); + + const tmuxOption = page.locator('button:has-text("Terminal (tmux)")'); + await tmuxOption.click(); + + // Wait for navigation to session page + await page.waitForURL(/\/session\/.+/); + const sessionUrl = page.url(); + createdSessionId = decodeURIComponent(sessionUrl.split('/session/')[1]); + console.log('[Test] Created session:', createdSessionId); + + // 2. Wait for terminal to load and session to be active + const terminalOutput = page.locator('pre.text-green-400'); + await expect(terminalOutput).toBeVisible({ timeout: 10000 }); + + // 3. Run a command to verify terminal is working + const commandInput = page.locator('input[aria-label="Terminal input"]'); + await expect(commandInput).toBeEnabled({ timeout: 5000 }); + await commandInput.focus(); + await page.keyboard.type('echo "eject-test-marker"'); + await page.keyboard.press('Enter'); + await page.waitForTimeout(500); + + // Verify command output appeared + await expect(async () => { + const content = await terminalOutput.textContent(); + expect(content).toContain('eject-test-marker'); + }).toPass({ timeout: 5000 }); + console.log('[Test] Terminal is working'); + + // 4. Handle the alert that will appear after eject + page.on('dialog', async (dialog) => { + console.log('[Test] Alert message:', dialog.message()); + // Extract the new session name from the message + // Message format: "Session ejected. You can attach to it with: tmux attach -t " + const match = dialog.message().match(/tmux attach -t (\S+)/); + if (match) { + ejectedSessionName = match[1]; + } + await dialog.accept(); + }); + + // 5. Open settings menu and click eject + const settingsButton = page.locator('button[title="Session settings"]'); + await settingsButton.click(); + + const ejectButton = page.locator('text=Eject session'); + await expect(ejectButton).toBeVisible(); + await ejectButton.click(); + console.log('[Test] Clicked eject button'); + + // 6. Wait for navigation back to home page + await page.waitForURL('/'); + console.log('[Test] Navigated back to home'); + + // 7. Verify the session is no longer in the list + await page.waitForTimeout(500); // Give time for UI to update + const sessionCards = page.locator(`a[href="/session/${encodeURIComponent(createdSessionId!)}"]`); + await expect(sessionCards).toHaveCount(0); + console.log('[Test] Session removed from list'); + + // 8. Verify the ejected session still exists as an external tmux session + // Open the import menu to check external sessions + const importButton = page.locator('button[title="Import tmux session"]'); + await importButton.click(); + + // Wait for the dropdown to load + await page.waitForTimeout(1000); + + // The ejected session should now appear in the import list + // The name should be without the spiceflow- prefix + const externalSessionItem = page.locator('.min-w-\\[200px\\] button').filter({ hasText: /.+/ }); + const externalSessionTexts = await externalSessionItem.allTextContents(); + console.log('[Test] External sessions:', externalSessionTexts); + + // Verify at least one external session exists (our ejected one) + expect(externalSessionTexts.length).toBeGreaterThan(0); + console.log('[Test] Ejected session is available for import'); + + // 9. Cleanup: Import the ejected session back and delete it via UI + if (ejectedSessionName) { + // Click on the ejected session in the import dropdown to import it + const ejectedSessionButton = page.locator(`button:has-text("${ejectedSessionName}")`); + await ejectedSessionButton.click(); + console.log('[Test] Clicked to import ejected session'); + + // Wait for navigation to the imported session + await page.waitForURL(/\/session\/.+/); + const importedSessionUrl = page.url(); + const importedSessionId = decodeURIComponent(importedSessionUrl.split('/session/')[1]); + console.log('[Test] Imported session:', importedSessionId); + + // Delete the imported session via API (which will kill the tmux session) + await deleteSession(request, importedSessionId); + console.log('[Test] Cleaned up imported session'); + } + }); }); diff --git a/server/src/spiceflow/adapters/tmux.clj b/server/src/spiceflow/adapters/tmux.clj index 3a85885..d5746b4 100644 --- a/server/src/spiceflow/adapters/tmux.clj +++ b/server/src/spiceflow/adapters/tmux.clj @@ -339,9 +339,9 @@ ;; Screen size presets for different device orientations (def ^:private screen-sizes - {:fullscreen {:width 180 :height 24} - :desktop {:width 100 :height 24} - :landscape {:width 65 :height 24} + {:fullscreen {:width 260 :height 48} + :desktop {:width 120 :height 48} + :landscape {:width 80 :height 24} :portrait {:width 40 :height 24}}) (defn resize-session @@ -420,3 +420,24 @@ :name new-name :working-dir (or (run-tmux "display-message" "-t" new-name "-p" "#{pane_current_path}") (System/getProperty "user.home"))}))))) + +(defn eject-session + "Eject a spiceflow-managed tmux session by removing the spiceflow prefix. + The tmux session continues running but is no longer managed by spiceflow. + Returns the new session name on success, nil on failure." + [session-name] + (when (and session-name (str/starts-with? session-name session-prefix)) + (let [;; Remove the spiceflow- prefix to get the base name + base-name (subs session-name (count session-prefix)) + output-file (output-file-path session-name)] + ;; Stop pipe-pane first + (run-tmux "pipe-pane" "-t" session-name) + ;; Clean up output file + (when output-file + (let [f (File. ^String output-file)] + (when (.exists f) + (.delete f)))) + ;; Rename the session to remove spiceflow prefix + (when (rename-session session-name base-name) + (log/info "[Tmux] Ejected session" session-name "-> " base-name) + base-name)))) diff --git a/server/src/spiceflow/api/routes.clj b/server/src/spiceflow/api/routes.clj index c1c0299..2d07ac8 100644 --- a/server/src/spiceflow/api/routes.clj +++ b/server/src/spiceflow/api/routes.clj @@ -310,6 +310,25 @@ (error-response 400 "Failed to import session. It may not exist or is already managed by spiceflow.")) (error-response 400 "Session name is required")))) +(defn eject-tmux-handler + "Eject a spiceflow-managed tmux session. Removes it from spiceflow but keeps the tmux session running." + [store] + (fn [request] + (let [id (get-in request [:path-params :id])] + (if (tmux-session-id? id) + (if (tmux/session-alive? id) + (if-let [new-name (tmux/eject-session id)] + (do + ;; Remove the session from the database (tmux sessions aren't in DB, but call anyway for safety) + (db/delete-session store id) + (json-response {:status "ejected" + :old-name id + :new-name new-name + :message (str "Session ejected. You can attach to it with: tmux attach -t " new-name)})) + (error-response 500 "Failed to eject tmux session")) + (error-response 400 "Tmux session not alive")) + (error-response 400 "Not a tmux session"))))) + ;; Health check (defn health-handler [_request] @@ -367,6 +386,7 @@ ["/sessions/:id/terminal" {:get (terminal-capture-handler store)}] ["/sessions/:id/terminal/input" {:post (terminal-input-handler store broadcast-fn)}] ["/sessions/:id/terminal/resize" {:post (terminal-resize-handler store)}] + ["/sessions/:id/eject" {:post (eject-tmux-handler store)}] ["/tmux/external" {:get list-external-tmux-handler}] ["/tmux/import" {:post import-tmux-handler}] ["/push/vapid-key" {:get (vapid-key-handler push-store)}]