From 0bfa1f75927de4f2f2b511c1802ac1f44f10a22b Mon Sep 17 00:00:00 2001 From: zerodice0 Date: Sat, 10 Oct 2026 09:12:28 +0900 Subject: [PATCH] feat(terminal): repeat a held key-bar arrow like a held key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Change On a phone, holding an arrow on the touch key bar sent one key and then nothing. It now repeats like a held hardware key. - `src/lib/keyRepeat.ts` holds the timing as a pure state machine with an injectable clock: no key on press, the first repeat after `KEY_REPEAT_DELAY_MS` (400 ms), then one every `KEY_REPEAT_INTERVAL_MS` (50 ms) until release. Only `ArrowUp`/`ArrowDown`/`ArrowLeft`/`ArrowRight` key items repeat, including saved combinations such as Ctrl+←; Esc, Tab, Enter, ^C, catalog keys and the modifier and mode buttons do not. - A tap is unchanged: it still sends one key through `onKey` → `pressKey` on `click`. After a hold has repeated, the click that follows the release is swallowed, so a hold never ends with an extra key. Every repeat goes through the same `onKey` path (latest callback through a ref), so held Ctrl/Alt/Shift and saved combinations apply to each repeated key. - `Key` in `KeyBar.tsx` tracks the primary pointer with pointer capture. The repeat stops on `pointerup`, `pointercancel`, `lostpointercapture`, the button becoming disabled, the document becoming hidden, the window losing focus, and unmount. KeyBar is not remounted when the pane changes, so `PaneTerminal` passes a `holdScope` (`paneStorageId:view`) and a change of pane or lens cancels the hold. - A sideways swipe that starts on an arrow keeps scrolling the row and sends nothing: moving more than 10 px before the repeat starts cancels the pending hold, as does the `pointercancel` the browser sends when it takes over the pan. - Keys prevent `contextmenu`, and `.key` sets `-webkit-touch-callout: none`, so a long press opens no callout or menu. - No setting, WS frame or server change: the existing `keys` frame and the server's queue merge carry the repeat. No new UI strings. - Docs: `docs/terminal-input.md` (key bar), `docs/development.md` and `scripts/ci-browser.sh` list the new browser regression. CHANGELOG: `Unreleased → Added`. On the issue's open questions this PR takes the narrow side: only the four arrows repeat, and 400 ms / 50 ms are fixed constants. Closes #718 ## Validation - `bun run generate:types --check`, `bun run typecheck`, `bun run build`: pass. - `bun run test:unit`: 2237 pass, 0 fail, run with `TMPDIR=/tmp`. New `src/lib/keyRepeat.test.ts` covers tap/hold boundaries at the delay and interval, release and cancel, the one-shot click suppression, the movement slop and which items repeat. `scripts/ci-browser.test.ts` now expects 22 browser scripts. - New `scripts/key-bar-hold-repeat-regression.ts` (demo transport, 393×852 touch context, CDP touch events): all 7 checks pass, in three runs before rebasing onto current `main` and one after: - a tap on ← sends exactly one key; - holding ← sends 10–11 keys, none before the delay, and nothing after release; - a hold shorter than the delay sends one key; - a horizontal swipe from ← scrolls the overflowing row and sends no key; - a held Esc does not repeat (Chrome sends no `click` after a long touch, so it sends no key, as before this change); - with Ctrl held, every repeated key is Ctrl+←; - a held arrow prevents the `contextmenu` default. - `scripts/sticky-modifiers-regression.ts`, `scripts/key-bar-customization-demo-regression.ts`, `scripts/terminal-arrows-clicks-regression.ts`: pass. - Tried by hand on a phone over Tailscale Serve against a server built from this branch: tap, hold, release and sideways scroll behaved as described. - Not run: the full `bun run test:integration` and `bun run test:ui`. --- CHANGELOG.md | 5 + docs/development.md | 1 + docs/terminal-input.md | 5 + scripts/ci-browser.sh | 1 + scripts/ci-browser.test.ts | 2 +- scripts/key-bar-hold-repeat-regression.ts | 224 +++++++++++++++++++++ src/components/KeyBar.css | 1 + src/components/KeyBar.tsx | 91 ++++++++- src/components/PaneTerminal.tsx | 1 + src/lib/keyRepeat.test.ts | 225 ++++++++++++++++++++++ src/lib/keyRepeat.ts | 90 +++++++++ 11 files changed, 639 insertions(+), 7 deletions(-) create mode 100644 scripts/key-bar-hold-repeat-regression.ts create mode 100644 src/lib/keyRepeat.test.ts create mode 100644 src/lib/keyRepeat.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b052735e4..523ffbc126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa ## [Unreleased] +### Added +- Holding an arrow in the touch key bar repeats it until release, including held modifiers and + saved arrow combinations. A tap still sends one key, and swiping sideways scrolls the row + without sending a key. + ### Fixed - A request that carries a session cookie but states no `Origin` is now read as cross-site rather than same-origin, so a header that went missing is no longer trusted as though a browser had diff --git a/docs/development.md b/docs/development.md index 6a1b0a6ca8..47c1bbb294 100644 --- a/docs/development.md +++ b/docs/development.md @@ -71,6 +71,7 @@ bun test # needs herdr installed; creates and removes its bun run test:ui # browser regression against isolated test servers bun scripts/sticky-modifiers-regression.ts # mobile held keys through real legacy/Kitty PTYs bun scripts/key-bar-customization-demo-regression.ts # mobile key layout, saved combinations and migration on demo fixtures +bun scripts/key-bar-hold-repeat-regression.ts # phone touch-arrow taps, holds, swipes and sticky Ctrl on demo fixtures bun scripts/settings-pages-demo-regression.ts # every Settings page on a 390px and a 320px phone, and Back stepping out of the dialog bun scripts/chat-browser-qa.ts # chat lens end to end bun scripts/output-browser-qa.ts # terminal output flow control end to end diff --git a/docs/terminal-input.md b/docs/terminal-input.md index d60e0937df..a96d1a912a 100644 --- a/docs/terminal-input.md +++ b/docs/terminal-input.md @@ -62,6 +62,11 @@ menus, not this terminal-input row. Ctrl, Alt and Shift stay held until tapped again and can be combined. Removing a modifier button clears its held state immediately. Ordinary key buttons use the held modifiers, while saved combinations send exactly their configured modifiers without changing the held state. +Holding one of the four arrows (↑ ↓ ← →) starts repeating after about 400 ms, then sends a +key about every 50 ms until release. A tap still sends exactly one key. Every repeat uses the +same held modifiers or saved combination as a tap. Only arrows repeat, including saved arrow +combinations; other key-bar buttons do not. A sideways swipe scrolls the row without sending +a key. Long presses on key buttons do not open a callout or context menu. Direct typing and arrow buttons send logical key chords; Herdr encodes them for the PTY's keyboard protocol. Home/End, Page and Delete/Insert keys use CSI navigation through the attach stream because Herdr's RPC key parser lacks those names. Paste, IME commits and terminal reports pass through unchanged. diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index 42cfe3b165..cbef447c9b 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -55,6 +55,7 @@ run_script scripts/terminal-dispose-browser-qa.ts run_script scripts/sticky-modifiers-regression.ts run_script scripts/terminal-arrows-clicks-regression.ts run_script scripts/key-bar-customization-demo-regression.ts +run_script scripts/key-bar-hold-repeat-regression.ts run_script scripts/settings-pages-demo-regression.ts run_script scripts/chat-history-browser-qa.ts run_script scripts/math-browser-qa.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index d1188068f2..7435ea5bc2 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -57,7 +57,7 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(21); + expect(rows(text)).toHaveLength(22); expect(rows(text).every((row) => row.code === 0 && row.seconds >= 0)).toBe(true); expect(rows(text).at(-1)?.script).toBe("scripts/machine-conflict-regression.ts"); expect(evidence).toBe(false); diff --git a/scripts/key-bar-hold-repeat-regression.ts b/scripts/key-bar-hold-repeat-regression.ts new file mode 100644 index 0000000000..bee9bd5a9f --- /dev/null +++ b/scripts/key-bar-hold-repeat-regression.ts @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium, type CDPSession, type Locator, type Page } from "playwright-core"; +import type { KeyBarItem } from "../src/lib/keyBar.ts"; +import { KEY_REPEAT_DELAY_MS, KEY_REPEAT_INTERVAL_MS } from "../src/lib/keyRepeat.ts"; +import panes from "../site/demo/fixtures/panes.json"; +import { buildDemoApp } from "./demo-build.ts"; + +// Production client over the demo's synthetic transport. The disposable app only listens on +// loopback; no herdr session, user terminal or real input transport is opened. +const app = mkdtempSync(join(tmpdir(), "herdr-key-bar-hold-repeat-")); +const evidence = process.env.UI_EVIDENCE_DIR; +const HOLD_MS = KEY_REPEAT_DELAY_MS + 10 * KEY_REPEAT_INTERVAL_MS; +// These waits measure a gesture or give unwanted sends time to appear, not app readiness. +const NO_SEND_WAIT_MS = 4 * KEY_REPEAT_INTERVAL_MS; +const keyBarItems: KeyBarItem[] = [ + { type: "key", key: "Escape" }, + { type: "modifier", modifier: "ctrl" }, + { type: "key", key: "ArrowLeft" }, + ...["ArrowUp", "ArrowDown", "ArrowRight", "Tab", "Enter", "Backspace", "Delete", "Home", "End", "PageUp", "PageDown", "F1", "F2", "F3", "F4"].map((key): KeyBarItem => ({ type: "key", key })), +]; + +type InputFrame = + | { type: "keys"; pane_id: string; keys: string[] } + | { type: "input"; pane_id: string; text: string }; +interface Recorder { + keyBarFrames: InputFrame[]; + keyBarReleaseFrameCount: number | null; +} +interface Point { x: number; y: number } + +const framesOf = (page: Page): Promise => page.evaluate(() => (window as unknown as Recorder).keyBarFrames); +const framesSince = async (page: Page, before: number): Promise => (await framesOf(page)).slice(before).filter((frame) => frame.pane_id === panes.api); +const sentKeys = (frames: InputFrame[]): string[] => frames.flatMap((frame) => frame.type === "keys" ? frame.keys : []); +const leftKeys = (frames: InputFrame[]): string[] => sentKeys(frames).filter((key) => key === "left" || key.endsWith("+left")); +const settled = (page: Page): Promise => page.evaluate(() => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(() => done())))); +const touch = (cdp: CDPSession, type: "touchStart" | "touchMove" | "touchEnd", point?: Point): Promise => + cdp.send("Input.dispatchTouchEvent", { type, touchPoints: point ? [{ x: point.x, y: point.y }] : [] }); + +async function centerOf(key: Locator): Promise { + await key.scrollIntoViewIfNeeded(); + const box = await key.boundingBox(); + assert.ok(box, "the key is visible for the touch gesture"); + return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) }; +} + +/** Returns the frame boundary captured on pointerup, before React handles the release click. */ +async function hold(page: Page, cdp: CDPSession, key: Locator, duration: number, during?: () => Promise): Promise { + const point = await centerOf(key); + await page.evaluate(() => { (window as unknown as Recorder).keyBarReleaseFrameCount = null; }); + await touch(cdp, "touchStart", point); + await Bun.sleep(duration); + await during?.(); + await touch(cdp, "touchEnd"); + await settled(page); + const boundary = await page.evaluate(() => (window as unknown as Recorder).keyBarReleaseFrameCount); + assert.notEqual(boundary, null, "a stationary held finger ends with pointerup"); + return boundary!; +} + +try { + await buildDemoApp(app); + const index = join(app, "index.html"); + const html = readFileSync(index, "utf8"); + assert.match(html, /`; + writeFileSync(index, html.replace(/\n ${record}\n