From 71a868be17bb8c19e90501c564b92dceab25c3ec Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 9 May 2026 17:02:15 -0700 Subject: [PATCH 01/12] Initial unimplemented mobile UI spec. --- docs/specs/mobile-ui.md | 503 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 docs/specs/mobile-ui.md diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md new file mode 100644 index 0000000..91c4a84 --- /dev/null +++ b/docs/specs/mobile-ui.md @@ -0,0 +1,503 @@ +# Mobile Terminal Website Prototype Spec + +## 1. Overview + +This document specifies a greenfield prototype for a mobile-first terminal UI. + +The prototype tests one core idea: + +```text +Native phone keyboard for text + simple terminal controls for missing keys. +``` + +The app should feel like a lightweight mobile terminal playground. It does not need remote sessions, SSH, user accounts, or production infrastructure. The first version should use a local playground PTY, fake shell, in-browser terminal demo, or command simulator that is realistic enough to test the mobile UI. + +The main interface has a terminal viewport and a bottom navigation row: + +```text +Recent | Type | Draft | Keys +``` + +Only the most important sections need complete behavior in v0: + +* **Type**: implemented. +* **Keys**: implemented. +* **Recent**: opens a TODO pane. +* **Draft**: opens a TODO pane. + +## 2. Prototype goals + +### 2.1 Primary goals + +* Test mobile terminal text entry using the native phone keyboard. +* Test whether simple on-screen terminal controls make missing keys usable on a phone. +* Keep the terminal viewport usable when the mobile keyboard is visible. +* Provide enough terminal behavior to evaluate typing, Enter, Backspace, arrows, Escape, Tab, and Ctrl+C. +* Keep the implementation small and easy to iterate on. + +### 2.2 Non-goals + +The prototype does not need: + +* Remote shell support. +* SSH support. +* WebSocket transport. +* User accounts. +* Session persistence. +* Multiple terminal sessions. +* Command history storage. +* Snippet management. +* A real draft/scratchpad workflow. +* Terminal mouse mode. +* Advanced gestures. +* Production security hardening. +* Full accessibility implementation. + +## 3. Core layout + +## 3.1 Default portrait layout + +```text +┌─────────────────────────┐ +│ Terminal playground │ +│ │ +│ │ +├─────────────────────────┤ +│ Active pane │ +├─────────────────────────┤ +│ Recent | Type | Draft | Keys +└─────────────────────────┘ +``` + +The active pane changes when the user taps a bottom navigation item. + +## 3.2 Type mode layout + +Type is the default active section. + +```text +┌─────────────────────────┐ +│ Terminal playground │ +│ │ +├─────────────────────────┤ +│ Esc Tab Ctrl+C ← ↓ ↑ → │ +├─────────────────────────┤ +│ Recent | Type | Draft | Keys +├─────────────────────────┤ +│ native phone keyboard │ +└─────────────────────────┘ +``` + +When the native keyboard is open, the terminal viewport should resize to remain visible. + +## 3.3 Keys mode layout + +Keys mode shows the same important controls in a larger layout. + +```text +┌─────────────────────────┐ +│ Terminal playground │ +│ │ +├─────────────────────────┤ +│ Esc Tab Ctrl+C │ +│ ← ↓ ↑ →│ +├─────────────────────────┤ +│ Recent | Type | Draft | Keys +└─────────────────────────┘ +``` + +Keys mode is useful when the user needs more reliable taps for arrow keys and Ctrl+C. + +## 3.4 TODO pane layout + +Recent and Draft should open simple placeholder panes. + +```text +┌─────────────────────────┐ +│ Terminal playground │ +│ │ +├─────────────────────────┤ +│ TODO: Recent │ +├─────────────────────────┤ +│ Recent | Type | Draft | Keys +└─────────────────────────┘ +``` + +```text +┌─────────────────────────┐ +│ Terminal playground │ +│ │ +├─────────────────────────┤ +│ TODO: Draft │ +├─────────────────────────┤ +│ Recent | Type | Draft | Keys +└─────────────────────────┘ +``` + +The TODO panes should make the app structure visible without implementing those workflows. + +## 4. Bottom navigation + +The bottom navigation row is always present unless the native keyboard or viewport constraints make that impossible. + +```text +Recent | Type | Draft | Keys +``` + +### 4.1 Navigation behavior + +| Item | v0 behavior | +| ------ | ------------------------------------------ | +| Recent | Opens TODO pane | +| Type | Opens Type pane and focuses terminal input | +| Draft | Opens TODO pane | +| Keys | Opens large Keys pane | + +### 4.2 Active state + +The active item should be visually obvious. + +Recommended active states: + +* Highlight the selected label. +* Use a top border or pill background. +* Keep the active pane directly above the nav row. + +### 4.3 Default state + +The app should start in **Type** mode. + +## 5. Terminal playground + +The terminal area is the main testing surface. + +### 5.1 Requirements + +The terminal playground should: + +* Display terminal-like output. +* Accept typed input from the native mobile keyboard. +* Show a cursor. +* Support Enter. +* Support Backspace. +* Respond visibly to arrow keys. +* Respond visibly to Escape, Tab, and Ctrl+C. +* Scroll when output exceeds the visible area. +* Resize when the mobile keyboard appears or disappears. + +### 5.2 Implementation options + +Use the simplest implementation that gives a realistic enough interaction test. + +Acceptable options: + +* xterm.js connected to a local playground process. +* xterm.js with an in-browser command simulator. +* A custom terminal-like component if true terminal emulation is not needed yet. + +Prefer xterm.js if the prototype should test realistic cursor movement, ANSI behavior, and terminal rendering. + +### 5.3 Minimal fake shell behavior + +A fake shell is acceptable for v0. + +Minimum useful behavior: + +* Echo typed characters. +* Maintain a command line buffer. +* Enter submits the current command. +* Backspace edits the current command. +* Arrow keys produce visible behavior. +* Ctrl+C clears or interrupts the current command. +* Escape and Tab produce visible behavior. + +Example commands: + +```text +help +clear +echo hello +``` + +The shell only needs enough behavior to test the mobile controls. + +## 6. Type pane + +Type is the primary implemented pane. + +### 6.1 Purpose + +Type mode lets the user enter normal text through the native phone keyboard while keeping a compact terminal control row available. + +### 6.2 Layout + +```text +Esc Tab Ctrl+C ← ↓ ↑ → +``` + +### 6.3 Behavior + +When the user opens Type mode: + +* Focus the terminal input. +* Open the native phone keyboard where browser policy allows. +* Show the compact terminal control row. +* Keep the terminal viewport visible above the controls and keyboard. + +### 6.4 Hidden input + +Use a hidden or visually minimal input configured for terminal-style typing: + +```html + +``` + +### 6.5 Text input behavior + +Required behavior: + +* Normal characters are sent to the terminal playground. +* Enter sends terminal Enter. +* Backspace works. +* Autocorrect and autocapitalization are disabled where possible. +* Input should support mobile keyboard behavior and IME composition. +* The app should not depend only on `keydown` for text input. + +## 7. Keys pane + +Keys is the second implemented pane. + +### 7.1 Purpose + +Keys mode provides larger tap targets for the terminal controls that are most important on mobile. + +### 7.2 Layout + +```text +┌───────────────────────┐ +│ Esc Tab Ctrl+C │ +│ ← ↓ ↑ → │ +└───────────────────────┘ +``` + +### 7.3 Controls + +| Button | Behavior | +| ------ | ---------------- | +| Esc | Send Escape | +| Tab | Send Tab | +| Ctrl+C | Send interrupt | +| ← | Send left arrow | +| ↓ | Send down arrow | +| ↑ | Send up arrow | +| → | Send right arrow | + +### 7.4 Interaction rules + +* Tapping a key sends exactly one action. +* The same key mappings should be used in Type mode and Keys mode. +* Keys mode should not add extra controls in v0. +* Long-press repeat is not required for v0. +* Modifier-lock behavior is not required for v0. + +## 8. Recent TODO pane + +### 8.1 Purpose + +Recent is visible in the navigation so the overall app structure can be tested, but it does not implement command history in v0. + +### 8.2 Layout + +```text +TODO: Recent commands + +This pane will eventually show recently used commands. +``` + +### 8.3 Behavior + +* Tapping Recent opens the TODO pane. +* The terminal remains visible above the pane. +* No command list is required. +* No storage is required. + +## 9. Draft TODO pane + +### 9.1 Purpose + +Draft is visible in the navigation so the overall app structure can be tested, but it does not implement scratchpad or dual-pane editing in v0. + +### 9.2 Layout + +```text +TODO: Draft + +This pane will eventually support composing text before sending it to the terminal. +``` + +### 9.3 Behavior + +* Tapping Draft opens the TODO pane. +* The terminal remains visible above the pane. +* No editable scratchpad is required. +* No copy/paste workflow is required. + +## 10. Key sequence mapping + +Use these mappings for both Type mode and Keys mode. + +| UI action | Sequence | +| ----------- | -------------- | +| Ctrl+C | `\x03` | +| Esc | `\x1B` | +| Tab | `\x09` | +| Enter | `\r` | +| Backspace | Usually `\x7F` | +| Arrow Up | `\x1B[A` | +| Arrow Down | `\x1B[B` | +| Arrow Right | `\x1B[C` | +| Arrow Left | `\x1B[D` | + +If the playground terminal uses a higher-level input API instead of raw terminal sequences, map these actions to the equivalent local action. + +## 11. Keyboard visibility and layout + +Keyboard handling should be simple and pragmatic. + +The prototype should resize the terminal area when the phone keyboard appears. Use the simplest reliable approach available: + +1. Use `window.visualViewport` resize events if available. +2. Fall back to normal viewport sizing. +3. Avoid complex keyboard detection logic unless the layout is broken. + +Minimal approach: + +```js +function updateLayoutForKeyboard() { + const viewportHeight = window.visualViewport?.height ?? window.innerHeight; + document.documentElement.style.setProperty( + "--visible-height", + `${viewportHeight}px` + ); +} + +window.visualViewport?.addEventListener("resize", updateLayoutForKeyboard); +window.visualViewport?.addEventListener("scroll", updateLayoutForKeyboard); +window.addEventListener("resize", updateLayoutForKeyboard); +updateLayoutForKeyboard(); +``` + +The prototype does not need to perfectly detect whether the keyboard is present. It only needs to keep the terminal, active pane, and nav row usable. + +## 12. Touch interactions + +Keep touch behavior minimal. + +Required interactions: + +* Tap terminal to focus typing. +* Tap Type to focus typing. +* Tap control buttons. +* Tap bottom navigation items. +* Scroll terminal output. + +Not required for v0: + +* Swipe navigation. +* Long-press arrow repeat. +* Trackpad mode. +* Two-finger gestures. +* Terminal mouse mode. +* Custom text selection behavior. + +## 13. Copy and paste + +Keep copy and paste minimal. + +Prototype behavior: + +* Let users paste through the native browser/OS paste flow where possible. +* Let terminal output selection rely on default browser behavior where possible. +* No custom clipboard manager is required. +* No multi-line paste review is required. + +## 14. Recommended v0 scope + +Build exactly this: + +* One terminal playground screen. +* Bottom navigation row: + +```text +Recent | Type | Draft | Keys +``` + +* Type pane with compact controls: + +```text +Esc Tab Ctrl+C ← ↓ ↑ → +``` + +* Keys pane with larger controls: + +```text +Esc Tab Ctrl+C +← ↓ ↑ → +``` + +* Recent TODO pane. +* Draft TODO pane. +* Native mobile keyboard input. +* Basic viewport resizing when the keyboard opens. +* Simple local playground terminal behavior. + +## 15. Prototype success criteria + +The prototype should answer these questions: + +1. Is the terminal viewport usable when the mobile keyboard is open? +2. Is the compact Type control row easy to reach? +3. Is the larger Keys pane necessary or useful? +4. Are arrow keys usable enough for command history and cursor movement? +5. Is Ctrl+C discoverable and easy to trigger? +6. Does the native keyboard feel acceptable for terminal text entry? +7. Does the four-item navigation row make sense, even with Recent and Draft as placeholders? +8. Is the UI too cramped in portrait orientation? + +## 16. Future work + +Potential later additions: + +* Real recent commands. +* Draft scratchpad. +* Dual-pane copy/paste. +* Pinned snippets. +* Ctrl+D and Ctrl+Z. +* Alt and modifier behavior. +* Home, End, PgUp, PgDn. +* Long-press key repeat. +* Gesture navigation. +* Terminal mouse mode. +* Remote backend PTY. +* SSH sessions. +* User accounts. +* Session persistence. +* Multi-session support. +* Production security model. + +## 17. Product principle + +The v0 prototype should stay focused: + +```text +Type and Keys are real. +Recent and Draft establish the shape of the app. +Everything else waits. +``` From 38962576ac6f873afb6b09fde7b2776ad4bfc658 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 9 May 2026 17:05:11 -0700 Subject: [PATCH 02/12] Rename Roam marketing to Tether --- website/src/pages/Home.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/src/pages/Home.tsx b/website/src/pages/Home.tsx index f21c882..84471f2 100644 --- a/website/src/pages/Home.tsx +++ b/website/src/pages/Home.tsx @@ -321,7 +321,7 @@ function NotifySignupForm() { type="submit" className="min-h-12 inline-flex items-center justify-center rounded-md border border-[var(--color-caramel)] bg-[var(--color-caramel)]/15 px-6 py-3 text-base font-display text-[var(--color-caramel)] transition hover:bg-[var(--color-caramel)]/25 sm:w-auto" > - Notify me when Roam ships + Notify me when Tether ships {message && ( @@ -965,7 +965,7 @@ function Home() { Walk away. Keep going.

