Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ A spec is the accurate reference for the current code: it states the invariants
- **`docs/specs/vscode.md`** — VS Code: webview ↔ Workspace mapping, persistence ordering, CSP, the build (update policy, `engines.vscode` floor).
- **`docs/specs/standalone.md`** — Tauri host: the Rust ↔ Node-sidecar bridge, boot sequence, AppBar, persistence, shutdown ordering, the build/dev workflow.
- **`docs/specs/auto-update.md`** — Standalone auto-update: check → approved download → install-on-quit, the Baseboard notice, Windows sidecar teardown, per-platform quit behavior.
- **`docs/specs/mobile-terminal-ui.md`** — The mobile composition (`MobileTerminalUi` / `MobileWall`): stable viewport + keyboard reserve, touch modes, the radial gesture menu; shipped in the Pocket playground and the Pocket app.
- **`docs/specs/mobile-terminal-ui.md`** — The mobile composition (`MobileTerminalUi` / `MobileWall`): full-screen terminal panned above the keyboard, touch modes, the radial gesture menu, the Type composer, the Sessions screen and its Settings; shipped in the Pocket playground and the Pocket app.
- **`docs/specs/tutorial.md`** — Website playground tutorial: device-specific routes, the `tutorial` runner and progress state, desktop and Pocket profiles, the desktop's copy-on-write in-memory filesystem and playground `dor`, the lib hooks for tutorial observability.
- **`docs/specs/website-docs.md`** — Public documentation on the marketing site: the generated references, the Markdown rendering contract they share, the left rail across the docs section, `vscode-ext/README.md` as the canonical guide published off-site, and the lint that pins their links.
- **`docs/specs/webgl-text.md`** — SDF text rendering for the 3D/WebXR effort: the diffplug/xterm.js fork pipeline and its version lockstep, the reserved SDF texel layout, the canopy Storybook lab.
Expand Down
48 changes: 26 additions & 22 deletions docs/specs/mobile-terminal-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

> See `docs/specs/glossary.md` for Session / Pane / Door vocabulary. This spec uses it throughout.

The mobile terminal composition: `MobileTerminalUi` (the wrapper owning touch modes, input modes, and the keyboard reserve) around `MobileWall` (one visible terminal Session at a time, with session switching). **Mobile exposes no split-pane layout and no multiple Workspaces.**
The mobile terminal composition: `MobileTerminalUi` (the wrapper owning touch modes, the Type composer, the Sessions screen, and the keyboard pan) around `MobileWall` (one visible terminal Session at a time, with session switching). **Mobile exposes no split-pane layout and no multiple Workspaces.**

Three consumers compose them: the website Pocket playground (`website/src/components/PocketTerminalExperience.tsx` on `FakePtyAdapter`; `docs/specs/tutorial.md`), the Pocket app (`lib/src/remote/pocket-app/PocketWall.tsx` on `RemotePtyAdapter`; `docs/specs/pocket-app.md`), and Storybook.

## Core layout

The reserve, at the bottom, is a fixed CSS height holding app UI while the OS keyboard is hidden and occupied by the OS keyboard otherwise.
The terminal fills the screen above one bar of buttons at the bottom: the touch modes, then Type.

**Never recompute height from `window.visualViewport`**, so the terminal region does not bounce as the OS keyboard animates (rationale).
**Never resize the terminal for the OS keyboard.** While the composer is open the terminal pans up until its bottom rows sit just above the keyboard, measured from `window.visualViewport`; its rows and columns, and so its PTY size, stay put (rationale).

* Pocket suppresses kill (`showKillButton={false}`). A ring shows as the alarm inset on the header bar and on its session-list row.
* **A tap acknowledges the active Session; a drag never does.** **Select mode's router consumes a touch it owns**, so it announces one that never became a drag as `TERMINAL_TAP_EVENT`.
Expand Down Expand Up @@ -39,13 +39,13 @@ Event routing, by mode (rationale):
* **Gestures / Select** — pane-content `wheel` and `touchmove` are consumed, except Gestures mode's synthesized edge-scroll wheels ([Edge scrolling](#edge-scrolling)).
* **Mouse** — touch events are consumed instead, and primary touch and pen pointers synthesize left-button `mousedown` / `mousemove` / `mouseup` on the element under the pointer. `wheel` still reaches the terminal, and real mouse pointers fall through untouched.

