This repository has no description
0

Configure Feed

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

Mobile keyboard support, touch scrolling, and web server improvements

- Add virtual keyboard UI for mobile: quick bar with common keys,
full key panel, and text input mode with chat bubble toggle
- Sticky Ctrl/Alt modifiers with double-tap to lock
- Touch scroll handler replicating xterm.js Gesture class physics
(additive friction, rolling position tracking, pixel-level inertia)
with auto-disable when xterm.js 6.1.0 ships native touch support
- Visual Viewport API for proper mobile keyboard-aware sizing
- Renegotiate PTY size when a client disconnects
- Bind web server to 0.0.0.0 for Tailscale/remote access
- Add TODO.md for future connection tracking work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Nathan Herald (Mar 17, 2026, 5:09 PM +0100) 2902b16f eaf4bd4c

+186 -10
+5
TODO.md
··· 1 + # TODO 2 + 3 + - Show connection count per session in `pty list` and the web UI session list. Requires adding a protocol message (e.g., INFO) so the CLI/web can query the daemon for its current client count. 4 + - Allow killing stale connections. With the web frontend, browser tabs may disconnect uncleanly and leave stale connections. Need a way to list and kill individual connections per session. 5 + - Upgrade to xterm.js 6.1.0 when released — fixes native touch scrolling (currently worked around with a manual touchmove handler).
+2
src/server.ts
··· 278 278 279 279 socket.on("close", () => { 280 280 this.clients.delete(socket); 281 + this.negotiateSize(); 281 282 }); 282 283 283 284 socket.on("error", () => { 284 285 this.clients.delete(socket); 286 + this.negotiateSize(); 285 287 }); 286 288 } 287 289
+1 -1
src/web/server.ts
··· 35 35 private connections = new Set<{ ws: WSType; unix: net.Socket }>(); 36 36 37 37 constructor(options: WebServerOptions = {}) { 38 - const host = options.host ?? "127.0.0.1"; 38 + const host = options.host ?? "0.0.0.0"; 39 39 const port = options.port ?? 7681; 40 40 this.connectCode = options.connectCode; 41 41
+178 -9
src/web/static/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="utf-8" /> 5 - <meta name="viewport" content="width=device-width, initial-scale=1" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" /> 6 6 <title>pty</title> 7 7 <link rel="stylesheet" href="/vendor/xterm.css" /> 8 8 <script type="importmap"> ··· 12 12 } } 13 13 </script> 14 14 <style> 15 + :root { --vh: 100vh; } 15 16 * { margin: 0; padding: 0; box-sizing: border-box; } 16 17 body { 17 18 background: #1e1e1e; 18 19 color: #ccc; 19 20 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 20 - height: 100vh; 21 + height: var(--vh); 21 22 overflow: hidden; 23 + overscroll-behavior: none; 22 24 } 23 25 24 26 /* Login view */ ··· 26 28 display: none; 27 29 justify-content: center; 28 30 align-items: center; 29 - height: 100vh; 31 + height: var(--vh); 30 32 } 31 33 #login-view form { 32 34 background: #2a2a2a; ··· 78 80 #list-view { 79 81 display: none; 80 82 flex-direction: column; 81 - height: 100vh; 83 + height: var(--vh); 82 84 padding: 1.5rem; 83 85 } 84 86 #list-view h1 { ··· 131 133 #terminal-view { 132 134 display: none; 133 135 flex-direction: column; 134 - height: 100vh; 136 + height: var(--vh); 135 137 } 136 138 #toolbar { 137 139 display: flex; ··· 234 236 border: 1px solid #555; 235 237 border-radius: 4px; 236 238 } 239 + #show-kb-btn { 240 + display: none; 241 + position: absolute; 242 + bottom: 8px; 243 + right: 8px; 244 + width: 44px; 245 + height: 44px; 246 + background: rgba(68, 68, 68, 0.8); 247 + color: #ccc; 248 + border: none; 249 + border-radius: 22px; 250 + font-size: 20px; 251 + cursor: pointer; 252 + -webkit-tap-highlight-color: transparent; 253 + user-select: none; 254 + z-index: 5; 255 + } 256 + 237 257 #text-send-btn, #text-back-btn { 238 258 padding: 8px 16px; 239 259 background: #558; ··· 271 291 <div id="exit-overlay"></div> 272 292 273 293 <div id="quick-bar"> 294 + <button id="show-text-btn" class="mode-btn">&#x1F4AC;</button> 274 295 <button data-key="\x1b">Esc</button> 275 296 <button data-key="\t">Tab</button> 276 297 <button data-mod="ctrl" class="mod-btn">Ctrl</button> ··· 286 307 <button data-key="\x1b[H">Home</button> 287 308 <button data-key="\x1b[F">End</button> 288 309 <button id="show-panel-btn" class="mode-btn">&#x2328;</button> 289 - <button id="show-text-btn" class="mode-btn">Aa</button> 310 + <button id="hide-kb-btn" class="mode-btn">&#x2715;</button> 290 311 </div> 291 312 292 313 <div id="key-panel"> 314 + <button id="panel-back-btn" class="mode-btn">&#x25be; bar</button> 293 315 <button data-key="\x1b[Z">&#x21e5;Tab</button> 294 316 <button data-key="\x1b[2~">Ins</button> 295 317 <button data-key="\x1b[3~">Del</button> ··· 332 354 <button data-key=">">&gt;</button> 333 355 <button data-key="`">`</button> 334 356 <button data-key="\">\</button> 335 - <button id="panel-back-btn" class="mode-btn">&#x25be; bar</button> 336 357 </div> 337 358 338 359 <div id="text-input-bar"> ··· 340 361 <button id="text-send-btn">Send</button> 341 362 <button id="text-back-btn" class="mode-btn">&#x25be; bar</button> 342 363 </div> 364 + 365 + <button id="show-kb-btn">&#x2328;</button> 343 366 </div> 344 367 345 368 <script type="module"> ··· 543 566 const term = new Terminal({ 544 567 cursorBlink: true, 545 568 fontSize: 14, 569 + smoothScrollDuration: 80, 546 570 theme: { background: "#1e1e1e" }, 547 571 }); 548 572 const fitAddon = new FitAddon(); ··· 673 697 const keyPanel = document.getElementById("key-panel"); 674 698 const textInputBar = document.getElementById("text-input-bar"); 675 699 const textInput = document.getElementById("text-input"); 700 + const showKbBtn = document.getElementById("show-kb-btn"); 676 701 677 702 function showKeyboardMode(mode) { 678 703 kbMode = mode; ··· 680 705 quickBar.style.display = (mode === "bar" && isMobileDevice) ? "block" : "none"; 681 706 keyPanel.style.display = (mode === "panel" && isMobileDevice) ? "grid" : "none"; 682 707 textInputBar.style.display = (mode === "text" && isMobileDevice) ? "flex" : "none"; 708 + showKbBtn.style.display = (mode === "hidden" && isMobileDevice) ? "block" : "none"; 683 709 if (mode === "text") { 684 710 textInput.focus(); 711 + } else if (mode === "panel") { 712 + // Hide native keyboard — user wants virtual keys only 713 + if (document.activeElement) document.activeElement.blur(); 685 714 } else if (currentTerm) { 686 715 currentTerm.focus(); 687 716 } ··· 753 782 754 783 for (const btn of quickBar.querySelectorAll('button')) { 755 784 btn.addEventListener('mousedown', preventFocusSteal); 756 - btn.addEventListener('touchstart', preventFocusSteal); 757 785 } 758 786 for (const btn of keyPanel.querySelectorAll('button')) { 759 787 btn.addEventListener('mousedown', preventFocusSteal); 760 - btn.addEventListener('touchstart', preventFocusSteal); 761 788 } 762 789 763 790 // Quick bar button clicks ··· 776 803 showKeyboardMode('text'); 777 804 return; 778 805 } 806 + if (btn.id === 'hide-kb-btn') { 807 + showKeyboardMode('hidden'); 808 + return; 809 + } 779 810 if (btn.dataset.key) { 780 811 sendKey(parseDataKey(btn.dataset.key)); 781 812 } ··· 803 834 } 804 835 }); 805 836 837 + // Floating reopen button 838 + showKbBtn.addEventListener('mousedown', preventFocusSteal); 839 + showKbBtn.addEventListener('click', () => { 840 + showKeyboardMode('bar'); 841 + }); 842 + 806 843 // Text input bar 807 844 document.getElementById('text-send-btn').addEventListener('mousedown', preventFocusSteal); 808 845 document.getElementById('text-back-btn').addEventListener('mousedown', preventFocusSteal); ··· 836 873 updateModButtons(); 837 874 showKeyboardMode("bar"); 838 875 } 876 + 877 + // ── Viewport height (mobile keyboard aware) ── 878 + 879 + function updateViewportHeight() { 880 + const vh = window.visualViewport 881 + ? window.visualViewport.height 882 + : window.innerHeight; 883 + document.documentElement.style.setProperty('--vh', `${vh}px`); 884 + } 885 + 886 + if (window.visualViewport) { 887 + window.visualViewport.addEventListener('resize', updateViewportHeight); 888 + window.visualViewport.addEventListener('scroll', updateViewportHeight); 889 + } 890 + window.addEventListener('resize', updateViewportHeight); 891 + updateViewportHeight(); 892 + 893 + // ── Touch scrolling (xterm.js 6.0.x workaround) ── 894 + // xterm.js 6.0 uses a VS Code SmoothScrollableElement that only handles 895 + // wheel events. Touch scrolling was added in 6.1.0 (PR #5563) via the 896 + // internal Gesture class dispatching '-xterm-gesturechange' events. 897 + // 898 + // This handler replicates the Gesture class physics (additive friction, 899 + // rolling position tracking, pixel-level inertia) and will auto-disable 900 + // when xterm.js handles touch scrolling natively (6.1.0+). 901 + 902 + const SCROLL_FRICTION = -0.005; 903 + let nativeTouchScroll = false; // set true when xterm 6.1.0+ detected 904 + let touchId = -1; 905 + let rollingPageY = []; 906 + let rollingTimestamps = []; 907 + let lastPageY = 0; 908 + let inertiaHandle = 0; 909 + let scrollPixelOffset = 0; // sub-line pixel accumulator 910 + 911 + // Detect xterm.js 6.1.0+ by listening for its gesture events 912 + terminalContainer.addEventListener('-xterm-gesturechange', () => { 913 + nativeTouchScroll = true; 914 + stopInertia(); 915 + }); 916 + 917 + function stopInertia() { 918 + if (inertiaHandle) { 919 + cancelAnimationFrame(inertiaHandle); 920 + inertiaHandle = 0; 921 + } 922 + } 923 + 924 + function getLineHeight() { 925 + if (!currentTerm) return 16; 926 + // Access cell height from the render dimensions if available 927 + const el = terminalContainer.querySelector('.xterm-screen canvas'); 928 + if (el && currentTerm.rows > 0) { 929 + return el.clientHeight / currentTerm.rows; 930 + } 931 + return currentTerm.options.fontSize * 1.2; 932 + } 933 + 934 + function scrollByPixels(deltaY) { 935 + if (!currentTerm) return; 936 + const lineHeight = getLineHeight(); 937 + scrollPixelOffset += deltaY; 938 + const lines = Math.trunc(scrollPixelOffset / lineHeight); 939 + if (lines !== 0) { 940 + currentTerm.scrollLines(lines); 941 + scrollPixelOffset -= lines * lineHeight; 942 + } 943 + } 944 + 945 + function runInertia(t1, vY, dirY) { 946 + inertiaHandle = requestAnimationFrame(() => { 947 + const now = Date.now(); 948 + const deltaT = now - t1; 949 + 950 + vY += SCROLL_FRICTION * deltaT; 951 + 952 + if (vY <= 0) { 953 + inertiaHandle = 0; 954 + return; 955 + } 956 + 957 + const deltaPosY = dirY * vY * deltaT; 958 + scrollByPixels(-deltaPosY); 959 + 960 + runInertia(now, vY, dirY); 961 + }); 962 + } 963 + 964 + terminalContainer.addEventListener('touchstart', (e) => { 965 + if (nativeTouchScroll || e.touches.length !== 1) return; 966 + stopInertia(); 967 + const touch = e.touches[0]; 968 + touchId = touch.identifier; 969 + lastPageY = touch.pageY; 970 + scrollPixelOffset = 0; 971 + rollingPageY = [touch.pageY]; 972 + rollingTimestamps = [Date.now()]; 973 + }, { passive: true }); 974 + 975 + terminalContainer.addEventListener('touchmove', (e) => { 976 + if (nativeTouchScroll || !currentTerm || e.touches.length !== 1) return; 977 + const touch = e.touches[0]; 978 + if (touch.identifier !== touchId) return; 979 + 980 + const deltaY = lastPageY - touch.pageY; 981 + lastPageY = touch.pageY; 982 + scrollByPixels(deltaY); 983 + 984 + // Keep last 3 data points for velocity calculation 985 + if (rollingPageY.length > 3) { 986 + rollingPageY.shift(); 987 + rollingTimestamps.shift(); 988 + } 989 + rollingPageY.push(touch.pageY); 990 + rollingTimestamps.push(Date.now()); 991 + }, { passive: true }); 992 + 993 + terminalContainer.addEventListener('touchend', (e) => { 994 + if (nativeTouchScroll || !currentTerm || rollingPageY.length < 2) return; 995 + 996 + const finalY = rollingPageY[rollingPageY.length - 1]; 997 + const deltaT = rollingTimestamps[rollingTimestamps.length - 1] - rollingTimestamps[0]; 998 + const deltaY = finalY - rollingPageY[0]; 999 + 1000 + if (deltaT > 0 && Math.abs(deltaY) > 5) { 1001 + const speed = Math.abs(deltaY) / deltaT; 1002 + const dir = deltaY > 0 ? 1 : -1; 1003 + runInertia(Date.now(), speed, dir); 1004 + } 1005 + 1006 + touchId = -1; 1007 + }, { passive: true }); 839 1008 840 1009 // ── Init ── 841 1010