- Coming next: Roam. Pair a + Coming next: Tether. Pair a terminal session to your phone over WebRTC and take a stroll, the MouseTerm alert system will buzz you if there's anything to do. A hosted auto-pairing service comes later — just leave and keep working, no "I'm walking away" dance. From 0f676da840e2cb8c4c545e8573d8e5360a0bdf6f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 9 May 2026 18:15:08 -0700 Subject: [PATCH 03/12] Add Tether mobile prototype page --- docs/specs/layout.md | 8 +- lib/src/components/MobileTerminalUi.tsx | 355 +++++++++++++++++++ lib/src/components/Wall.tsx | 14 +- lib/src/stories/MobileTerminalUi.stories.tsx | 111 ++++++ website/src/App.tsx | 4 + website/src/index.css | 16 + website/src/pages/Tether.tsx | 209 +++++++++++ 7 files changed, 709 insertions(+), 8 deletions(-) create mode 100644 lib/src/components/MobileTerminalUi.tsx create mode 100644 lib/src/stories/MobileTerminalUi.stories.tsx create mode 100644 website/src/pages/Tether.tsx diff --git a/docs/specs/layout.md b/docs/specs/layout.md index f9c0697..8e75670 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -18,7 +18,7 @@ Transitioning between Pane and Door does not alter the Session in any way. Minim There are two areas: - **Content** — tiling layout containing Panes, powered by dockview -- **Baseboard** — always-visible bottom strip containing Doors and shortcut hints +- **Baseboard** — bottom strip containing Doors and shortcut hints. It is visible in the main shell; tightly constrained embedders may suppress it with `Wall showBaseboard={false}` when they do not expose door/minimize workflows. The user can navigate between all elements using the mouse, or by entering `command` mode and using the keyboard. @@ -32,7 +32,7 @@ Wall │ │ │ ├── TerminalPanel → TerminalPane → xterm.js │ │ │ └── TerminalPaneHeader (tab component, drag handle) │ │ └── WorkspaceSelectionOverlay (fixed positioned, pointer-events: none) -│ ├── Baseboard (always-visible bottom strip, shortcut hints when empty) +│ ├── Baseboard (bottom strip, shortcut hints when empty; optional for constrained embedders) │ │ └── Door components (one per minimized session) │ └── KillConfirmOverlay (conditional) ``` @@ -104,7 +104,9 @@ The header adapts to available width via ResizeObserver in three tiers: ## Baseboard -Below the content area is the baseboard (`h-7`, 28px). It is always visible and has no top divider. The dockview area ends 2px above it, leaving a narrow theme-colored gap that keeps rounded pane corners distinct from the baseboard. Its horizontal padding matches the Dockview wrapper's 6px inset, so doors align with the panes above. When empty, it shows keyboard shortcut hints when there are no doors and the container is wider than 350px (currently: `LCmd → RCmd to enter command mode`). +Below the content area is the baseboard (`h-7`, 28px). It is visible by default and has no top divider. The dockview area ends 2px above it, leaving a narrow theme-colored gap that keeps rounded pane corners distinct from the baseboard. Its horizontal padding matches the Dockview wrapper's 6px inset, so doors align with the panes above. When empty, it shows keyboard shortcut hints when there are no doors and the container is wider than 350px (currently: `LCmd → RCmd to enter command mode`). + +`Wall` accepts `showBaseboard={false}` for constrained embedders such as the website mobile Tether prototype, where a separate bottom navigation owns the area below the terminal and door workflows are outside the prototype scope. The main app shell keeps the default `showBaseboard=true`. When a session is minimized, it becomes a **door** on the baseboard. The door displays the same derived terminal label as the pane header, a TODO badge (if set), and an alert bell icon with activity dot. It uses the bottom edge of the window as its bottom border, with left, top, and right borders using the shared terminal top radius from `lib/src/components/design.tsx` — resembling a mouse hole and matching pane rounding. Door dimensions: `min-w-[68px] max-w-[220px] h-6`. diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx new file mode 100644 index 0000000..4f238f1 --- /dev/null +++ b/lib/src/components/MobileTerminalUi.tsx @@ -0,0 +1,355 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type CSSProperties, + type KeyboardEvent, + type ReactNode, +} from 'react'; +import { clsx } from 'clsx'; + +export type MobileTerminalSection = 'recent' | 'type' | 'draft' | 'keys'; + +export const MOBILE_TERMINAL_KEY_SEQUENCES = { + ctrlC: '\x03', + esc: '\x1b', + tab: '\x09', + enter: '\r', + backspace: '\x7f', + up: '\x1b[A', + down: '\x1b[B', + right: '\x1b[C', + left: '\x1b[D', +} as const; + +interface TerminalKey { + id: keyof typeof MOBILE_TERMINAL_KEY_SEQUENCES; + label: string; + title: string; +} + +const TERMINAL_KEYS: TerminalKey[] = [ + { id: 'esc', label: 'Esc', title: 'Escape' }, + { id: 'tab', label: 'Tab', title: 'Tab' }, + { id: 'ctrlC', label: 'Ctrl+C', title: 'Interrupt' }, + { id: 'left', label: '\u2190', title: 'Left arrow' }, + { id: 'down', label: '\u2193', title: 'Down arrow' }, + { id: 'up', label: '\u2191', title: 'Up arrow' }, + { id: 'right', label: '\u2192', title: 'Right arrow' }, +]; + +const NAV_ITEMS: { id: MobileTerminalSection; label: string }[] = [ + { id: 'recent', label: 'Recent' }, + { id: 'type', label: 'Type' }, + { id: 'draft', label: 'Draft' }, + { id: 'keys', label: 'Keys' }, +]; + +type MobileTerminalStyle = CSSProperties & { + '--mobile-terminal-visible-height'?: string; +}; + +export interface MobileTerminalUiProps { + terminal: ReactNode; + activeSection?: MobileTerminalSection; + defaultSection?: MobileTerminalSection; + onSectionChange?: (section: MobileTerminalSection) => void; + onSendInput?: (data: string) => void; + onFocusInput?: () => void; + interactive?: boolean; + fillViewport?: boolean; + className?: string; + terminalClassName?: string; + style?: CSSProperties; +} + +function useVisualViewportHeight(enabled: boolean): number | null { + const [height, setHeight] = useState(null); + + useEffect(() => { + if (!enabled || typeof window === 'undefined') return; + + const update = () => { + setHeight(window.visualViewport?.height ?? window.innerHeight); + }; + + update(); + window.visualViewport?.addEventListener('resize', update); + window.visualViewport?.addEventListener('scroll', update); + window.addEventListener('resize', update); + + return () => { + window.visualViewport?.removeEventListener('resize', update); + window.visualViewport?.removeEventListener('scroll', update); + window.removeEventListener('resize', update); + }; + }, [enabled]); + + return height; +} + +function keyDownSequence(event: KeyboardEvent): string | null { + if (event.ctrlKey && event.key.toLowerCase() === 'c') { + return MOBILE_TERMINAL_KEY_SEQUENCES.ctrlC; + } + + switch (event.key) { + case 'Enter': + return MOBILE_TERMINAL_KEY_SEQUENCES.enter; + case 'Backspace': + return MOBILE_TERMINAL_KEY_SEQUENCES.backspace; + case 'Escape': + return MOBILE_TERMINAL_KEY_SEQUENCES.esc; + case 'Tab': + return MOBILE_TERMINAL_KEY_SEQUENCES.tab; + case 'ArrowUp': + return MOBILE_TERMINAL_KEY_SEQUENCES.up; + case 'ArrowDown': + return MOBILE_TERMINAL_KEY_SEQUENCES.down; + case 'ArrowRight': + return MOBILE_TERMINAL_KEY_SEQUENCES.right; + case 'ArrowLeft': + return MOBILE_TERMINAL_KEY_SEQUENCES.left; + default: + return null; + } +} + +function KeyButton({ + item, + size, + disabled, + onPress, +}: { + item: TerminalKey; + size: 'compact' | 'large'; + disabled: boolean; + onPress: (id: keyof typeof MOBILE_TERMINAL_KEY_SEQUENCES) => void; +}) { + return ( + + ); +} + +function TodoPane({ section }: { section: Exclude }) { + return ( +

