This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Mobile improvements

Nathan Herald (Mar 17, 2026, 10:30 AM +0100) 1196a00e 10bf74f3

+528 -1
+324
src/web/static/index.html
··· 174 174 text-align: center; 175 175 z-index: 10; 176 176 } 177 + 178 + /* Mobile keyboard containers */ 179 + #quick-bar, #key-panel, #text-input-bar { 180 + display: none; 181 + background: #2a2a2a; 182 + border-top: 1px solid #444; 183 + flex-shrink: 0; 184 + } 185 + 186 + #quick-bar { 187 + overflow-x: auto; 188 + white-space: nowrap; 189 + padding: 4px 6px; 190 + -webkit-overflow-scrolling: touch; 191 + scrollbar-width: none; 192 + } 193 + #quick-bar::-webkit-scrollbar { display: none; } 194 + 195 + #quick-bar button, #key-panel button { 196 + min-width: 44px; 197 + min-height: 44px; 198 + margin: 2px; 199 + background: #444; 200 + color: #ccc; 201 + border: none; 202 + border-radius: 4px; 203 + font-size: 14px; 204 + cursor: pointer; 205 + -webkit-tap-highlight-color: transparent; 206 + user-select: none; 207 + } 208 + #quick-bar button:active, #key-panel button:active { background: #666; } 209 + #quick-bar button.active { background: #668; color: #fff; } 210 + #quick-bar button.locked { background: #88a; color: #fff; } 211 + 212 + #key-panel { 213 + display: none; 214 + grid-template-columns: repeat(6, 1fr); 215 + gap: 4px; 216 + padding: 6px; 217 + max-height: 50vh; 218 + overflow-y: auto; 219 + } 220 + 221 + #text-input-bar { 222 + display: none; 223 + flex-direction: row; 224 + padding: 6px; 225 + gap: 6px; 226 + align-items: center; 227 + } 228 + #text-input { 229 + flex: 1; 230 + font-size: 16px; 231 + padding: 8px; 232 + background: #1e1e1e; 233 + color: #fff; 234 + border: 1px solid #555; 235 + border-radius: 4px; 236 + } 237 + #text-send-btn, #text-back-btn { 238 + padding: 8px 16px; 239 + background: #558; 240 + color: #fff; 241 + border: none; 242 + border-radius: 4px; 243 + font-size: 14px; 244 + min-height: 44px; 245 + } 177 246 </style> 178 247 </head> 179 248 <body> ··· 200 269 </div> 201 270 <div id="terminal-container"></div> 202 271 <div id="exit-overlay"></div> 272 + 273 + <div id="quick-bar"> 274 + <button data-key="\x1b">Esc</button> 275 + <button data-key="\t">Tab</button> 276 + <button data-mod="ctrl" class="mod-btn">Ctrl</button> 277 + <button data-mod="alt" class="mod-btn">Alt</button> 278 + <button data-key="|">|</button> 279 + <button data-key="-">-</button> 280 + <button data-key="/">/</button> 281 + <button data-key="~">~</button> 282 + <button data-key="\x1b[D">&larr;</button> 283 + <button data-key="\x1b[C">&rarr;</button> 284 + <button data-key="\x1b[A">&uarr;</button> 285 + <button data-key="\x1b[B">&darr;</button> 286 + <button data-key="\x1b[H">Home</button> 287 + <button data-key="\x1b[F">End</button> 288 + <button id="show-panel-btn" class="mode-btn">&#x2328;</button> 289 + <button id="show-text-btn" class="mode-btn">Aa</button> 290 + </div> 291 + 292 + <div id="key-panel"> 293 + <button data-key="\x1b[Z">&#x21e5;Tab</button> 294 + <button data-key="\x1b[2~">Ins</button> 295 + <button data-key="\x1b[3~">Del</button> 296 + <button data-key="\x1b[5~">PgUp</button> 297 + <button data-key="\x1b[6~">PgDn</button> 298 + <button data-action="paste">Paste</button> 299 + <button data-key="\x03">^C</button> 300 + <button data-key="\x04">^D</button> 301 + <button data-key="\x1a">^Z</button> 302 + <button data-key="\x0c">^L</button> 303 + <button data-key="\x01">^A</button> 304 + <button data-key="\x05">^E</button> 305 + <button data-key="\x13">^S</button> 306 + <button data-key="\x11">^Q</button> 307 + <button data-key="\x12">^R</button> 308 + <button data-key="\x17">^W</button> 309 + <button data-key="\x15">^U</button> 310 + <button data-key="\x0b">^K</button> 311 + <button data-key="\x1bOP">F1</button> 312 + <button data-key="\x1bOQ">F2</button> 313 + <button data-key="\x1bOR">F3</button> 314 + <button data-key="\x1bOS">F4</button> 315 + <button data-key="\x1b[15~">F5</button> 316 + <button data-key="\x1b[17~">F6</button> 317 + <button data-key="\x1b[18~">F7</button> 318 + <button data-key="\x1b[19~">F8</button> 319 + <button data-key="\x1b[20~">F9</button> 320 + <button data-key="\x1b[21~">F10</button> 321 + <button data-key="\x1b[23~">F11</button> 322 + <button data-key="\x1b[24~">F12</button> 323 + <button data-key="\x1b\x1b[D">Alt+&larr;</button> 324 + <button data-key="\x1b\x1b[C">Alt+&rarr;</button> 325 + <button data-key="\x1b\x1b[A">Alt+&uarr;</button> 326 + <button data-key="\x1b\x1b[B">Alt+&darr;</button> 327 + <button data-key="{">{</button> 328 + <button data-key="}">}</button> 329 + <button data-key="[">[</button> 330 + <button data-key="]">]</button> 331 + <button data-key="<">&lt;</button> 332 + <button data-key=">">&gt;</button> 333 + <button data-key="`">`</button> 334 + <button data-key="\">\</button> 335 + <button id="panel-back-btn" class="mode-btn">&#x25be; bar</button> 336 + </div> 337 + 338 + <div id="text-input-bar"> 339 + <input id="text-input" type="text" autocomplete="off" autocorrect="on" autocapitalize="sentences" placeholder="Type text..." /> 340 + <button id="text-send-btn">Send</button> 341 + <button id="text-back-btn" class="mode-btn">&#x25be; bar</button> 342 + </div> 203 343 </div> 204 344 205 345 <script type="module"> ··· 485 625 }); 486 626 487 627 term.focus(); 628 + showKeyboardMode("bar"); 488 629 } 489 630 490 631 function doDetach() { ··· 510 651 currentFit = null; 511 652 } 512 653 detachBtn.removeEventListener("click", doDetach); 654 + resetKeyboardState(); 513 655 loadSessions(); 656 + } 657 + 658 + // ── Mobile Keyboard ── 659 + 660 + const isMobile = 'ontouchstart' in window; 661 + const nativeKeys = new URLSearchParams(location.search).has('nativeKeys'); 662 + if (isMobile && !nativeKeys) document.body.classList.add('mobile-device'); 663 + 664 + let kbMode = "bar"; // "bar" | "panel" | "text" 665 + let stickyCtrl = false; 666 + let stickyAlt = false; 667 + let ctrlLocked = false; 668 + let altLocked = false; 669 + let lastCtrlTap = 0; 670 + let lastAltTap = 0; 671 + 672 + const quickBar = document.getElementById("quick-bar"); 673 + const keyPanel = document.getElementById("key-panel"); 674 + const textInputBar = document.getElementById("text-input-bar"); 675 + const textInput = document.getElementById("text-input"); 676 + 677 + function showKeyboardMode(mode) { 678 + kbMode = mode; 679 + const isMobileDevice = document.body.classList.contains('mobile-device'); 680 + quickBar.style.display = (mode === "bar" && isMobileDevice) ? "block" : "none"; 681 + keyPanel.style.display = (mode === "panel" && isMobileDevice) ? "grid" : "none"; 682 + textInputBar.style.display = (mode === "text" && isMobileDevice) ? "flex" : "none"; 683 + if (mode === "text") { 684 + textInput.focus(); 685 + } else if (currentTerm) { 686 + currentTerm.focus(); 687 + } 688 + } 689 + 690 + function updateModButtons() { 691 + for (const btn of quickBar.querySelectorAll('.mod-btn')) { 692 + const mod = btn.dataset.mod; 693 + if (!mod) continue; 694 + const isActive = mod === 'ctrl' ? stickyCtrl : stickyAlt; 695 + const isLocked = mod === 'ctrl' ? ctrlLocked : altLocked; 696 + btn.classList.toggle('active', isActive && !isLocked); 697 + btn.classList.toggle('locked', isLocked); 698 + } 699 + } 700 + 701 + function handleModTap(mod) { 702 + const now = Date.now(); 703 + if (mod === 'ctrl') { 704 + if (now - lastCtrlTap < 400) { 705 + ctrlLocked = !ctrlLocked; 706 + stickyCtrl = ctrlLocked; 707 + } else { 708 + if (ctrlLocked) { ctrlLocked = false; stickyCtrl = false; } 709 + else { stickyCtrl = !stickyCtrl; } 710 + } 711 + lastCtrlTap = now; 712 + } else if (mod === 'alt') { 713 + if (now - lastAltTap < 400) { 714 + altLocked = !altLocked; 715 + stickyAlt = altLocked; 716 + } else { 717 + if (altLocked) { altLocked = false; stickyAlt = false; } 718 + else { stickyAlt = !stickyAlt; } 719 + } 720 + lastAltTap = now; 721 + } 722 + updateModButtons(); 723 + } 724 + 725 + function parseDataKey(raw) { 726 + return raw.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => 727 + String.fromCharCode(parseInt(hex, 16)) 728 + ).replace(/\\t/g, '\t').replace(/\\r/g, '\r').replace(/\\n/g, '\n'); 729 + } 730 + 731 + function sendKey(seq) { 732 + if (stickyCtrl && seq.length === 1) { 733 + const lower = seq.toLowerCase(); 734 + if (lower >= 'a' && lower <= 'z') { 735 + seq = String.fromCharCode(lower.charCodeAt(0) - 96); 736 + } 737 + } 738 + if (stickyAlt) { 739 + seq = '\x1b' + seq; 740 + } 741 + if (currentWs && currentWs.readyState === WebSocket.OPEN) { 742 + currentWs.send(dataPacket(seq)); 743 + } 744 + if (!ctrlLocked) { stickyCtrl = false; updateModButtons(); } 745 + if (!altLocked) { stickyAlt = false; updateModButtons(); } 746 + if (currentTerm) currentTerm.focus(); 747 + } 748 + 749 + // Prevent focus steal on all keyboard buttons 750 + function preventFocusSteal(e) { 751 + e.preventDefault(); 752 + } 753 + 754 + for (const btn of quickBar.querySelectorAll('button')) { 755 + btn.addEventListener('mousedown', preventFocusSteal); 756 + btn.addEventListener('touchstart', preventFocusSteal); 757 + } 758 + for (const btn of keyPanel.querySelectorAll('button')) { 759 + btn.addEventListener('mousedown', preventFocusSteal); 760 + btn.addEventListener('touchstart', preventFocusSteal); 761 + } 762 + 763 + // Quick bar button clicks 764 + quickBar.addEventListener('click', (e) => { 765 + const btn = e.target.closest('button'); 766 + if (!btn) return; 767 + if (btn.dataset.mod) { 768 + handleModTap(btn.dataset.mod); 769 + return; 770 + } 771 + if (btn.id === 'show-panel-btn') { 772 + showKeyboardMode('panel'); 773 + return; 774 + } 775 + if (btn.id === 'show-text-btn') { 776 + showKeyboardMode('text'); 777 + return; 778 + } 779 + if (btn.dataset.key) { 780 + sendKey(parseDataKey(btn.dataset.key)); 781 + } 782 + }); 783 + 784 + // Key panel button clicks 785 + keyPanel.addEventListener('click', (e) => { 786 + const btn = e.target.closest('button'); 787 + if (!btn) return; 788 + if (btn.id === 'panel-back-btn') { 789 + showKeyboardMode('bar'); 790 + return; 791 + } 792 + if (btn.dataset.action === 'paste') { 793 + navigator.clipboard.readText().then((text) => { 794 + if (text && currentWs && currentWs.readyState === WebSocket.OPEN) { 795 + currentWs.send(dataPacket(text)); 796 + } 797 + if (currentTerm) currentTerm.focus(); 798 + }).catch(() => {}); 799 + return; 800 + } 801 + if (btn.dataset.key) { 802 + sendKey(parseDataKey(btn.dataset.key)); 803 + } 804 + }); 805 + 806 + // Text input bar 807 + document.getElementById('text-send-btn').addEventListener('mousedown', preventFocusSteal); 808 + document.getElementById('text-back-btn').addEventListener('mousedown', preventFocusSteal); 809 + 810 + document.getElementById('text-send-btn').addEventListener('click', () => { 811 + const val = textInput.value; 812 + if (val && currentWs && currentWs.readyState === WebSocket.OPEN) { 813 + currentWs.send(dataPacket(val + '\r')); 814 + } 815 + textInput.value = ''; 816 + showKeyboardMode('bar'); 817 + }); 818 + 819 + document.getElementById('text-back-btn').addEventListener('click', () => { 820 + showKeyboardMode('bar'); 821 + }); 822 + 823 + textInput.addEventListener('keydown', (e) => { 824 + if (e.key === 'Enter') { 825 + e.preventDefault(); 826 + document.getElementById('text-send-btn').click(); 827 + } 828 + }); 829 + 830 + function resetKeyboardState() { 831 + kbMode = "bar"; 832 + stickyCtrl = false; 833 + stickyAlt = false; 834 + ctrlLocked = false; 835 + altLocked = false; 836 + updateModButtons(); 837 + showKeyboardMode("bar"); 514 838 } 515 839 516 840 // ── Init ──
+204 -1
tests/web.test.ts
··· 1 - import { test, expect } from "@playwright/test"; 1 + import { test, expect, devices } from "@playwright/test"; 2 2 import * as fs from "node:fs"; 3 3 import * as os from "node:os"; 4 4 import * as path from "node:path"; ··· 213 213 await page.goto(url); 214 214 await expect(page.locator(".session-item")).toHaveCount(2, { timeout: 5000 }); 215 215 }); 216 + 217 + // ── Mobile Keyboard Tests ── 218 + 219 + const iPhone = devices["iPhone 14"]; 220 + 221 + test.describe("mobile keyboard", () => { 222 + test("quick bar visible on mobile", async ({ browser }) => { 223 + const name = uniqueName(); 224 + await startPty(name, "cat"); 225 + const { url } = await startWeb(); 226 + 227 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 228 + const page = await ctx.newPage(); 229 + await page.goto(url); 230 + await page.locator(".session-item").click(); 231 + await expect(page.locator("#terminal-view")).toBeVisible({ timeout: 5000 }); 232 + await expect(page.locator("#quick-bar")).toBeVisible(); 233 + await ctx.close(); 234 + }); 235 + 236 + test("quick bar hidden on desktop", async ({ page }) => { 237 + const name = uniqueName(); 238 + await startPty(name, "cat"); 239 + const { url } = await startWeb(); 240 + 241 + await page.goto(url); 242 + await page.locator(".session-item").click(); 243 + await expect(page.locator("#terminal-view")).toBeVisible({ timeout: 5000 }); 244 + await expect(page.locator("#quick-bar")).not.toBeVisible(); 245 + }); 246 + 247 + test("nativeKeys hides keyboard UI", async ({ browser }) => { 248 + const name = uniqueName(); 249 + await startPty(name, "cat"); 250 + const { url } = await startWeb(); 251 + 252 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 253 + const page = await ctx.newPage(); 254 + await page.goto(url + "?nativeKeys=1"); 255 + await page.locator(".session-item").click(); 256 + await expect(page.locator("#terminal-view")).toBeVisible({ timeout: 5000 }); 257 + await expect(page.locator("#quick-bar")).not.toBeVisible(); 258 + await ctx.close(); 259 + }); 260 + 261 + test("Esc key sends escape", async ({ browser }) => { 262 + const name = uniqueName(); 263 + await startPty(name, "cat"); 264 + const { url } = await startWeb(); 265 + 266 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 267 + const page = await ctx.newPage(); 268 + await page.goto(url); 269 + await page.locator(".session-item").click(); 270 + await expect(page.locator("#quick-bar")).toBeVisible({ timeout: 5000 }); 271 + await new Promise((r) => setTimeout(r, 300)); 272 + 273 + // Tap Esc button 274 + await page.locator('#quick-bar button:has-text("Esc")').click(); 275 + 276 + // cat will echo the escape sequence - look for ^[ which is how terminals display ESC 277 + await expect(page.locator(".xterm-rows")).toContainText("^[", { timeout: 5000 }); 278 + await ctx.close(); 279 + }); 280 + 281 + test("Tab key sends tab", async ({ browser }) => { 282 + const name = uniqueName(); 283 + // Use cat -vt to display control characters (including tabs) visibly 284 + await startPty(name, "cat", ["-vt"]); 285 + const { url } = await startWeb(); 286 + 287 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 288 + const page = await ctx.newPage(); 289 + await page.goto(url); 290 + await page.locator(".session-item").click(); 291 + await expect(page.locator("#quick-bar")).toBeVisible({ timeout: 5000 }); 292 + await new Promise((r) => setTimeout(r, 300)); 293 + 294 + // Tap Tab button, then press Enter to flush cat's line buffer 295 + await page.locator('#quick-bar button:has-text("Tab")').click(); 296 + await page.keyboard.press("Enter"); 297 + 298 + // cat -vt outputs ^I for tab characters 299 + await expect(page.locator(".xterm-rows")).toContainText("^I", { timeout: 5000 }); 300 + await ctx.close(); 301 + }); 302 + 303 + test("arrow keys work", async ({ browser }) => { 304 + const name = uniqueName(); 305 + await startPty(name, "cat"); 306 + const { url } = await startWeb(); 307 + 308 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 309 + const page = await ctx.newPage(); 310 + await page.goto(url); 311 + await page.locator(".session-item").click(); 312 + await expect(page.locator("#quick-bar")).toBeVisible({ timeout: 5000 }); 313 + await new Promise((r) => setTimeout(r, 300)); 314 + 315 + // Tap right arrow button (→) 316 + await page.locator('#quick-bar button').filter({ hasText: /^→$/ }).click(); 317 + 318 + // cat echoes escape sequences - look for ^[[C (right arrow) 319 + await expect(page.locator(".xterm-rows")).toContainText("^[[C", { timeout: 5000 }); 320 + await ctx.close(); 321 + }); 322 + 323 + test("full panel opens and sends keys", async ({ browser }) => { 324 + const name = uniqueName(); 325 + await startPty(name, "cat"); 326 + const { url } = await startWeb(); 327 + 328 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 329 + const page = await ctx.newPage(); 330 + await page.goto(url); 331 + await page.locator(".session-item").click(); 332 + await expect(page.locator("#quick-bar")).toBeVisible({ timeout: 5000 }); 333 + 334 + // Tap keyboard icon to show panel 335 + await page.locator("#show-panel-btn").click(); 336 + await expect(page.locator("#key-panel")).toBeVisible(); 337 + await expect(page.locator("#quick-bar")).not.toBeVisible(); 338 + 339 + // Tap ^C button in the panel 340 + await page.locator('#key-panel button:has-text("^C")').click(); 341 + 342 + // cat echoes ^C 343 + await expect(page.locator(".xterm-rows")).toContainText("^C", { timeout: 5000 }); 344 + await ctx.close(); 345 + }); 346 + 347 + test("panel back returns to bar", async ({ browser }) => { 348 + const name = uniqueName(); 349 + await startPty(name, "cat"); 350 + const { url } = await startWeb(); 351 + 352 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 353 + const page = await ctx.newPage(); 354 + await page.goto(url); 355 + await page.locator(".session-item").click(); 356 + await expect(page.locator("#quick-bar")).toBeVisible({ timeout: 5000 }); 357 + 358 + // Open panel 359 + await page.locator("#show-panel-btn").click(); 360 + await expect(page.locator("#key-panel")).toBeVisible(); 361 + 362 + // Tap back button 363 + await page.locator("#panel-back-btn").click(); 364 + await expect(page.locator("#quick-bar")).toBeVisible(); 365 + await expect(page.locator("#key-panel")).not.toBeVisible(); 366 + await ctx.close(); 367 + }); 368 + 369 + test("text input mode", async ({ browser }) => { 370 + const name = uniqueName(); 371 + await startPty(name, "cat"); 372 + const { url } = await startWeb(); 373 + 374 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 375 + const page = await ctx.newPage(); 376 + await page.goto(url); 377 + await page.locator(".session-item").click(); 378 + await expect(page.locator("#quick-bar")).toBeVisible({ timeout: 5000 }); 379 + 380 + // Tap Aa to show text input 381 + await page.locator("#show-text-btn").click(); 382 + await expect(page.locator("#text-input-bar")).toBeVisible(); 383 + await expect(page.locator("#quick-bar")).not.toBeVisible(); 384 + 385 + // Type text and send 386 + await page.locator("#text-input").fill("hello world"); 387 + await page.locator("#text-send-btn").click(); 388 + 389 + // Should see the text echoed by cat 390 + await expect(page.locator(".xterm-rows")).toContainText("hello world", { timeout: 5000 }); 391 + 392 + // Should return to bar mode after send 393 + await expect(page.locator("#quick-bar")).toBeVisible(); 394 + await ctx.close(); 395 + }); 396 + 397 + test("text input back returns to bar", async ({ browser }) => { 398 + const name = uniqueName(); 399 + await startPty(name, "cat"); 400 + const { url } = await startWeb(); 401 + 402 + const ctx = await browser.newContext({ ...iPhone, hasTouch: true }); 403 + const page = await ctx.newPage(); 404 + await page.goto(url); 405 + await page.locator(".session-item").click(); 406 + await expect(page.locator("#quick-bar")).toBeVisible({ timeout: 5000 }); 407 + 408 + // Open text input 409 + await page.locator("#show-text-btn").click(); 410 + await expect(page.locator("#text-input-bar")).toBeVisible(); 411 + 412 + // Tap back 413 + await page.locator("#text-back-btn").click(); 414 + await expect(page.locator("#quick-bar")).toBeVisible(); 415 + await expect(page.locator("#text-input-bar")).not.toBeVisible(); 416 + await ctx.close(); 417 + }); 418 + });