**Never treat a portaled descendant as pane content**: a press outside the host's DOM starts no touch mode, tap, or keyboard dismissal.
**Never treat a portaled descendant or a control as pane content**: a press outside the host's DOM, or on a button or link inside it (`MobileWall`'s header), starts no touch mode or tap, so the control takes its own press.

**Must release a tracked Mouse-mode press on pointerup or cancel even after leaving Mouse mode.** Cancellation releases on the last target.

**Gesture mode also takes primary mouse/trackpad clicks**, which open the radial menu and **must never reach xterm or the pane** for focus, selection, or pane interaction (rationale). **Non-primary mouse buttons are ignored**, so their browser or host behavior continues.

Source of truth: `TOUCH_MODES`, `paneMouseOverride`, and `isPortaledTarget` in `lib/src/components/MobileTerminalUi.tsx`; per-pane wiring in `lib/src/remote/pocket-app/PocketWall.tsx` and `website/src/components/PocketTerminalExperience.tsx`.
Source of truth: `TOUCH_MODES`, `paneMouseOverride`, `isPortaledTarget`, and `isHostControlTarget` in `lib/src/components/MobileTerminalUi.tsx`; per-pane wiring in `lib/src/remote/pocket-app/PocketWall.tsx` and `website/src/components/PocketTerminalExperience.tsx`.

## Edge scrolling