+ {section === 'recent' ? ( + <> +

TODO: Recent commands

+

This pane will eventually show recently used commands.

+ + ) : ( + <> +

TODO: Draft

+

This pane will eventually support composing text before sending it to the terminal.

+ + )} +
+ ); +} + +export function MobileTerminalUi({ + terminal, + activeSection, + defaultSection = 'type', + onSectionChange, + onSendInput, + onFocusInput, + interactive = true, + fillViewport = false, + className, + terminalClassName, + style, +}: MobileTerminalUiProps) { + const [internalSection, setInternalSection] = useState(defaultSection); + const section = activeSection ?? internalSection; + const inputRef = useRef(null); + const composingRef = useRef(false); + const [inputValue, setInputValue] = useState(''); + const viewportHeight = useVisualViewportHeight(fillViewport); + + const sendInput = useCallback((data: string) => { + if (!interactive || data.length === 0) return; + onSendInput?.(data); + }, [interactive, onSendInput]); + + const focusInput = useCallback(() => { + if (!interactive) return; + onFocusInput?.(); + inputRef.current?.focus({ preventScroll: true }); + }, [interactive, onFocusInput]); + + const setSection = useCallback((nextSection: MobileTerminalSection) => { + if (activeSection === undefined) setInternalSection(nextSection); + onSectionChange?.(nextSection); + if (nextSection === 'type') { + window.requestAnimationFrame(focusInput); + } + }, [activeSection, focusInput, onSectionChange]); + + const flushInputValue = useCallback((value: string) => { + if (value) sendInput(value); + setInputValue(''); + }, [sendInput]); + + useEffect(() => { + if (section !== 'type' || !interactive) return; + const frame = window.requestAnimationFrame(focusInput); + const delayedFocus = window.setTimeout(focusInput, 120); + const settledFocus = window.setTimeout(focusInput, 500); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(delayedFocus); + window.clearTimeout(settledFocus); + }; + }, [focusInput, interactive, section]); + + const rootStyle: MobileTerminalStyle = { ...style }; + if (fillViewport && viewportHeight !== null) { + rootStyle['--mobile-terminal-visible-height'] = `${viewportHeight}px`; + } + + return ( +
+
+
{terminal}
+
+ + {section === 'type' ? ( +
+ {TERMINAL_KEYS.map((item) => ( + { + sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id]); + focusInput(); + }} + /> + ))} +
+ ) : null} + + {section === 'keys' ? ( +
+
+ {TERMINAL_KEYS.slice(0, 3).map((item) => ( + sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} + /> + ))} +
+
+ {TERMINAL_KEYS.slice(3).map((item) => ( + sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} + /> + ))} +
+
+ ) : null} + + {section === 'recent' || section === 'draft' ? : null} + + + + ``` -### 6.5 Text input behavior - Required behavior: -* Normal characters are sent to the terminal playground. +* Normal characters are sent to the active terminal immediately. * Enter sends terminal Enter. * Backspace works. +* Physical `Ctrl+C` sends `\x03`. * Autocorrect and autocapitalization are disabled where possible. -* Input should support mobile keyboard behavior and IME composition. -* The app should not depend only on `keydown` for text input. - -## 7. Keys pane +* Input supports mobile keyboard behavior and IME composition. +* The app does not depend only on `keydown` for text input. -Keys is the second implemented pane. - -### 7.1 Purpose - -Keys mode provides larger tap targets for the terminal controls that are most important on mobile. - -### 7.2 Layout - -```text -┌───────────────────────┐ -│ Esc Tab Ctrl+C │ -│ ← ↓ ↑ → │ -└───────────────────────┘ -``` - -### 7.3 Controls - -| Button | Behavior | -| ------ | ---------------- | -| Esc | Send Escape | -| Tab | Send Tab | -| Ctrl+C | Send interrupt | -| ← | Send left arrow | -| ↓ | Send down arrow | -| ↑ | Send up arrow | -| → | Send right arrow | - -### 7.4 Interaction rules - -* Tapping a key sends exactly one action. -* The same key mappings should be used in Type mode and Keys mode. -* Keys mode should not add extra controls in v0. -* Long-press repeat is not required for v0. -* Modifier-lock behavior is not required for v0. - -## 8. Recent TODO pane - -### 8.1 Purpose - -Recent is visible in the navigation so the overall app structure can be tested, but it does not implement command history in v0. - -### 8.2 Layout - -```text -TODO: Recent commands +## 8. Terminal Playground Behavior -This pane will eventually show recently used commands. -``` - -### 8.3 Behavior - -* Tapping Recent opens the TODO pane. -* The terminal remains visible above the pane. -* No command list is required. -* No storage is required. - -## 9. Draft TODO pane +A fake shell is acceptable for v0. -### 9.1 Purpose +Minimum useful behavior: -Draft is visible in the navigation so the overall app structure can be tested, but it does not implement scratchpad or dual-pane editing in v0. +* Echo typed characters. +* Maintain a command line buffer. +* Enter submits the current command. +* Backspace edits the current command. +* Arrow keys produce visible behavior. +* Escape and Tab produce visible behavior. +* When a fake full-screen app such as `ascii-splash`, `splash`, `changelog`, or + `tut` is running, `Ctrl+C` sends `\x03` to that app; if the app exits, the + terminal returns to the fake shell prompt instead of restarting the app. +* New panes created from the wall get the same fake shell behavior and prompt as + regular `/playground` panes. -### 9.2 Layout +Example commands: ```text -TODO: Draft - -This pane will eventually support composing text before sending it to the terminal. +help +clear +echo hello +ascii-splash +changelog +tut ``` -### 9.3 Behavior - -* Tapping Draft opens the TODO pane. -* The terminal remains visible above the pane. -* No editable scratchpad is required. -* No copy/paste workflow is required. - -## 10. Key sequence mapping - -Use these mappings for both Type mode and Keys mode. - -| UI action | Sequence | -| ----------- | -------------- | -| Ctrl+C | `\x03` | -| Esc | `\x1B` | -| Tab | `\x09` | -| Enter | `\r` | -| Backspace | Usually `\x7F` | -| Arrow Up | `\x1B[A` | -| Arrow Down | `\x1B[B` | -| Arrow Right | `\x1B[C` | -| Arrow Left | `\x1B[D` | - -If the playground terminal uses a higher-level input API instead of raw terminal sequences, map these actions to the equivalent local action. - -## 11. Keyboard visibility and layout - -Keyboard handling should be simple and pragmatic. - -The prototype should resize the terminal area when the phone keyboard appears. Use the simplest reliable approach available: - -1. Use `window.visualViewport` resize events if available. -2. Fall back to normal viewport sizing. -3. Avoid complex keyboard detection logic unless the layout is broken. - -Minimal approach: +The shell only needs enough behavior to test the mobile controls. -```js -function updateLayoutForKeyboard() { - const viewportHeight = window.visualViewport?.height ?? window.innerHeight; - document.documentElement.style.setProperty( - "--visible-height", - `${viewportHeight}px` - ); -} +## 9. Keyboard Reserve -window.visualViewport?.addEventListener("resize", updateLayoutForKeyboard); -window.visualViewport?.addEventListener("scroll", updateLayoutForKeyboard); -window.addEventListener("resize", updateLayoutForKeyboard); -updateLayoutForKeyboard(); -``` +The keyboard reserve area has a stable height. It should not be recomputed from +`visualViewport` while the native keyboard animates. -The prototype does not need to perfectly detect whether the keyboard is present. It only needs to keep the terminal, active pane, and nav row usable. +When the OS keyboard is hidden, the reserve area shows the selected app keyboard +UI (`Recent - WIP`, Type focus target, `Draft - WIP`, or Keys buttons). -## 12. Touch interactions +When the OS keyboard is visible, the OS keyboard may cover or occupy that same +physical area. This is preferred over resizing the whole app around the keyboard. -Keep touch behavior minimal. +## 10. Touch Interactions Required interactions: -* Tap terminal to focus typing. -* Tap Type to focus typing. -* Tap control buttons. -* Tap bottom navigation items. -* Scroll terminal output. +* Tap keyboard mode selector items. +* Tap touch mode selector items. +* Tap Type reserve area to focus typing. +* Type through the native keyboard. +* Tap key buttons in Keys mode. +* Drag in Gestures mode to send arrow keys. +* Use Text selection mode for terminal selection and copy/paste. +* Use Cursor mode for terminal mouse/cursor input when a TUI requests mouse reporting. + +Pane-content touches must never open the native keyboard. The pane content area +may focus the terminal internally for key routing or mouse handling, but the +mobile wrapper must configure text inputs created by the terminal surface as +non-keyboard targets (`inputmode="none"`, readonly, not tab-reachable) and +immediately blur them when the touch starts there. Since `Wall` may defer xterm +focus to `requestAnimationFrame`, the wrapper must also repeat that blur shortly +after the touch. The only mobile UI surfaces that should open the native +keyboard are the Type selector and the Type reserve area. Not required for v0: -* Swipe navigation. -* Long-press arrow repeat. +* Long-press key repeat. +* Multi-touch gestures. * Trackpad mode. -* Two-finger gestures. -* Terminal mouse mode. -* Custom text selection behavior. +* A full command history UI. +* A real draft editor. -## 13. Copy and paste +## 11. Copy And Paste Keep copy and paste minimal. Prototype behavior: +* Text selection mode should allow the existing terminal selection and copy/paste flows to work. * Let users paste through the native browser/OS paste flow where possible. -* Let terminal output selection rely on default browser behavior where possible. -* No custom clipboard manager is required. +* No custom mobile clipboard manager is required. * No multi-line paste review is required. -## 14. Recommended v0 scope +## 12. Recommended v0 Scope Build exactly this: * One terminal playground screen. * Floating theme switcher using the shared MouseTerm theme picker. -* Bottom navigation row: +* Touch mode selector: ```text -Recent | Type | Draft | Keys +Gestures | Text selection | Cursor ``` -* Type pane with compact controls: +* Keyboard mode selector: ```text -Esc Tab Ctrl+C ← ↓ ↑ → +Recent | Type | Draft | Keys ``` -* Keys pane with larger controls: +* Stable keyboard reserve area. +* Recent reserve content: `Recent - WIP`. +* Draft reserve content: `Draft - WIP`. +* Type mode native mobile keyboard input. +* Keys buttons: ```text -Esc Tab Ctrl+C -← ↓ ↑ → +Esc Tab Space Enter +← ↓ ↑ → ``` -* Recent TODO pane. -* Draft TODO pane. -* Native mobile keyboard input. -* Basic viewport resizing when the keyboard opens. * Simple local playground terminal behavior. -## 15. Prototype success criteria +## 13. Prototype Success Criteria The prototype should answer these questions: -1. Is the terminal viewport usable when the mobile keyboard is open? -2. Is the compact Type control row easy to reach? -3. Is the larger Keys pane necessary or useful? -4. Are arrow keys usable enough for command history and cursor movement? -5. Is Ctrl+C discoverable and easy to trigger? -6. Does the native keyboard feel acceptable for terminal text entry? -7. Does the four-item navigation row make sense, even with Recent and Draft as placeholders? +1. Does the terminal viewport feel stable when the mobile keyboard opens and closes? +2. Is the touch mode selector understandable and reachable? +3. Are gesture arrows usable enough for command history and cursor movement? +4. Is text selection discoverable and reliable on mobile? +5. Is Cursor mode useful when a TUI captures mouse events? +6. Does native keyboard Type mode feel acceptable for terminal text entry? +7. Does the stable keyboard reserve feel better than resizing the whole UI? 8. Is the UI too cramped in portrait orientation? -## 16. Future work +## 14. Future Work Potential later additions: @@ -484,12 +305,10 @@ Potential later additions: * Draft scratchpad. * Dual-pane copy/paste. * Pinned snippets. -* Ctrl+D and Ctrl+Z. +* Ctrl+C, Ctrl+D, and Ctrl+Z app-key buttons. * Alt and modifier behavior. * Home, End, PgUp, PgDn. * Long-press key repeat. -* Gesture navigation. -* Terminal mouse mode. * Remote backend PTY. * SSH sessions. * User accounts. @@ -497,12 +316,12 @@ Potential later additions: * Multi-session support. * Production security model. -## 17. Product principle +## 15. Product Principle The v0 prototype should stay focused: ```text -Type and Keys are real. -Recent and Draft establish the shape of the app. +Touch modes make pane touches explicit. +Keyboard modes make the reserve area explicit. Everything else waits. ``` diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 4f238f1..f07d244 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -4,17 +4,27 @@ import { useRef, useState, type CSSProperties, + type ComponentType, type KeyboardEvent, + type PointerEvent, type ReactNode, } from 'react'; +import { + CursorClickIcon, + CursorTextIcon, + HandPointingIcon, +} from '@phosphor-icons/react'; import { clsx } from 'clsx'; -export type MobileTerminalSection = 'recent' | 'type' | 'draft' | 'keys'; +export type MobileTerminalKeyboardMode = 'recent' | 'type' | 'draft' | 'keys'; +export type MobileTerminalSection = MobileTerminalKeyboardMode; +export type MobileTerminalTouchMode = 'gestures' | 'selection' | 'cursor'; export const MOBILE_TERMINAL_KEY_SEQUENCES = { ctrlC: '\x03', esc: '\x1b', tab: '\x09', + space: ' ', enter: '\r', backspace: '\x7f', up: '\x1b[A', @@ -32,29 +42,44 @@ interface TerminalKey { const TERMINAL_KEYS: TerminalKey[] = [ { id: 'esc', label: 'Esc', title: 'Escape' }, { id: 'tab', label: 'Tab', title: 'Tab' }, - { id: 'ctrlC', label: 'Ctrl+C', title: 'Interrupt' }, + { id: 'space', label: 'Space', title: 'Space' }, + { id: 'enter', label: 'Enter', title: 'Enter' }, { id: 'left', label: '\u2190', title: 'Left arrow' }, { id: 'down', label: '\u2193', title: 'Down arrow' }, { id: 'up', label: '\u2191', title: 'Up arrow' }, { id: 'right', label: '\u2192', title: 'Right arrow' }, ]; -const NAV_ITEMS: { id: MobileTerminalSection; label: string }[] = [ +const KEYBOARD_MODES: { id: MobileTerminalKeyboardMode; label: string }[] = [ { id: 'recent', label: 'Recent' }, { id: 'type', label: 'Type' }, { id: 'draft', label: 'Draft' }, { id: 'keys', label: 'Keys' }, ]; -type MobileTerminalStyle = CSSProperties & { - '--mobile-terminal-visible-height'?: string; -}; +const TOUCH_MODES: Array<{ + id: MobileTerminalTouchMode; + label: string; + title: string; + Icon: ComponentType<{ size?: number; weight?: 'regular' | 'bold' | 'duotone' | 'fill' }>; +}> = [ + { id: 'gestures', label: 'Gestures', title: 'Gestures', Icon: HandPointingIcon }, + { id: 'selection', label: 'Text selection', title: 'Text selection', Icon: CursorTextIcon }, + { id: 'cursor', label: 'Cursor', title: 'Cursor', Icon: CursorClickIcon }, +]; export interface MobileTerminalUiProps { terminal: ReactNode; - activeSection?: MobileTerminalSection; - defaultSection?: MobileTerminalSection; - onSectionChange?: (section: MobileTerminalSection) => void; + activeSection?: MobileTerminalKeyboardMode; + defaultSection?: MobileTerminalKeyboardMode; + onSectionChange?: (section: MobileTerminalKeyboardMode) => void; + activeKeyboardMode?: MobileTerminalKeyboardMode; + defaultKeyboardMode?: MobileTerminalKeyboardMode; + onKeyboardModeChange?: (mode: MobileTerminalKeyboardMode) => void; + activeTouchMode?: MobileTerminalTouchMode; + defaultTouchMode?: MobileTerminalTouchMode; + onTouchModeChange?: (mode: MobileTerminalTouchMode) => void; + cursorTouchAvailable?: boolean; onSendInput?: (data: string) => void; onFocusInput?: () => void; interactive?: boolean; @@ -64,31 +89,6 @@ export interface MobileTerminalUiProps { style?: CSSProperties; } -function useVisualViewportHeight(enabled: boolean): number | null { - const [height, setHeight] = useState(null); - - useEffect(() => { - if (!enabled || typeof window === 'undefined') return; - - const update = () => { - setHeight(window.visualViewport?.height ?? window.innerHeight); - }; - - update(); - window.visualViewport?.addEventListener('resize', update); - window.visualViewport?.addEventListener('scroll', update); - window.addEventListener('resize', update); - - return () => { - window.visualViewport?.removeEventListener('resize', update); - window.visualViewport?.removeEventListener('scroll', update); - window.removeEventListener('resize', update); - }; - }, [enabled]); - - return height; -} - function keyDownSequence(event: KeyboardEvent): string | null { if (event.ctrlKey && event.key.toLowerCase() === 'c') { return MOBILE_TERMINAL_KEY_SEQUENCES.ctrlC; @@ -118,12 +118,10 @@ function keyDownSequence(event: KeyboardEvent): string | nu function KeyButton({ item, - size, disabled, onPress, }: { item: TerminalKey; - size: 'compact' | 'large'; disabled: boolean; onPress: (id: keyof typeof MOBILE_TERMINAL_KEY_SEQUENCES) => void; }) { @@ -138,9 +136,7 @@ function KeyButton({ 'flex min-w-0 items-center justify-center rounded border border-border bg-surface-raised font-mono text-foreground transition-colors', 'hover:bg-header-inactive-bg focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-focus-ring', 'disabled:pointer-events-none disabled:opacity-60', - size === 'compact' - ? 'min-h-10 px-1 text-xs' - : 'min-h-14 px-3 text-base', + 'min-h-14 px-2 text-sm', )} > {item.label} @@ -148,20 +144,85 @@ function KeyButton({ ); } -function TodoPane({ section }: { section: Exclude }) { +function KeyboardModeButton({ + id, + label, + selected, + disabled, + onSelect, +}: { + id: MobileTerminalKeyboardMode; + label: string; + selected: boolean; + disabled: boolean; + onSelect: (mode: MobileTerminalKeyboardMode) => void; +}) { return ( -
- {section === 'recent' ? ( - <> -

TODO: Recent commands

-

This pane will eventually show recently used commands.

- - ) : ( - <> -

TODO: Draft

-

This pane will eventually support composing text before sending it to the terminal.

- + + ); +} + +function TouchModeSelector({ + mode, + cursorAvailable, + disabled, + onSelect, +}: { + mode: MobileTerminalTouchMode; + cursorAvailable: boolean; + disabled: boolean; + onSelect: (mode: MobileTerminalTouchMode) => void; +}) { + return ( +
+ {TOUCH_MODES.map((item) => { + const selected = item.id === mode; + const itemDisabled = disabled || (item.id === 'cursor' && !cursorAvailable); + const Icon = item.Icon; + return ( + + ); + })} +
+ ); +} + +function WorkInProgressPane({ label }: { label: 'Recent' | 'Draft' }) { + return ( +
+ {label} - WIP
); } @@ -171,6 +232,13 @@ export function MobileTerminalUi({ activeSection, defaultSection = 'type', onSectionChange, + activeKeyboardMode, + defaultKeyboardMode, + onKeyboardModeChange, + activeTouchMode, + defaultTouchMode = 'gestures', + onTouchModeChange, + cursorTouchAvailable = false, onSendInput, onFocusInput, interactive = true, @@ -179,12 +247,16 @@ export function MobileTerminalUi({ terminalClassName, style, }: MobileTerminalUiProps) { - const [internalSection, setInternalSection] = useState(defaultSection); - const section = activeSection ?? internalSection; + const resolvedDefaultKeyboardMode = defaultKeyboardMode ?? defaultSection; + const [internalKeyboardMode, setInternalKeyboardMode] = useState(resolvedDefaultKeyboardMode); + const [internalTouchMode, setInternalTouchMode] = useState(defaultTouchMode); + const keyboardMode = activeKeyboardMode ?? activeSection ?? internalKeyboardMode; + const touchMode = activeTouchMode ?? internalTouchMode; + const terminalHostRef = useRef(null); const inputRef = useRef(null); const composingRef = useRef(false); + const gestureStartRef = useRef<{ pointerId: number; x: number; y: number } | null>(null); const [inputValue, setInputValue] = useState(''); - const viewportHeight = useVisualViewportHeight(fillViewport); const sendInput = useCallback((data: string) => { if (!interactive || data.length === 0) return; @@ -197,13 +269,62 @@ export function MobileTerminalUi({ inputRef.current?.focus({ preventScroll: true }); }, [interactive, onFocusInput]); - const setSection = useCallback((nextSection: MobileTerminalSection) => { - if (activeSection === undefined) setInternalSection(nextSection); - onSectionChange?.(nextSection); - if (nextSection === 'type') { - window.requestAnimationFrame(focusInput); + const blurInput = useCallback(() => { + inputRef.current?.blur(); + }, []); + + const configurePaneTextInputs = useCallback(() => { + const host = terminalHostRef.current; + if (!host) return; + for (const input of host.querySelectorAll('input, textarea')) { + if (input.inputMode !== 'none') input.inputMode = 'none'; + if (input.autocomplete !== 'off') input.autocomplete = 'off'; + if (!input.readOnly) input.readOnly = true; + if (input.tabIndex !== -1) input.tabIndex = -1; } - }, [activeSection, focusInput, onSectionChange]); + }, []); + + const blurPaneTextInputs = useCallback(() => { + if (typeof document === 'undefined') return; + const blurActivePaneInput = () => { + configurePaneTextInputs(); + inputRef.current?.blur(); + const active = document.activeElement; + if (!(active instanceof HTMLElement)) return; + if (!terminalHostRef.current?.contains(active)) return; + if ( + active instanceof HTMLInputElement + || active instanceof HTMLTextAreaElement + || active.isContentEditable + ) { + active.blur(); + } + }; + blurActivePaneInput(); + window.setTimeout(blurActivePaneInput, 0); + window.setTimeout(blurActivePaneInput, 50); + window.setTimeout(blurActivePaneInput, 200); + window.requestAnimationFrame(blurActivePaneInput); + }, [configurePaneTextInputs]); + + const setKeyboardMode = useCallback((nextMode: MobileTerminalKeyboardMode) => { + if (activeKeyboardMode === undefined && activeSection === undefined) { + setInternalKeyboardMode(nextMode); + } + onKeyboardModeChange?.(nextMode); + onSectionChange?.(nextMode); + if (nextMode === 'type') { + focusInput(); + } else { + blurInput(); + } + }, [activeKeyboardMode, activeSection, blurInput, focusInput, onKeyboardModeChange, onSectionChange]); + + const setTouchMode = useCallback((nextMode: MobileTerminalTouchMode) => { + if (nextMode === 'cursor' && !cursorTouchAvailable) return; + if (activeTouchMode === undefined) setInternalTouchMode(nextMode); + onTouchModeChange?.(nextMode); + }, [activeTouchMode, cursorTouchAvailable, onTouchModeChange]); const flushInputValue = useCallback((value: string) => { if (value) sendInput(value); @@ -211,7 +332,7 @@ export function MobileTerminalUi({ }, [sendInput]); useEffect(() => { - if (section !== 'type' || !interactive) return; + if (keyboardMode !== 'type' || !interactive) return; const frame = window.requestAnimationFrame(focusInput); const delayedFocus = window.setTimeout(focusInput, 120); const settledFocus = window.setTimeout(focusInput, 500); @@ -220,101 +341,153 @@ export function MobileTerminalUi({ window.clearTimeout(delayedFocus); window.clearTimeout(settledFocus); }; - }, [focusInput, interactive, section]); + }, [focusInput, interactive, keyboardMode]); - const rootStyle: MobileTerminalStyle = { ...style }; - if (fillViewport && viewportHeight !== null) { - rootStyle['--mobile-terminal-visible-height'] = `${viewportHeight}px`; - } + useEffect(() => { + if (touchMode === 'cursor' && !cursorTouchAvailable) { + setTouchMode('gestures'); + } + }, [cursorTouchAvailable, setTouchMode, touchMode]); + + useEffect(() => { + const host = terminalHostRef.current; + if (!host) return; + configurePaneTextInputs(); + const observer = new MutationObserver(configurePaneTextInputs); + observer.observe(host, { + childList: true, + subtree: true, + }); + return () => observer.disconnect(); + }, [configurePaneTextInputs, terminal]); + + const handlePanePointerDownCapture = useCallback((event: PointerEvent) => { + blurPaneTextInputs(); + if (!interactive || touchMode !== 'gestures') return; + if (event.pointerType === 'mouse') return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + gestureStartRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; + }, [interactive, touchMode]); + + const handlePanePointerUpCapture = useCallback((event: PointerEvent) => { + const start = gestureStartRef.current; + if (!start || start.pointerId !== event.pointerId) return; + gestureStartRef.current = null; + event.preventDefault(); + event.stopPropagation(); + + const dx = event.clientX - start.x; + const dy = event.clientY - start.y; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 24) return; + if (Math.abs(dx) > Math.abs(dy)) { + sendInput(dx < 0 ? MOBILE_TERMINAL_KEY_SEQUENCES.left : MOBILE_TERMINAL_KEY_SEQUENCES.right); + } else { + sendInput(dy < 0 ? MOBILE_TERMINAL_KEY_SEQUENCES.up : MOBILE_TERMINAL_KEY_SEQUENCES.down); + } + }, [sendInput]); + + const handlePaneFocusStartCapture = useCallback(() => { + blurPaneTextInputs(); + }, [blurPaneTextInputs]); + + const handlePanePointerCancelCapture = useCallback((event: PointerEvent) => { + if (gestureStartRef.current?.pointerId === event.pointerId) { + gestureStartRef.current = null; + } + }, []); return (
{terminal}
- {section === 'type' ? ( -
- {TERMINAL_KEYS.map((item) => ( - { - sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id]); - focusInput(); - }} - /> - ))} -
- ) : null} - - {section === 'keys' ? ( -
-
- {TERMINAL_KEYS.slice(0, 3).map((item) => ( - sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} - /> - ))} -
-
- {TERMINAL_KEYS.slice(3).map((item) => ( - sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[id])} - /> - ))} -
-
- ) : null} - - {section === 'recent' || section === 'draft' ? : null} - -