diff --git a/docs/superpowers/plans/2026-08-08-mobile-flash-and-keybar.md b/docs/superpowers/plans/2026-08-08-mobile-flash-and-keybar.md new file mode 100644 index 0000000..5eb9716 --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-mobile-flash-and-keybar.md @@ -0,0 +1,929 @@ +# Mobile Flash Fix + Key Bar Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Kill the both-axes shrink flash when the phone keyboard opens, and give touch devices an on-screen key bar (Esc, Tab, sticky Ctrl, mode-aware arrows) so TUIs like Claude Code are usable from a phone. + +**Architecture:** (1) `relayout` in the terminal view currently scales the whole surface down whenever the pty stops fitting in either axis; when only *rows* stop fitting — exactly what a keyboard sliding over the pane does — it will now keep the surface one-to-one and let the bottom rows clip for the settle window, so nothing moves horizontally. (2) A new `lib/keys.ts` owns escape-sequence encoding (CSI vs SS3 arrows per DECCKM, Ctrl chords, sticky-Ctrl byte transform); a dumb `KeyBar` component renders chips inside the terminal pane on coarse-pointer devices; the Terminal effect wires presses into the same guarded input path keystrokes use. The emulator seam gains one method, `applicationCursorKeys()`, so arrows follow the mode the running program set. + +**Tech Stack:** React + xterm.js (v6, `term.modes.applicationCursorKeysMode`) + vitest/jsdom in `web/`. + +## Global Constraints + +- Tailwind scans **prose** in every file under `web/` outside `*.test.*`, `src/testing/`, `src/client/` and `*.go` — a single English word that is also a utility name (even in a comment; even, for some words, as a bare identifier — `transform` was measured to leak) can compile a stray CSS rule and fail `web/src/styles.build.test.ts`. New scanned files in this plan: `web/src/lib/keys.ts`, `web/src/components/key-bar.tsx`. Known-dangerous words to keep out of their prose: `resize` (quoted), `transform`, `shrink`, `blur`, `collapse`, `grid`, `hidden`, `fixed`, `static`, `table`, `container`, `filter`, `outline`, `rounded`, `running`, `underline`, `truncate`, `visible`, `inline`, `isolate`. The guard measures build output, so after any edit to scanned sources run the full web suite (`cd web && npx vitest run` — its `styles.build.test.ts` does a real vite build, minutes; be patient) and reword whatever leaks. +- Test commands: `cd web && npx vitest run src/components/terminal.test.tsx` (or another file) focused; `cd web && npx vitest run` full. +- If a `web/package-lock.json` appears after npx runs, delete it; never stage it. +- Never edit `web/dist/`. +- Commit style: conventional prefix, body explains why, matching repo history. +- Base branch: `main`. + +--- + +### Task 1: Clip instead of scale when only rows stop fitting + +Today `relayout` (web/src/components/terminal.tsx) takes the one-to-one branch only when `want.cols >= dims.cols && want.rows >= dims.rows`, and otherwise scales the whole surface by a uniform factor. When the phone keyboard opens, the pane loses half its height for the ~150ms settle window before the pty follows, and the uniform factor pinches *both* axes — the reported "flashes to half horizontally". Fix: the one-to-one branch triggers whenever every **column** fits; rows overflowing alone means the bottom rows clip briefly (the pane has `overflow-hidden`), which the keyboard animation covers. + +**Files:** +- Modify: `web/src/components/terminal.tsx` (the two-branch layout at the end of `relayout`, currently `if (want.cols >= dims.cols && want.rows >= dims.rows) {`) +- Test: `web/src/components/terminal.test.tsx` (inside `describe('the sizing policy', ...)`) + +**Interfaces:** +- Consumes: existing test harness — `paneOf`, `resizeObservers`, `mountTerminal`, `attached`, `surfaceEl`, `GUTTER_PX`. +- Produces: nothing later tasks rely on. + +- [ ] **Step 1: Write the failing test** + +Add inside `describe('the sizing policy', ...)` in `web/src/components/terminal.test.tsx`: + +```tsx + it('stays one-to-one when only rows stop fitting, so a keyboard cannot pinch the width', async () => { + const observers = resizeObservers() + const box = paneOf(800 + GUTTER_PX, 408) + const { sock, em } = mountTerminal((e) => ( + + )) + // 80x24 rendered at 800x408 puts a cell at 10 x 17. + em.live().measured = { width: 800, height: 408 } + act(() => sock.emitControl(attached({ ref: 1, id: 's1', cols: 80, rows: 24, primary: true }))) + + // The keyboard takes half the pane's height: same columns, half the rows. + box.mockReturnValue({ width: 800 + GUTTER_PX, height: 204 } as DOMRect) + act(() => observers.fire()) + + // The settled report proves the whole relayout → settle path ran… + await waitFor(() => + expect(sock.ofType('resize')).toContainEqual({ + type: 'resize', + ref: 1, + cols: 80, + rows: 12, + primary: true, + }), + ) + // …and through all of it the surface was never scaled or resized: the + // bottom rows clip behind the keyboard until the pty follows, and the + // width never moves. + expect(surfaceEl().style.scale).toBe('') + expect(surfaceEl().style.width).toBe('') + expect(surfaceEl().style.height).toBe('') + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'only rows stop fitting'` +Expected: FAIL — under the current branch condition the rows-only overflow takes the scaling path, so `surface.style.scale` is a number, not `''`. + +- [ ] **Step 3: Change the branch condition** + +In `relayout` in `web/src/components/terminal.tsx`, replace the first branch's condition and comment (currently "The pty fits this pane — whatever view set the size, this one can show it whole — so the surface simply fills the pane and nothing is transformed."): + +```ts + if (want.cols >= dims.cols) { + // Every column fits, so the surface lays out one-to-one whatever the + // row count says. Rows overflowing alone is almost always this view's + // own keyboard sliding over the pane: for the settle window the bottom + // rows clip behind it and then the pty takes the new height. Scaling + // here instead used to pinch both axes for that window — a lurch on + // every keyboard open. The cost is deliberate: a view whose columns + // fit while its rows do not shows the top of the screen until the pty + // follows, and in the rare enduring cross-device shape of that kind, + // scrollback still reaches what the pane cannot. + surface.style.removeProperty('width') + surface.style.removeProperty('height') + surface.style.removeProperty('scale') + return + } + + // Columns overflow: a wider view is setting the size, and columns + // clipping would amputate lines mid-word. Lay the surface out at the + // screen's true size and scale the whole thing down, rather than + // reflowing text. +``` + +Keep the rest of the scaling branch (the gutter comment and the three style assignments) unchanged. + +- [ ] **Step 4: Run test to verify it passes, then the file, then the full suite** + +Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'only rows stop fitting'` → PASS. +Run: `cd web && npx vitest run src/components/terminal.test.tsx` → PASS (the existing `scales its surface to the larger view…` test still passes: its overflow is in columns). +Run: `cd web && npx vitest run` → PASS, including `styles.build.test.ts` (the rewritten comment is scanned prose — if the guard names a leaked word, reword the comment and re-run). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/components/terminal.tsx web/src/components/terminal.test.tsx +git commit -m "fix(web): keep the width still while the keyboard settles + +Rows-only overflow now renders one-to-one with the bottom rows briefly +clipped instead of scaling the whole surface down: the uniform factor +pinched both axes for the settle window, a visible lurch on every +keyboard open. Scaling remains for column overflow, where clipping +would amputate lines." +``` + +--- + +### Task 2: Key sequences library + +Pure functions for what the bar sends. Arrows are mode-aware — a full-screen program that sets DECCKM (vim, less, Claude Code's TUI) expects SS3 (`ESC O A`), a shell expects CSI (`ESC [ A`) — and Ctrl-chorded arrows are `ESC [ 1 ; 5 X` regardless of mode, which is what xterm itself emits. Sticky Ctrl over typed text is a byte transform: `c` → 0x03. + +**Files:** +- Create: `web/src/lib/keys.ts` +- Test: `web/src/lib/keys.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces (Task 3 relies on these exact names): `type BarKey = 'esc' | 'tab' | 'up' | 'down' | 'left' | 'right'`; `barKeyBytes(key: BarKey, opts: { appCursor: boolean; ctrl: boolean }): Uint8Array`; `ctrlTransform(bytes: Uint8Array): Uint8Array | null`. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/lib/keys.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { barKeyBytes, ctrlTransform } from './keys' + +const text = (b: Uint8Array) => new TextDecoder().decode(b) + +describe('barKeyBytes', () => { + it('encodes arrows as CSI when the program has not asked for more', () => { + expect(text(barKeyBytes('up', { appCursor: false, ctrl: false }))).toBe('\x1b[A') + expect(text(barKeyBytes('down', { appCursor: false, ctrl: false }))).toBe('\x1b[B') + expect(text(barKeyBytes('right', { appCursor: false, ctrl: false }))).toBe('\x1b[C') + expect(text(barKeyBytes('left', { appCursor: false, ctrl: false }))).toBe('\x1b[D') + }) + + it('switches arrows to SS3 under application cursor keys', () => { + expect(text(barKeyBytes('up', { appCursor: true, ctrl: false }))).toBe('\x1bOA') + expect(text(barKeyBytes('left', { appCursor: true, ctrl: false }))).toBe('\x1bOD') + }) + + it('encodes Ctrl-arrows as modified CSI, whatever the cursor mode', () => { + // xterm sends CSI 1;5 for ctrl-arrows even in application mode. + expect(text(barKeyBytes('up', { appCursor: false, ctrl: true }))).toBe('\x1b[1;5A') + expect(text(barKeyBytes('right', { appCursor: true, ctrl: true }))).toBe('\x1b[1;5C') + }) + + it('sends esc and tab as their single bytes, ctrl or not', () => { + expect(text(barKeyBytes('esc', { appCursor: false, ctrl: false }))).toBe('\x1b') + expect(text(barKeyBytes('tab', { appCursor: true, ctrl: true }))).toBe('\x09') + }) +}) + +describe('ctrlTransform', () => { + const of = (...b: number[]) => Uint8Array.from(b) + + it('folds letters onto control codes, either case', () => { + expect(ctrlTransform(of(0x63))).toEqual(of(0x03)) // c → ETX (Ctrl+C) + expect(ctrlTransform(of(0x43))).toEqual(of(0x03)) // C too + expect(ctrlTransform(of(0x64))).toEqual(of(0x04)) // d → EOT + }) + + it('covers the punctuation controls a terminal actually uses', () => { + expect(ctrlTransform(of(0x5b))).toEqual(of(0x1b)) // [ → ESC + expect(ctrlTransform(of(0x20))).toEqual(of(0x00)) // space → NUL + expect(ctrlTransform(of(0x3f))).toEqual(of(0x7f)) // ? → DEL + }) + + it('declines anything it cannot fold', () => { + expect(ctrlTransform(of(0x31))).toBeNull() // digit + expect(ctrlTransform(new TextEncoder().encode('é'))).toBeNull() // multi-byte + expect(ctrlTransform(new TextEncoder().encode('ls'))).toBeNull() // paste + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/lib/keys.test.ts` +Expected: FAIL — `./keys` does not exist. + +- [ ] **Step 3: Implement `web/src/lib/keys.ts`** + +```ts +/** The keys the on-screen bar offers. Ctrl is a modifier, not a key here. */ +export type BarKey = 'esc' | 'tab' | 'up' | 'down' | 'left' | 'right' + +const encoder = new TextEncoder() + +/** VT arrow finals: CSI/SS3 A B C D are up, down, right, left — in that order. */ +const ARROW_FINAL = { up: 'A', down: 'B', right: 'C', left: 'D' } as const + +/** + * The bytes a bar key sends. + * + * Arrows follow DECCKM — CSI for a shell, SS3 once a full-screen program has + * asked for application cursor keys — because a bar that always sent CSI + * would move the cursor in vim and type `A` in less. Ctrl-arrows are the + * modified CSI form whatever the mode, which is what xterm itself emits. + * Esc and tab are single bytes with no Ctrl form worth sending. + */ +export function barKeyBytes(key: BarKey, opts: { appCursor: boolean; ctrl: boolean }): Uint8Array { + if (key === 'esc') return encoder.encode('\x1b') + if (key === 'tab') return encoder.encode('\x09') + const fin = ARROW_FINAL[key] + if (opts.ctrl) return encoder.encode(`\x1b[1;5${fin}`) + return encoder.encode(opts.appCursor ? `\x1bO${fin}` : `\x1b[${fin}`) +} + +/** + * Fold one typed key onto its control code, for the bar's sticky Ctrl. + * + * Touch keyboards carry no Ctrl, so the bar arms one and the next keystroke + * lands here. Null means "not foldable" — a digit, a paste, a multi-byte + * character — and the caller sends the bytes untouched; the arming is spent + * either way, like a real sticky modifier. + */ +export function ctrlTransform(bytes: Uint8Array): Uint8Array | null { + if (bytes.length !== 1) return null + const b = bytes[0]! + if (b === 0x20) return Uint8Array.of(0x00) // Ctrl+Space + if (b === 0x3f) return Uint8Array.of(0x7f) // Ctrl+? + if (b >= 0x61 && b <= 0x7a) return Uint8Array.of(b & 0x1f) // a-z + if (b >= 0x40 && b <= 0x5f) return Uint8Array.of(b & 0x1f) // @, A-Z, [ \ ] ^ _ + return null +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && npx vitest run src/lib/keys.test.ts` +Expected: PASS (11 assertions across 7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/lib/keys.ts web/src/lib/keys.test.ts +git commit -m "feat(web): encode what an on-screen key bar sends + +Arrows follow DECCKM (CSI for a shell, SS3 for vim and friends), +Ctrl-arrows are the modified CSI form either way, and a sticky Ctrl +folds the next typed byte onto its control code. Pure functions ahead +of the bar that will press them." +``` + +--- + +### Task 3: The key bar — seam method, component, wiring + +Three pieces: the emulator seam gains `applicationCursorKeys()` (xterm: `term.modes.applicationCursorKeysMode`; fake: a settable field); a dumb `KeyBar` component renders the chips; the Terminal component shows it on coarse-pointer devices, reserves bottom room for it inside the inset, and routes presses and the sticky Ctrl through the same ref/mute-guarded path typed input uses. + +**Files:** +- Modify: `web/src/emulator/types.ts` (add one method to `Emulator`) +- Modify: `web/src/emulator/xterm.ts` (implement it) +- Modify: `web/src/testing/emulator.ts` (fake: `appCursor` field + method) +- Create: `web/src/components/key-bar.tsx` +- Modify: `web/src/components/terminal.tsx` (state, onData transform, `sendKey` action, render) +- Test: `web/src/components/terminal.test.tsx` (new `describe('the key bar', ...)`) + +**Interfaces:** +- Consumes: `BarKey`, `barKeyBytes`, `ctrlTransform` from `@/lib/keys` (Task 2, exact signatures there). +- Produces: `Emulator.applicationCursorKeys(): boolean`; `KeyBar(props: { ctrl: boolean; onCtrl: () => void; onKey: (key: BarKey) => void })`; `FakeEmulator.appCursor: boolean`. + +- [ ] **Step 1: Write the failing tests** + +Add to `web/src/components/terminal.test.tsx` (top-level import: add `fireEvent` is already imported; nothing new needed): + +```tsx + describe('the key bar', () => { + /** jsdom has no matchMedia; a coarse pointer is claimed explicitly. */ + function coarsePointer() { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: query.includes('coarse'), + addEventListener: () => {}, + removeEventListener: () => {}, + })) + } + const bar = () => document.querySelector('[data-flue-keybar]') + const key = (label: string) => + Array.from(document.querySelectorAll('[data-flue-keybar] button')).find( + (b) => b.textContent === label, + )! + + it('exists only for touch', () => { + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + expect(bar()).toBeNull() + }) + + it('sends CSI arrows for a shell and SS3 once the program asks', () => { + coarsePointer() + const { sock, em } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('↑')) + expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[A' }]) + + em.live().appCursor = true + fireEvent.pointerDown(key('↓')) + expect(sock.input()).toEqual([ + { ref: 1, text: '\x1b[A' }, + { ref: 1, text: '\x1bOB' }, + ]) + }) + + it('arms Ctrl for exactly one following keystroke', () => { + coarsePointer() + const { sock, em } = mountTerminal((e) => ( + + )) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('ctrl')) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('true') + act(() => em.live().send('c')) + act(() => em.live().send('c')) + expect(sock.input()).toEqual([ + { ref: 1, text: '\x03' }, + { ref: 1, text: 'c' }, + ]) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') + }) + + it('chords Ctrl with an arrow', () => { + coarsePointer() + const { sock } = mountTerminal((e) => ) + act(() => sock.emitControl(attached({ ref: 1, id: 's1' }))) + + fireEvent.pointerDown(key('ctrl')) + fireEvent.pointerDown(key('→')) + expect(sock.input()).toEqual([{ ref: 1, text: '\x1b[1;5C' }]) + expect(key('ctrl').getAttribute('aria-pressed')).toBe('false') + }) + + it('drops bar keys pressed before the attach comes back', () => { + coarsePointer() + const { sock } = mountTerminal((e) => ) + fireEvent.pointerDown(key('esc')) + expect(sock.input()).toEqual([]) + }) + + it('reserves bottom room in the inset so the bar covers no rows', () => { + coarsePointer() + mountTerminal((e) => ) + expect(inset().className).toContain('bottom-16') + }) + }) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'key bar'` +Expected: FAIL — no `[data-flue-keybar]` renders (and `appCursor` does not exist on the fake yet). The `exists only for touch` case may pass vacuously; the rest must fail. + +- [ ] **Step 3: Add the seam method** + +`web/src/emulator/types.ts`, append to the `Emulator` interface (after `answerQueries`); also amend the interface's opening doc comment ("talks to these ten methods") to not count methods — reword that clause to "talks to this interface", since the count is now wrong either way: + +```ts + /** + * Whether the program has asked for application cursor keys (DECCKM). + * + * The on-screen key bar synthesises arrow presses, and an arrow's encoding + * is the program's choice, not the bar's: CSI moves history at a shell, + * SS3 is what vim and friends expect once they have set the mode. Reading + * it here keeps the bar as mode-honest as a hardware keyboard through + * xterm would be. + */ + applicationCursorKeys(): boolean +``` + +`web/src/emulator/xterm.ts`, inside the returned emulator object (beside `answerQueries`): + +```ts + applicationCursorKeys: () => term.modes.applicationCursorKeysMode, +``` + +`web/src/testing/emulator.ts`: add `appCursor: boolean` to the `FakeEmulator` interface with the doc `/** What applicationCursorKeys() reports; set by hand like measured. */`, initialise `appCursor: false` in the object literal, and add the method: + +```ts + applicationCursorKeys: () => self.appCursor, +``` + +- [ ] **Step 4: Create `web/src/components/key-bar.tsx`** + +```tsx +import type { BarKey } from '@/lib/keys' +import { cn } from '@/lib/utils' + +const KEYS: ReadonlyArray<{ key: BarKey; label: string; name: string }> = [ + { key: 'esc', label: 'esc', name: 'Escape' }, + { key: 'tab', label: 'tab', name: 'Tab' }, + { key: 'left', label: '←', name: 'Arrow left' }, + { key: 'down', label: '↓', name: 'Arrow down' }, + { key: 'up', label: '↑', name: 'Arrow up' }, + { key: 'right', label: '→', name: 'Arrow right' }, +] + +/** + * The touch device's missing keys, floated over the terminal's bottom edge. + * + * Presses land on pointerdown, and the handler prevents the default so the + * press never takes focus from xterm's textarea — losing it would close the + * very keyboard the bar exists to work beside. Ctrl is sticky: one press + * arms it for the next key, bar or typed, and the Terminal owns that state + * because the fold happens on the input path, not here. + */ +export function KeyBar(props: { + ctrl: boolean + onCtrl: () => void + onKey: (key: BarKey) => void +}) { + const chip = 'rounded-md px-2.5 py-1.5 font-mono text-sm/4 transition-colors select-none' + return ( +
+ + {KEYS.map((k) => ( + + ))} +
+ ) +} +``` + +(Note the `label` and the visible text are what the tests select on: `esc`, `tab`, `ctrl`, `←`, `↓`, `↑`, `→`. The `sr-only` span rides inside the button, so `textContent` for arrows is `'↑Arrow up'` — **therefore the test's `key()` helper must match with `startsWith`, not equality.** Use `b.textContent?.startsWith(label)` in Step 1's helper; this note wins over the equality form if you wrote that first.) + +- [ ] **Step 5: Wire into Terminal** + +In `web/src/components/terminal.tsx`: + +Imports: + +```ts +import { KeyBar } from '@/components/key-bar' +import { barKeyBytes, ctrlTransform, type BarKey } from '@/lib/keys' +``` + +State, beside the existing `mode` state: + +```ts + // Coarse pointer once per mount: whether this device's primary pointer is a + // finger decides the key bar's existence, and a pointer does not change + // class mid-session in any way worth re-rendering for. + const [coarse] = useState(() => globalThis.matchMedia?.('(pointer: coarse)')?.matches ?? false) + // The sticky Ctrl: state for the chip's pressed look, a ref for the input + // path, which lives inside the effect and must read it without re-running. + const [ctrlArmed, setCtrlArmed] = useState(false) + const ctrlArmedRef = useRef(ctrlArmed) + ctrlArmedRef.current = ctrlArmed +``` + +Replace the `emulator.onData` registration body: + +```ts + emulator.onData((bytes) => { + // No ref, no destination — and no input while the backlog replays. + if (ref === null || consumed < muteUntil) return + let out = bytes + if (ctrlArmedRef.current) { + // The bar's sticky Ctrl folds this keystroke, and is spent on it + // whether or not it could fold — like a real sticky modifier. + out = ctrlTransform(bytes) ?? bytes + setCtrlArmed(false) + } + client.sendInput(ref, out) + }) +``` + +Extend the `actionsRef` type and object with `sendKey` (type field: `sendKey: (key: BarKey) => void`; object, beside `restart` and `applyTheme`): + +```ts + sendKey: (key) => { + if (ref === null || consumed < muteUntil) return + const bytes = barKeyBytes(key, { + appCursor: emulator.applicationCursorKeys(), + ctrl: ctrlArmedRef.current, + }) + if (ctrlArmedRef.current) setCtrlArmed(false) + client.sendInput(ref, bytes) + }, +``` + +Render: the inset div's `className` gains bottom room when the bar exists — + +```tsx + className={cn( + 'absolute inset-3 transition-opacity', + coarse && 'bottom-16', + phase === 'exited' && 'opacity-60', + )} +``` + +— and after the inset div's closing tag (before the top-right controls block), render the bar: + +```tsx + {coarse && ( + setCtrlArmed((v) => !v)} + onKey={(k) => actionsRef.current?.sendKey(k)} + /> + )} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd web && npx vitest run src/components/terminal.test.tsx` +Expected: PASS, all describes — the pre-existing tests never stub `matchMedia`, so `coarse` is false and the bar changes nothing for them. + +- [ ] **Step 7: Full suite (scanner guard included)** + +Run: `cd web && npx vitest run` +Expected: PASS. `key-bar.tsx` and the `terminal.tsx` edits are scanned prose + markup; the class strings here are all hyphenated or var-referencing (safe shapes), and if `styles.build.test.ts` names a leaked bare word from a comment, reword and re-run. Also `tsc --noEmit` via `npm run lint`. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/emulator/types.ts web/src/emulator/xterm.ts web/src/testing/emulator.ts web/src/components/key-bar.tsx web/src/components/terminal.tsx web/src/components/terminal.test.tsx +git commit -m "feat(web): an on-screen key bar for touch devices + +Esc, tab, a sticky ctrl and the four arrows, floated over the +terminal's bottom edge on coarse-pointer devices only. Arrows follow +DECCKM through a new emulator seam method, ctrl folds the next +keystroke on the input path, and presses ride pointerdown with the +default prevented so the soft keyboard stays open. The inset reserves +the bar's room, so it covers no rows." +``` + +--- + +### Task 4: Momentum for touch scrolling + +Drags today translate one-to-one into whole-line `scrollLines()` calls and stop dead the instant the finger lifts — accurate, but rigid. This adds inertia: `touchmove` keeps a short window of `(timeStamp, clientY)` samples, `touchend` turns them into a line-per-second velocity, and a glide loop decays it with iOS-feel friction (`0.998^ms`), emitting whole lines with the fractional carry. A new touch, a pinch-zoom, or unmount cancels the glide. + +**Files:** +- Create: `web/src/lib/glide.ts` +- Create: `web/src/lib/glide.test.ts` +- Modify: `web/src/components/terminal.tsx` (touch handlers + cleanup) +- Test: `web/src/components/terminal.test.tsx` (inside `describe('touch scrolling', ...)`) + +**Interfaces:** +- Consumes: existing touch handlers, `zoomedIn` from `@/lib/viewport`, the `touch()` test helper (extended with a timestamp). +- Produces: `startGlide(opts: { velocity: number; onLines: (lines: number) => void; raf?: typeof requestAnimationFrame; caf?: typeof cancelAnimationFrame }): () => void` — starts a decaying scroll, returns cancel. Velocity is in lines per second, positive toward newer output, matching `scrollLines`. + +- [ ] **Step 1: Write the failing unit test** + +Create `web/src/lib/glide.test.ts`: + +```ts +import { describe, expect, it } from 'vitest' +import { startGlide } from './glide' + +/** A hand-cranked animation frame loop. step() advances the clock. */ +function frames() { + const queue: Array<{ id: number; cb: (t: number) => void }> = [] + let nextId = 1 + let now = 0 + return { + raf: (cb: (t: number) => void) => { + const id = nextId++ + queue.push({ id, cb }) + return id + }, + caf: (id: number) => { + const at = queue.findIndex((f) => f.id === id) + if (at >= 0) queue.splice(at, 1) + }, + step(ms: number) { + now += ms + const due = queue.splice(0, queue.length) + for (const f of due) f.cb(now) + }, + pending: () => queue.length, + } +} + +describe('startGlide', () => { + it('keeps scrolling after the finger lifts, in decaying whole lines', () => { + const f = frames() + let lines = 0 + startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + + f.step(16) // first frame establishes the clock; no time has passed yet + const after1 = lines + for (let i = 0; i < 30; i++) f.step(16) + const after31 = lines + + expect(after31).toBeGreaterThan(after1) + // Half a second of 0.998^ms friction eats most of 60 lines/s: the total + // lands well under what the starting velocity alone would cover… + expect(after31).toBeLessThan(30) + expect(after31).toBeGreaterThan(5) + }) + + it('carries fractions so slow glides still add up to whole lines', () => { + const f = frames() + let lines = 0 + startGlide({ velocity: 4, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + f.step(16) + for (let i = 0; i < 20; i++) f.step(16) + // 4 lines/s over ~0.32s is roughly one line — deliverable only by carry. + expect(lines).toBeGreaterThanOrEqual(1) + }) + + it('emits only whole lines, never fractions', () => { + const f = frames() + const emitted: number[] = [] + startGlide({ velocity: 25, onLines: (n) => emitted.push(n), raf: f.raf, caf: f.caf }) + f.step(16) + for (let i = 0; i < 10; i++) f.step(16) + for (const n of emitted) expect(Number.isInteger(n)).toBe(true) + }) + + it('scrolls the other way for a negative velocity', () => { + const f = frames() + let lines = 0 + startGlide({ velocity: -60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + f.step(16) + for (let i = 0; i < 10; i++) f.step(16) + expect(lines).toBeLessThan(0) + }) + + it('comes to rest on its own and stops asking for frames', () => { + const f = frames() + startGlide({ velocity: 10, onLines: () => {}, raf: f.raf, caf: f.caf }) + for (let i = 0; i < 400 && f.pending(); i++) f.step(16) + expect(f.pending()).toBe(0) + }) + + it('cancel stops it mid-glide', () => { + const f = frames() + let lines = 0 + const cancel = startGlide({ velocity: 60, onLines: (n) => (lines += n), raf: f.raf, caf: f.caf }) + f.step(16) + f.step(16) + const before = lines + cancel() + f.step(16) + f.step(16) + expect(lines).toBe(before) + expect(f.pending()).toBe(0) + }) + + it('declines a velocity too small to glide', () => { + const f = frames() + startGlide({ velocity: 0.2, onLines: () => {}, raf: f.raf, caf: f.caf }) + expect(f.pending()).toBe(0) + }) +}) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd web && npx vitest run src/lib/glide.test.ts` +Expected: FAIL — `./glide` does not exist. + +- [ ] **Step 3: Implement `web/src/lib/glide.ts`** + +```ts +/** + * Friction per millisecond. UIKit's "normal" deceleration rate — velocity + * multiplied by 0.998 every millisecond — which is the feel a finger that + * has used any phone expects, and the reason the constant is not tunable. + */ +const FRICTION = 0.998 + +/** Below this many lines per second a glide has visibly stopped. */ +const REST = 0.5 + +/** + * Scroll on after the finger lifts. + * + * The drag handlers translate touch motion into whole-line scrolls while the + * finger is down; this carries the motion past the lift, decaying an initial + * lines-per-second velocity and emitting whole lines with the fraction + * carried between frames — the same carry trick the drag itself uses. + * Returns a cancel; the caller cancels on the next touch, on a pinch, and + * on unmount, because a glide must never outlive the surface it scrolls. + */ +export function startGlide(opts: { + velocity: number + onLines: (lines: number) => void + raf?: typeof requestAnimationFrame + caf?: typeof cancelAnimationFrame +}): () => void { + const raf = opts.raf ?? requestAnimationFrame + const caf = opts.caf ?? cancelAnimationFrame + let v = opts.velocity + if (Math.abs(v) < REST) return () => {} + + let carry = 0 + let last: number | null = null + let frame = 0 + + const tick = (t: number) => { + frame = 0 + if (last !== null) { + const dt = t - last + // Integrate at the frame's start velocity, then decay: at 60fps the + // difference from exact integration is under a line per flick. + const delta = (v * dt) / 1000 + carry + const lines = Math.trunc(delta) + carry = delta - lines + if (lines !== 0) opts.onLines(lines) + v *= FRICTION ** dt + if (Math.abs(v) < REST) return + } + last = t + frame = raf(tick) + } + + frame = raf(tick) + return () => { + if (frame) caf(frame) + frame = 0 + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd web && npx vitest run src/lib/glide.test.ts` +Expected: PASS (7 tests). + +- [ ] **Step 5: Write the failing integration test** + +In `web/src/components/terminal.test.tsx`, first extend the `touch()` helper with an explicit clock — jsdom stamps events at creation time, so consecutive dispatches are indistinguishable without it: + +```tsx +function touch(type: 'touchstart' | 'touchmove' | 'touchend', ys: number[], at?: number) { + const e = new Event(type, { bubbles: true, cancelable: true }) + Object.defineProperty(e, 'touches', { value: ys.map((clientY) => ({ clientY })) }) + if (at !== undefined) Object.defineProperty(e, 'timeStamp', { value: at }) + return e +} +``` + +(Existing callers pass no `at` and are unaffected.) Then add inside `describe('touch scrolling', ...)` — it already sets `em.live().measured = { width: 800, height: 408 }` and attaches at 80x24, a 17px line: + +```tsx + it('glides on after a flick, and a new touch stops the glide', () => { + const rafCbs: Array<(t: number) => void> = [] + vi.stubGlobal('requestAnimationFrame', (cb: (t: number) => void) => rafCbs.push(cb)) + vi.stubGlobal('cancelAnimationFrame', () => {}) + const surface = surfaceEl() + + // A fast upward drag: 34px (2 lines) per 16ms step. + act(() => { + surface.dispatchEvent(touch('touchstart', [300], 0)) + surface.dispatchEvent(touch('touchmove', [266], 16)) + surface.dispatchEvent(touch('touchmove', [232], 32)) + surface.dispatchEvent(touch('touchend', [], 48)) + }) + const dragged = em.live().scrolled + expect(dragged).toBeGreaterThan(0) + + // The glide keeps scrolling without any finger on the glass. + act(() => { + let t = 48 + while (rafCbs.length) rafCbs.shift()!(t += 16) + // Runs the loop to rest: each callback re-queues the next until the + // velocity dies, so draining until empty is running the whole glide. + }) + expect(em.live().scrolled).toBeGreaterThan(dragged) + + // A finger back on the glass pins the content: no glide survives it. + act(() => { + surface.dispatchEvent(touch('touchstart', [200], 400)) + }) + expect(rafCbs.length).toBe(0) + }) +``` + +- [ ] **Step 6: Run to verify it fails** + +Run: `cd web && npx vitest run src/components/terminal.test.tsx -t 'glides'` +Expected: FAIL — `scrolled` does not grow after `touchend`. + +- [ ] **Step 7: Wire the glide into the touch handlers** + +In `web/src/components/terminal.tsx`, import `startGlide` (beside the other `@/lib` imports): + +```ts +import { startGlide } from '@/lib/glide' +``` + +Amend the touch-handler block. New locals beside `touchY`/`touchCarry`: + +```ts + // The flick record: the last few moves' clocks and positions, enough to + // read a release velocity from. Cleared whenever a gesture starts. + let flick: Array<{ t: number; y: number }> = [] + let glide: (() => void) | null = null +``` + +`touchStart` gains a cancel and a sample reset (full replacement of the handler): + +```ts + const touchStart = (e: TouchEvent) => { + // A finger on the glass pins the content — any glide in flight ends. + glide?.() + glide = null + flick = [] + if (e.touches.length !== 1 || zoomedIn(window.visualViewport)) { + touchY = null + return + } + touchY = e.touches[0]!.clientY + touchCarry = 0 + } +``` + +`touchMove` records a sample after its existing work — add before the final `if (lines !== 0)`: + +```ts + flick.push({ t: e.timeStamp, y }) + if (flick.length > 6) flick.shift() +``` + +`touchEnd` reads the velocity and starts the glide (full replacement): + +```ts + const touchEnd = (e: TouchEvent) => { + const wasDragging = touchY !== null + touchY = null + touchCarry = 0 + // Velocity over the sample window. Two samples and thirty milliseconds + // are the floor: a tap or a hold-then-lift reads as no flick at all. + const a = flick[0] + const b = flick[flick.length - 1] + flick = [] + if (!wasDragging || !a || !b || b.t - a.t < 30) return + const dt = (b.t - a.t) / 1000 + const lps = (a.y - b.y) / lineHeightPx() / dt + glide = startGlide({ velocity: lps, onLines: (n) => emulator.scrollLines(n) }) + void e + } +``` + +Cleanup: in the effect's return, beside the touch listener removals, add: + +```ts + glide?.() +``` + +- [ ] **Step 8: Run tests, then full suite** + +Run: `cd web && npx vitest run src/components/terminal.test.tsx` → PASS (the pre-existing `touchend` tests pass `[]` with no timestamps: `flick` is empty or the span is under 30ms, so no glide starts and their assertions hold). +Run: `cd web && npx vitest run` → PASS including the scanner guard (new scanned prose in `glide.ts` and `terminal.tsx`; reword on any leak). + +- [ ] **Step 9: Commit** + +```bash +git add web/src/lib/glide.ts web/src/lib/glide.test.ts web/src/components/terminal.tsx web/src/components/terminal.test.tsx +git commit -m "feat(web): let a flick glide the scrollback + +Drags scrolled line-for-line and stopped dead at the lift, which reads +as rigid to a thumb calibrated by every other scrolling surface on a +phone. A release velocity now decays at UIKit's 0.998-per-millisecond +rate, emitting whole lines with the fraction carried, and dies at the +next touch, a pinch, or unmount." +``` + +--- + +## Self-Review Notes + +- Spec coverage: flash (Task 1), arrows/Esc/Tab/sticky-Ctrl bar shown only on mobile (Tasks 2–3), mode-aware arrows (Tasks 2–3 via seam method), scroll momentum (Task 4). The user's pty-policy point needs no work. +- Task 4 sign convention: drag delta is `(touchY - y) / lineHeight` — finger moving down produces negative lines (older). The flick velocity `(a.y - b.y)` preserves exactly that sign, and the glide unit tests pin both directions. +- Type consistency: `BarKey`/`barKeyBytes`/`ctrlTransform` signatures match across Tasks 2 and 3; `applicationCursorKeys()` name matches across seam, xterm, fake, and wiring; `appCursor` field name matches test usage. +- Known risk: `pointerDown` events in jsdom — `fireEvent.pointerDown` dispatches a PointerEvent-shaped event that React's `onPointerDown` receives; this is established @testing-library behavior. If an environment quirk surfaces, `fireEvent.mouseDown` on the same handler is NOT equivalent — instead dispatch `new Event('pointerdown', { bubbles: true, cancelable: true })`. +- Known risk: the `key()` helper matching — resolved in Task 3 Step 4's note (use `startsWith`). +- Deliberate scope cuts (do not add): no long-press auto-repeat on arrows, no PgUp/PgDn/Home/End, no haptics, no user-configurable keys. All are follow-ups if the bar earns its place. diff --git a/docs/superpowers/plans/2026-08-08-mobile-terminal-fixes.md b/docs/superpowers/plans/2026-08-08-mobile-terminal-fixes.md new file mode 100644 index 0000000..265452b --- /dev/null +++ b/docs/superpowers/plans/2026-08-08-mobile-terminal-fixes.md @@ -0,0 +1,593 @@ +# Mobile Terminal Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the terminal usable on a phone: pinch-zoom works, the prompt stays visible above the virtual keyboard, and the pty follows the device the user is actually using instead of the largest attached view. + +**Architecture:** Three independent fixes. (1) The terminal surface's `touch-action: none` becomes `touch-action: pinch-zoom` so the browser gets the pinch gesture back while single-finger drags still feed the custom scrollback handler. (2) A new `trackVisualViewport` helper sizes the terminal pane to the *visual* viewport, so an iOS keyboard opening shrinks the pane, the ResizeObserver refits, and the pty rows land above the keyboard; while pinch-zoomed it instead hands single-finger panning back to the browser. (3) The daemon's sizing policy changes from "componentwise maximum across views" to "the most recently active view's fit" (tmux `window-size latest` semantics) — activity being input, a size report, a signal, or the attach itself — so a phone gets a phone-sized pty the moment it speaks and the laptop takes it back on its first keystroke. + +**Tech Stack:** React + xterm + vitest (jsdom) in `web/`, Go daemon in `internal/daemon/` with `go test`. + +## Global Constraints + +- Tailwind scans **prose** in every file under `web/` outside `*.test.*`, `src/testing/`, `src/client/` and `*.go` — a quoted single English word that is also a utility name (e.g. `'resize'`) compiles a stray CSS rule and fails `styles.build.test.ts`. Never write the event name `resize` as a quoted string in web sources; use `onresize`/`onscroll` property assignment instead (bare identifiers are not scanner candidates). +- Comments in `web/` sources are scanned too. After any comment edit under `web/`, run `npm test -- --run src/styles.build.test.ts` (from `web/`) to prove no bare utility leaked. +- Test commands: `cd web && npx vitest run ` for web; `go test ./internal/daemon/ -run ` for daemon; `make test` for everything. +- Commit style: conventional prefix (`fix:`, `feat:`), body explains why, matching repo history. +- Never edit `web/dist/` (build output, committed by `make web`). + +--- + +### Task 1: Give pinch-zoom back to the browser + +The terminal surface carries `touch-action: none` (`web/src/styles.css`, `@utility flue-term-surface`), added when touch-drag scrollback landed. `none` also swallows two-finger pinch, which is why the page cannot be zoomed on a phone. `pinch-zoom` keeps single-finger pans out of the browser's hands (the drag handler in `terminal.tsx` still gets them, and it already bails on multi-touch: `e.touches.length !== 1`) while letting two fingers zoom. + +**Files:** +- Modify: `web/src/styles.css` (the `@utility flue-term-surface` block, ~line 296) +- Test: `web/src/styles.build.test.ts` (add one assertion to the existing `describe('compiled stylesheet')`) + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: the compiled stylesheet contains `touch-action:pinch-zoom` on the surface utility. Task 2's zoomed-pan behavior assumes pinch is browser-handled. + +- [ ] **Step 1: Write the failing test** + +In `web/src/styles.build.test.ts`, inside `describe('compiled stylesheet', ...)`, add: + +```ts + it('leaves the pinch gesture to the browser on the terminal surface', () => { + // touch-action: none once shipped here and made the page unzoomable on + // phones; pinch-zoom keeps single-finger drags for the scrollback + // handler while two fingers still zoom. + expect(css).toContain('touch-action:pinch-zoom') + expect(css).not.toContain('touch-action:none') + }) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/styles.build.test.ts -t 'pinch gesture'` +Expected: FAIL — compiled css contains `touch-action:none`, not `touch-action:pinch-zoom`. (The build in `beforeAll` takes up to a couple of minutes.) + +- [ ] **Step 3: Change the utility** + +In `web/src/styles.css`, replace the `flue-term-surface` utility body: + +```css +/* The terminal fills its pane; xterm manages its own internal scrolling. */ +@utility flue-term-surface { + block-size: 100%; + inline-size: 100%; + /* Single-finger gestures are ours: touches become scrollLines() calls + * (terminal.tsx), and a browser allowed to pan the page would eat them + * first. The pinch stays with the browser — `none` here once made the + * page unzoomable on phones, which is too much to take. */ + touch-action: pinch-zoom; +} +``` + +- [ ] **Step 4: Run the build test file to verify it passes** + +Run: `cd web && npx vitest run src/styles.build.test.ts` +Expected: PASS, including the pre-existing prose-leak guard (the new CSS comment is inside a `.css` file, which is outside the scan perimeter). + +- [ ] **Step 5: Commit** + +```bash +git add web/src/styles.css web/src/styles.build.test.ts +git commit -m "fix(web): let two fingers pinch-zoom the terminal + +touch-action: none routed every touch to the scrollback handler and +silently ate the pinch gesture with it. pinch-zoom keeps single-finger +drags ours and hands the zoom back to the browser." +``` + +--- + +### Task 2: Size the pane to the visual viewport (keyboard, zoomed panning) + +The pane is `h-full` of the *layout* viewport. An iOS keyboard shrinks only the *visual* viewport, the page cannot scroll (`overflow-hidden`), so the prompt sits behind the keyboard. Fix: track `window.visualViewport`; at scale ≈ 1, pin the pane's height (and vertical offset) to the visual viewport — the existing ResizeObserver on the inner box then refits and reports smaller rows, and the pty follows. At scale > 1 (pinch-zoomed, Task 1), stop fighting the browser: release `touch-action` on the surface so one finger pans the zoomed page, and leave the pane alone so zooming never refits the pty. + +**Files:** +- Create: `web/src/lib/viewport.ts` +- Create: `web/src/lib/viewport.test.ts` +- Modify: `web/src/components/terminal.tsx` (wire into the main effect + cleanup) +- Modify: `web/index.html` (viewport meta) + +**Interfaces:** +- Consumes: nothing from other tasks (behaviorally pairs with Task 1's `touch-action: pinch-zoom`). +- Produces: `trackVisualViewport(opts: { pane: HTMLElement; surface: HTMLElement; viewport: ViewportLike | null }): () => void` — installs handlers, applies once immediately, returned function removes handlers and clears every style it set. `ViewportLike` is `{ readonly height: number; readonly offsetTop: number; readonly scale: number; onresize: ((ev: Event) => void) | null; onscroll: ((ev: Event) => void) | null }`. + +- [ ] **Step 1: Write the failing test** + +Create `web/src/lib/viewport.test.ts`: + +```ts +import { beforeEach, describe, expect, it } from 'vitest' +import { trackVisualViewport, type ViewportLike } from './viewport' + +/** A hand-cranked visualViewport double; fire() plays both handler slots. */ +function fakeViewport(init: { height: number; offsetTop?: number; scale?: number }) { + const vv = { + height: init.height, + offsetTop: init.offsetTop ?? 0, + scale: init.scale ?? 1, + onresize: null as ViewportLike['onresize'], + onscroll: null as ViewportLike['onscroll'], + fire() { + vv.onresize?.(new Event('x')) + }, + } + return vv +} + +describe('trackVisualViewport', () => { + let pane: HTMLElement + let surface: HTMLElement + + beforeEach(() => { + pane = document.createElement('div') + surface = document.createElement('div') + }) + + it('is a no-op without a viewport, as on browsers that lack one', () => { + const dispose = trackVisualViewport({ pane, surface, viewport: null }) + expect(pane.getAttribute('style')).toBeNull() + dispose() + }) + + it('pins the pane to the visual viewport height when the keyboard opens', () => { + const vv = fakeViewport({ height: 700 }) + trackVisualViewport({ pane, surface, viewport: vv }) + expect(pane.style.height).toBe('700px') + + vv.height = 400 // keyboard up + vv.fire() + expect(pane.style.height).toBe('400px') + }) + + it('follows the viewport down the page when focusing scrolls it', () => { + const vv = fakeViewport({ height: 400, offsetTop: 120 }) + trackVisualViewport({ pane, surface, viewport: vv }) + expect(pane.style.transform).toBe('translateY(120px)') + }) + + it('releases the surface to the browser while pinch-zoomed', () => { + const vv = fakeViewport({ height: 700 }) + trackVisualViewport({ pane, surface, viewport: vv }) + + vv.scale = 2 + vv.height = 350 + vv.fire() + // One finger must pan the zoomed page, and a zoom is not a layout change: + // the pane keeps its unzoomed size so the pty never refits on a pinch. + expect(surface.style.touchAction).toBe('auto') + expect(pane.style.height).toBe('700px') + + vv.scale = 1 + vv.height = 700 + vv.fire() + expect(surface.style.touchAction).toBe('') + expect(pane.style.height).toBe('700px') + }) + + it('clears everything it set on dispose', () => { + const vv = fakeViewport({ height: 500, offsetTop: 40 }) + const dispose = trackVisualViewport({ pane, surface, viewport: vv }) + dispose() + expect(pane.getAttribute('style')).toBe('') + expect(surface.style.touchAction).toBe('') + expect(vv.onresize).toBeNull() + expect(vv.onscroll).toBeNull() + + vv.height = 300 + vv.fire() // a dead handler set would throw or restyle; neither may happen + expect(pane.style.height).toBe('') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd web && npx vitest run src/lib/viewport.test.ts` +Expected: FAIL — `./viewport` does not exist. + +- [ ] **Step 3: Implement `web/src/lib/viewport.ts`** + +```ts +/** + * The slice of VisualViewport this needs, as a seam for tests — and with the + * handlers as *properties*, not addEventListener: the event's name is also a + * Tailwind utility name, and a quoted string of it in any scanned source + * compiles a stray rule (see the scanner notes in src/styles.css). + */ +export interface ViewportLike { + readonly height: number + readonly offsetTop: number + readonly scale: number + onresize: ((ev: Event) => void) | null + onscroll: ((ev: Event) => void) | null +} + +/** + * Keep the pane inside the *visual* viewport. + * + * The pane fills the layout viewport, but a phone's keyboard shrinks only the + * visual one — the page cannot scroll under the terminal, so without this the + * bottom rows (and the prompt) sit behind the keyboard. Pinning the pane's + * height to the visual viewport lets the ResizeObserver in terminal.tsx do + * the rest: the inner box shrinks, the fit is re-reported, the pty rows land + * above the keyboard. + * + * While pinch-zoomed (scale > 1) the rules invert. A zoom is not a layout + * change, so the pane is left alone — refitting the pty on a pinch would + * reflow the very text being magnified — and the surface's touch-action is + * released so a single finger pans the zoomed page, which the stylesheet + * otherwise reserves for the scrollback drag handler. + */ +export function trackVisualViewport(opts: { + pane: HTMLElement + surface: HTMLElement + viewport: ViewportLike | null +}): () => void { + const { pane, surface, viewport } = opts + if (!viewport) return () => {} + + const apply = () => { + if (viewport.scale > 1.01) { + surface.style.touchAction = 'auto' + return + } + surface.style.touchAction = '' + pane.style.height = `${viewport.height}px` + pane.style.transform = `translateY(${viewport.offsetTop}px)` + } + + viewport.onresize = apply + viewport.onscroll = apply + apply() + + return () => { + viewport.onresize = null + viewport.onscroll = null + surface.style.touchAction = '' + pane.style.height = '' + pane.style.transform = '' + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd web && npx vitest run src/lib/viewport.test.ts` +Expected: PASS (5 tests). + +Note the dispose test asserts `pane.getAttribute('style')` is `''` — jsdom leaves an empty `style` attribute after properties are cleared. If it reports `null` instead, relax that one assertion to `expect(pane.style.height).toBe('')` plus `expect(pane.style.transform).toBe('')`; the behavior under test is "nothing remains set", not the attribute's presence. + +- [ ] **Step 5: Wire into the terminal effect** + +In `web/src/components/terminal.tsx`: + +Add to the imports from `@/lib`: + +```ts +import { trackVisualViewport } from '@/lib/viewport' +``` + +Inside the main effect, directly after the four `surface.addEventListener('touch…')` registrations: + +```ts + // The pane hugs the visual viewport: a phone keyboard shrinks it and the + // ResizeObserver below refits the terminal above the keyboard. While + // pinch-zoomed it instead releases the surface so one finger pans. + const untrackViewport = trackVisualViewport({ + pane, + surface, + viewport: window.visualViewport, + }) +``` + +In the cleanup, next to the touch listener removals: + +```ts + untrackViewport() +``` + +- [ ] **Step 6: Ask Android's keyboard to resize the layout too** + +In `web/index.html`, extend the viewport meta: + +```html + +``` + +(`interactive-widget=resizes-content` makes Chrome on Android shrink the layout viewport under the keyboard; iOS ignores it and is covered by the visualViewport tracker. Both paths converge on the same ResizeObserver.) + +- [ ] **Step 7: Run the web suite and the build guard** + +Run: `cd web && npx vitest run` +Expected: PASS. `styles.build.test.ts` matters most here — the comments added to `viewport.ts` and `terminal.tsx` are inside Tailwind's scan perimeter, and this proves no bare utility rule leaked from their prose. + +- [ ] **Step 8: Commit** + +```bash +git add web/src/lib/viewport.ts web/src/lib/viewport.test.ts web/src/components/terminal.tsx web/index.html +git commit -m "fix(web): keep the prompt above the phone keyboard + +The pane filled the layout viewport, but a phone keyboard shrinks only +the visual one and the page under the terminal cannot scroll — so the +prompt sat behind the keys. The pane now pins itself to the visual +viewport and the existing ResizeObserver refits the pty above the +keyboard; Android gets the same through interactive-widget. While +pinch-zoomed the tracker instead releases touch-action so one finger +pans the magnified page." +``` + +--- + +### Task 3: Pty follows the most recently active view + +The daemon keeps the pty at the componentwise maximum across attached views (`effectiveLocked`, `internal/daemon/server.go`), so a phone beside an open laptop tab always renders scaled down. Chosen replacement (tmux `window-size latest` semantics): the pty wears the fitted size of the **most recently active** view. The activity order already exists — `touch()` moves a conn to the back of `s.attached[id]` on input, resize and signal, and attach appends — so the policy change is: `effectiveLocked` walks that list from the back and returns the first recorded desire, and the *input* path also re-syncs the pty, because typing is how a view takes the size back without re-reporting. + +The web client needs no behavioral change: a view whose fit is at or above the pty already renders one-to-one (letterboxed in its pane), a smaller view already scales down. + +**Files:** +- Modify: `internal/daemon/server.go` (`effectiveLocked`, `recordDesire`, new `effective`) +- Modify: `internal/daemon/conn.go` (input path, `wire.Resize` handler, new `syncSize`) +- Modify: `internal/daemon/server_test.go` (replace `TestResizeKeepsTheLargestAttachedView`) +- Modify: `spec/protocol.md` (Sizing section) +- Modify: `web/src/components/terminal.tsx` (the "## The sizing policy" doc comment only) + +**Interfaces:** +- Consumes: nothing from Tasks 1–2. +- Produces: `(s *Server) effective(id string) (viewSize, bool)` — the most recently active view's desire, behind `primaryMu`. `(c *conn) syncSize(a *attachment)` — resizes the pty to `effective` and broadcasts when it differs from the session's current size. `recordDesire(id string, c *conn, cols, rows uint16)` loses its return values. + +- [ ] **Step 1: Write the failing test** + +In `internal/daemon/server_test.go`, replace `TestResizeKeepsTheLargestAttachedView` (keep its position in the file) with: + +```go +// TestPtySizeFollowsTheActiveView pins the sizing policy: the PTY wears the +// fitted size of the most recently active view — attaching, reporting a size +// and typing all count as activity. A phone that attaches beside a laptop +// gets a phone-sized terminal the moment it reports, and the laptop takes +// the size back with its first keystroke, no re-report needed. +func TestPtySizeFollowsTheActiveView(t *testing.T) { + ts, reg := newTestServer(t) + s, err := reg.Spawn(session.SpawnOpts{Cmd: []string{"sleep", "2"}, Cols: 80, Rows: 24}) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + defer s.Close() + + laptop := dial(t, ts) + writeControl(t, laptop, wire.Hello{Ver: "test"}) + writeControl(t, laptop, wire.Attach{ID: s.ID(), LastSeq: 0}) + var laptopRef uint32 + readUntil(t, laptop, func(msg any, _ []byte) bool { + a, ok := msg.(wire.Attached) + if ok { + laptopRef = a.Ref + } + return ok + }) + writeControl(t, laptop, wire.Resize{Ref: laptopRef, Cols: 120, Rows: 40, Primary: true}) + waitFor(t, func() bool { return s.Info().Cols == 120 && s.Info().Rows == 40 }) + + phone := dial(t, ts) + writeControl(t, phone, wire.Hello{Ver: "test"}) + writeControl(t, phone, wire.Attach{ID: s.ID(), LastSeq: 0}) + var phoneRef uint32 + readUntil(t, phone, func(msg any, _ []byte) bool { + a, ok := msg.(wire.Attached) + if ok { + phoneRef = a.Ref + } + return ok + }) + + // The phone just attached and reported, which makes it the active view: + // the PTY reshapes to the phone rather than staying with the largest. + writeControl(t, phone, wire.Resize{Ref: phoneRef, Cols: 40, Rows: 10, Primary: false}) + waitFor(t, func() bool { return s.Info().Cols == 40 && s.Info().Rows == 10 }) + + // A keystroke on the laptop moves the activity back; its recorded desire + // is applied without the laptop re-reporting anything. + frame := wire.EncodeBinary(wire.FrameInput, laptopRef, []byte("k")) + if err := laptop.Write(context.Background(), websocket.MessageBinary, frame); err != nil { + t.Fatalf("write input: %v", err) + } + waitFor(t, func() bool { return s.Info().Cols == 120 && s.Info().Rows == 40 }) + + // The phone's keyboard opening is a fresh report, and a report is + // activity: the size follows the phone again. + writeControl(t, phone, wire.Resize{Ref: phoneRef, Cols: 40, Rows: 15, Primary: false}) + waitFor(t, func() bool { return s.Info().Cols == 40 && s.Info().Rows == 15 }) + + // The phone leaves; the laptop is what remains, and its desire returns + // without anyone asking again. + writeControl(t, phone, wire.Detach{Ref: phoneRef}) + waitFor(t, func() bool { return s.Info().Cols == 120 && s.Info().Rows == 40 }) +} +``` + +(`context`, `websocket`, `wire` and `session` are already imported by this file; `waitFor` already exists below the old test — keep it.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/daemon/ -run TestPtySizeFollowsTheActiveView` +Expected: FAIL at the second `waitFor` — under the max policy the phone's 40x10 report leaves the pty at 120x40. + +- [ ] **Step 3: Implement the policy in server.go** + +Replace `recordDesire` and `effectiveLocked` in `internal/daemon/server.go`: + +```go +// recordDesire notes c's fitted size for a session. What the PTY is actually +// set to is effective's business: the desire of the most recently active +// view, which after a report is usually — but not necessarily — the reporter. +func (s *Server) recordDesire(id string, c *conn, cols, rows uint16) { + s.primaryMu.Lock() + defer s.primaryMu.Unlock() + m := s.desired[id] + if m == nil { + m = map[*conn]viewSize{} + s.desired[id] = m + } + m[c] = viewSize{cols: cols, rows: rows} +} + +// effective is effectiveLocked behind its lock, for callers outside the +// primaryMu critical sections. +func (s *Server) effective(id string) (viewSize, bool) { + s.primaryMu.Lock() + defer s.primaryMu.Unlock() + return s.effectiveLocked(id) +} + +// effectiveLocked computes, under primaryMu, the size the PTY should wear: +// the fitted size of the most recently active view that has reported one. +// The attachment list already encodes recency — touch keeps each session's +// most recent client at the back — so the walk is from the back, skipping +// views that have yet to report. One pty has one grid, so someone must be +// chosen; choosing the view being *used* is what lets a phone pick a session +// up at phone size and a laptop take it back with a keystroke. +func (s *Server) effectiveLocked(id string) (viewSize, bool) { + list := s.attached[id] + desires := s.desired[id] + for i := len(list) - 1; i >= 0; i-- { + if v, ok := desires[list[i]]; ok { + return v, true + } + } + return viewSize{}, false +} +``` + +(`releasePrimary` already calls `effectiveLocked` and its callers already resize to the result, so detach hand-back needs no change.) + +- [ ] **Step 4: Sync the pty on activity in conn.go** + +Add to `internal/daemon/conn.go`: + +```go +// syncSize points the PTY at the effective size — the most recently active +// view's desire — and broadcasts when that is a change. Called wherever +// activity may have moved which view is effective: after a size report, and +// after every input frame, because typing is how a view takes the size back +// without re-reporting it. +func (c *conn) syncSize(a *attachment) { + eff, ok := c.srv.effective(a.s.ID()) + if !ok { + return + } + info := a.s.Info() + if eff.cols == info.Cols && eff.rows == info.Rows { + return + } + if err := a.s.Resize(eff.cols, eff.rows); err != nil { + c.sendError("resize_failed", err.Error()) + return + } + c.srv.broadcastSize(a.s.ID(), eff.cols, eff.rows) +} +``` + +In `handleBinary`, after the existing `c.srv.touch(a.s.ID(), c)`: + +```go + c.srv.touch(a.s.ID(), c) + c.syncSize(a) + if err := a.s.Write(payload); err != nil { +``` + +In `handleControl`'s `case wire.Resize:`, replace everything from the policy comment through the `broadcastSize` call with: + +```go + c.srv.touch(a.s.ID(), c) + if m.Primary { + c.srv.setPrimary(a.s.ID(), c) + } + // The report is recorded for every view, but the PTY follows the most + // recently active one — which this reporter, having just been touched, + // now is. An idle view's desire therefore waits, and is applied the + // moment that view speaks again (see syncSize on the input path). The + // primary role above still decides who answers device queries; it + // does not own the dimensions. + c.srv.recordDesire(a.s.ID(), c, m.Cols, m.Rows) + c.syncSize(a) +``` + +- [ ] **Step 5: Run the daemon tests** + +Run: `go test ./internal/daemon/` +Expected: PASS, including `TestPtySizeFollowsTheActiveView`, `TestPrimarySeizureResizesPTY` (single view: its desire is the effective one) and the promotion tests (primary logic untouched). + +- [ ] **Step 6: Rewrite the policy prose** + +`spec/protocol.md`, replace the first paragraph of the `### Sizing` section with: + +```markdown +Every attached view sends `resize` with the cells that fit its own pane. The +daemon records one desired size per attachment and keeps the PTY at the fit +of the **most recently active** view — activity being an input frame, a size +report, a signal, or the attach itself. It recomputes when a report lands, +when activity moves between views, and when an attachment ends, broadcasting +the result as `sizeChanged`. A view whose fit is below the broadcast size +renders the full screen scaled down; one whose fit is at or above it renders +the grid one-to-one. One pty has one grid, so someone must be chosen, and it +is the view being used: picking up the phone reshapes the session to the +phone as soon as it reports, and the laptop's next keystroke reshapes it +back — an idle view's report is never lost, only waiting. +``` + +`web/src/components/terminal.tsx`, replace the "## The sizing policy" paragraph of the component doc comment (keep the surrounding sections) with: + +``` + * ## The sizing policy + * + * Every attached view measures its own pane and reports the cells that fit + * it; the daemon sizes the pty to the fit of the most recently active view + * (`effectiveLocked` in internal/daemon) — activity being input, a report, + * a signal or the attach itself. A view whose own fit matches or exceeds + * the pty renders it one-to-one; a smaller view renders the full screen and + * scales the whole surface down, staying fully interactive. Picking up the + * phone therefore reshapes the session to the phone the moment its report + * lands, and the first keystroke back on the laptop reshapes it back; a + * detaching view hands the size to whichever remaining view was active + * last. Ownership of the *primary* role never moves with any of this — the + * daemon keeps one client primary purely to answer device queries. +``` + +- [ ] **Step 7: Verify the whole tree** + +Run: `make test` +Expected: PASS across Go, web and relay. `styles.build.test.ts` re-proves the edited `terminal.tsx` comment leaked no utility rule. + +- [ ] **Step 8: Commit** + +```bash +git add internal/daemon/server.go internal/daemon/conn.go internal/daemon/server_test.go spec/protocol.md web/src/components/terminal.tsx +git commit -m "feat(daemon): pty follows the most recently active view + +The componentwise-max policy meant a phone beside an open laptop tab +always rendered the laptop's grid scaled down to unreadable. The pty now +wears the fit of the most recently active view — attach, resize report, +signal and input all count — so the phone gets a phone-sized terminal +the moment it speaks and the laptop takes the size back with its first +keystroke, its recorded desire applied without a re-report. tmux calls +this window-size latest. Detach hand-back is unchanged: the size goes to +whichever remaining view was active last." +``` + +--- + +## Self-Review Notes + +- Spec coverage: pinch-zoom (Task 1), keyboard/visual viewport including Android meta (Task 2), sizing policy per the user's "follow active device" choice (Task 3). The "AI agent disabled scrolling stuff" concern is Task 1's `touch-action` plus Task 2's zoomed-pan release; touch-drag scrollback is deliberately preserved. +- Type consistency: `ViewportLike` shape matches between Task 2's test and implementation; `effective`/`syncSize`/`recordDesire` signatures consistent across Task 3 steps. +- Known risk, called out in Task 2 Step 4: jsdom's `style` attribute serialization after clearing properties; the step says exactly how to relax the assertion without weakening the behavior under test. +- Deliberate semantics, decided with the user: a size report *is* activity (an idle laptop whose browser window is being resized has a human at it; a phone whose keyboard opens should win the pty immediately). The input-path `syncSize` covers the one case reports cannot: switching devices without any layout change on the destination.