Expand Down Expand Up @@ -79,41 +79,45 @@ Source of truth: `MOBILE_GESTURE_GROUPS` and `MOBILE_GESTURE_QUIT_GROUP` in `lib

Source of truth: `MOBILE_GESTURE_OPTION_DIRECTIONS` in `lib/src/lib/mobile-gesture-menu.ts`.

## Input mode selector
## Type

The input mode selector controls what appears in the reserve area.
Type opens the composer at the top of the visible screen: a text field, then a Password checkbox, Cancel, and Send.

| Mode | Reserve area content |
| --- | --- |
| Sessions | Session rows with active, alert, and TODO state; selecting one makes it the single visible terminal. |
| Recent | Placeholder ([Future](#future)). |
| Type | A button focusing the hidden terminal input — the way back from a dismissed keyboard. Typed keys echo into the terminal as they happen. |
| Draft | Placeholder ([Future](#future)). |
**Mobile never sends a soft keyboard's keystrokes as they are typed**; text reaches the terminal only by Send (rationale).

**Must focus the hidden input synchronously inside the Type selector's tap/click handler** (rationale); a follow-up effect retries as best effort. **Switching away from Type blurs the hidden input**, including consumer-controlled switches.
* **Must focus the composer's field synchronously inside the Type tap** (rationale).
* **Send writes the text through the consumer's paste path, then Enter** (rationale).
* **A password is masked and written as input, never through the paste path.**
* Send and Cancel clear the Password checkbox. Send clears the text; any other close keeps it for the next Type, unless a password.

Source of truth: `KEYBOARD_MODES` in `lib/src/components/MobileTerminalUi.tsx`.
Source of truth: `MobileTerminalUi` and `Composer` in `lib/src/components/MobileTerminalUi.tsx`.

## Type mode input
## Physical keyboard

Typing goes through a visually hidden `<textarea>` configured for terminal-style input.
**While the composer is closed and the terminal is showing, a physical keyboard types straight into the terminal.** **Must leave to the page** a key aimed at a focused field or control, a Command or Option chord, and IME composition. A soft keyboard opens only for a focused field, so it never reaches this path.

* **Must buffer composed text until `compositionend` and leave composing keydowns to the IME**, including its editing and confirmation keys.
* **Must handle software-keyboard Enter and Backspace through `beforeinput` when no `keydown` occurs**, including deletion from the empty hidden input.
Source of truth: `hardwareKeySequence` and `isHardwareKeyTarget` in `lib/src/components/MobileTerminalUi.tsx`.

Source of truth: `MobileTerminalUi` in `lib/src/components/MobileTerminalUi.tsx`; `isComposingKey` in `lib/src/lib/dom.ts`.
## Sessions screen

Tapping the Session title in the `MobileWall` header asks the consumer to show the Sessions screen over the whole composition; the terminal stays mounted beneath at its size. Choosing a row shows that Session. A consumer's `settings` (`MobileSettings`: theme, and font size over a sample) open from the screen's Settings button.

* **The font size applies to every terminal in the page, live ones refit**, so a remote Session's PTY resizes with it; **must hand it back to the theme when the composition unmounts**.
* **Must save Settings choices only when the consumer persists them**; the one-time page does not (`docs/specs/one-time.md` -> "Phone page").

Source of truth: `SessionsScreen` in `lib/src/components/MobileTerminalUi.tsx`; `MobileSettings` in `lib/src/components/MobileSettings.tsx`; `useMobileFontSize` in `lib/src/lib/mobile-font-size.ts`; `setTerminalFontSize` in `lib/src/lib/terminal-lifecycle.ts`.

## Keyboard focus invariant

**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 every text input the terminal surface creates — later-mounted ones included — is a non-keyboard target, blurred when the touch starts there; **that blur repeats across the window in which `Wall` can restore focus** (rationale). **The only mobile UI surfaces that may open the native keyboard are the Type selector and the Type reserve area.**
**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 every text input the terminal surface creates — later-mounted ones included — is a non-keyboard target, blurred when the touch starts there; **that blur repeats across the window in which `Wall` can restore focus** (rationale). **The only mobile UI surface that may open the native keyboard is the composer.**

Source of truth: `MobileTerminalUi` in `lib/src/components/MobileTerminalUi.tsx` and `MobileWall` in `lib/src/components/MobileWall.tsx`; pinned by `lib/src/components/MobileTerminalUi.test.tsx` and `lib/src/components/MobileWall.test.tsx`.

## Future

**Scope: mobile-backlog** — unprioritized:

* Real recent commands and a Draft scratchpad (both reserves are placeholder copy today).
* Recent commands in the composer.
* Dual-pane copy/paste.
* Pinned snippets.
* Ctrl+D and Ctrl+Z app-key buttons.
Expand Down
10 changes: 6 additions & 4 deletions docs/specs/mobile-terminal-ui.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Core layout

**Fixed reserve height rather than a `visualViewport` measurement.** `window.visualViewport` shrinks and grows for the whole length of the OS keyboard's open/close animation, so a height derived from it bounces the terminal region and reflows xterm mid-gesture. The trade — the keyboard covering the same physical area — is the cheaper cost, since what it hides is the app keyboard UI the OS keyboard is replacing anyway.
**Pan, not resize.** Shrinking the terminal to the space above the keyboard changes its rows, so the PTY resizes and a full-screen TUI (Claude Code, vim) redraws on every keyboard open and close; on a remote Session the laptop's view reflows too. Panning keeps the grid and only moves it, so the rows hidden are the top ones, under the composer. The pan follows `visualViewport` `resize` and `scroll`, which fire through the keyboard's animation; that motion is what the earlier fixed-height keyboard reserve avoided, and it moves the terminal rather than reflowing it. Not yet measured on a real iPhone (2026-10); if Safari fights the pan, the fallback is the resize.

## Touch mode selector

Expand All @@ -16,11 +16,13 @@

The edge gesture routes to either terminal history or a mouse-reporting TUI, neither of which is a browser scroll container. Its coast therefore approximates native scrolling rather than delegating to an overflow element. The exponential decay uses iOS's normal 0.998 rate, documented by [React Native](https://reactnative.dev/docs/scrollview#decelerationrate) (consulted 2026-09). Elapsed-time integration avoids refresh-rate-dependent travel. Recent directional samples avoid stale speed after reversals; release time includes a brief pause, while a longer hold suppresses the coast. Whole terminal lines preclude native subpixel motion and rubber-band bounce; the launch cap bounds wheel reports to a remote TUI.

## Input mode selector
## Type

**Why the Type focus is synchronous.** Mobile browsers open the native keyboard only for a focus call made while the user gesture is still on the stack; one deferred to `requestAnimationFrame` or a timer may be refused as not user-initiated, so the tap appears to do nothing.
**Why no live keystrokes.** A soft keyboard's autocorrect and word prediction act on the text of the field it types into. Sent key by key into an empty hidden field, as the earlier Type mode did, those keystrokes arrived without that help and with corrections replayed as deletions and retypes, which made them unreliable in practice (2026-10). A composed field keeps the keyboard's accuracy.

**What the follow-up effect buys.** It re-asserts focus after re-renders that would drop it, and is the only focus path when Type is the initial mode and no tap has happened yet. Strict browsers can still keep the keyboard closed until the first real tap — best effort, not the contract.
**Why the focus is synchronous.** Mobile browsers open the native keyboard only for a focus call made while the user gesture is still on the stack; one deferred to `requestAnimationFrame` or a timer may be refused as not user-initiated, so the tap appears to do nothing. The composer is committed with `flushSync` so its field exists to focus in the same handler, and the Password checkbox re-focuses the swapped field the same way.

**Why the paste path.** Apps that enable bracketed paste (Claude Code, zsh) also treat fast multi-character input as pasted, so text and Enter written together would leave the Enter inside the paste as a newline. Sent as a bracketed paste and then a separate Enter, the line submits; multi-line text stays one entry. A password skips it because a password prompt reads raw input.

## Keyboard focus invariant

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/pocket-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,4 @@ Source of truth: `pocketContentSecurityPolicy` in `remote-lib-common/src/remote/
**Scope: pocket-polish**

1. **Dedupe the composition** — the website's `PocketTerminalExperience` and the Pocket shell (`PocketWall.tsx`) each wire `MobileTerminalUi` + `MobileWall` independently; extract the shared wiring so they cannot drift.
2. **Theme picker in Pocket** — the app restores the persisted theme but exposes no picker; add the shared `ThemePicker` (and its theme-debugger entry) once its dropdown is phone-friendly.
2. **Theme debugger in Pocket** — the Settings page ([mobile-terminal-ui.md](./mobile-terminal-ui.md) → "Sessions screen") picks a theme but offers no theme-debugger entry.
2 changes: 1 addition & 1 deletion docs/specs/theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Source of truth: `DormouseTheme` in `lib/src/lib/themes/types.ts`; build importe

## Where the user picks a theme

**Every host that lets the user pick a theme does it in the Settings dialog**; **host chrome — the standalone titlebar, the website playground navbar — carries none**. Pages with no Settings dialog — the `/playground/pocket` mounts and the docs pages — use the free-floating `compact` picker (rationale).
**Every host that lets the user pick a theme does it in the Settings dialog**, or on the mobile composition's Settings page (`docs/specs/mobile-terminal-ui.md` -> "Sessions screen"); **host chrome — the standalone titlebar, the website playground navbar — carries none**. Pages with neither — the desktop `/playground/pocket` mount and the docs pages — use the free-floating `compact` picker (rationale).

- **VS Code offers none at all** (rationale). `VSCodeAdapter` sets the optional `hostOwnsTheme` capability and the dialog hides its Theme row (`docs/specs/transport.md` → Adapter model).
- **Only the website offers installing a theme from OpenVSX**, a fetch from the page that no network policy governs (`docs/specs/remote-network.md` -> "Policy"). **Never offer it in Standalone**, whose content policy blocks open-vsx.org.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/theme.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

**Why VS Code gets no picker.** VS Code supplies `--vscode-*` from its own active theme, so its built-in theme UI is the only control that can change what Dormouse renders there.

**Why `/playground/pocket` keeps the `compact` picker.** Those two mounts render a mobile prototype: no baseboard, so no Settings dialog to put the picker in.
**Why the desktop `/playground/pocket` keeps the `compact` picker.** It renders a mobile prototype in a marketing page: no baseboard, so no Settings dialog to put the picker in, and its phone mockup is not interactive. The phone mount picks on the mobile Settings page instead.

**Why the host fallback is module state.** Uninstalling the active theme is reachable from two depths — the picker row's `X` and the store dialog's `Remove` — and a prop-held fallback goes missing on one, dropping to the first bundled theme instead of the host's.

Expand Down
Loading
Loading