Skip to content

fix(webui): C-Space sets the Playbook mark and motion extends the region - #1298

Merged
edwin-zvs merged 3 commits into
mainfrom
agentd/sbecd9e8f8fea4e49bab84f79ec995c12
Aug 21, 2026
Merged

fix(webui): C-Space sets the Playbook mark and motion extends the region#1298
edwin-zvs merged 3 commits into
mainfrom
agentd/sbecd9e8f8fea4e49bab84f79ec995c12

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

What

C-Space in the web Playbook editor now sets the mark, and the next caret motion extends the region from it — the binding the TUI has always had.

C-Space region in the web Playbook editor

Mark set at "The·|quick", then M-f ×3 → DownC-e. Status reads mark set; the selection verb menu comes up on its own once the region is non-empty. (full-window shot)

Root cause

Not a swallowed keystroke — C-Space reached the page fine. The web editor had no mark at all.

playbookOnKeyDown bound exactly one of the TUI's movement keys (C-f) and had no notion of a region. C-Space fell straight through to the contenteditable underneath, where Chrome did the only thing it could with it: typed a literal space into the document. So the binding didn't just fail — it corrupted the playbook you were composing.

The TUI has had the full contract since the Playbook popup shipped: begin_playbook_selection sets anchor == head == cursor, and every motion arm calls update_playbook_selection_head. Two clients, one keymap, one of them missing half the vocabulary.

Ruled out along the way, since the brief flagged them:

  • The document-level capture keymap (handleChordKey) doesn't touch it — keyToken maps Ctrl+Space to C-space, which is bound to nothing and is not a prefix of the C-x Space chord, so it returns false and the event continues to the editor's own listener.
  • macOS can eat C-Space as the input-source switcher, but that's above the browser and no client can override it. Confirmed the keydown arrives and is prevented (below).

Fix

Give the web editor the same contract, built on the browser's own selection instead of a shadow offset pair. The DOM selection's anchor is the mark, so extending is just Selection.modify("extend", …) — nothing has to stay in sync with a DOM the renderer rewrites underneath us.

  • C-Space arms the region at the caret, with preventDefault() so the contenteditable never sees a space.
  • While armed, every motion extends from the mark: arrows, Home/End, C-f/C-b/C-n/C-p/C-a/C-e, plus word / line-boundary / document-boundary motions under each platform's modifier. Alt combos match on e.code, because macOS turns M-f into ƒ and M-b into in e.key.
  • C-g and Escape cancel — caret stays put, document untouched. C-g peels the clip menu and Find first, like the TUI's.
  • Copy/cut act on the region (it's a real DOM selection, so the platform clipboard already does) and then disarm the mark.
  • The region also ends on an edit, a pointer press, blur, or mounting another session's Playbook.

Adds spec 0206 for the cross-client contract. It's deliberately scoped so it doesn't fight spec 0059 (web parity is by capability, native affordances stay the baseline): it constrains only the mark bindings a client chooses to offer — but requires the whole contract rather than a fragment, since a half-implemented mark is what produced this bug.

Verification

Not eyeballed. Driven in a real Chrome over CDP against a hot-reloaded daemon (CONSTRUCT_ASSETS_DIR), with trusted Input.dispatchKeyEvent — a synthesized KeyboardEvent never runs the default action, which is the entire bug.

after C-Space:   {"probe":[{"key":" ","ctrl":true,"defaultPrevented":true}],"mark":true,"msg":"mark set","sel":""}
after 9x Right:  {"sel":"The quick","mark":true}
after 6x C-f:    {"sel":"The quick brown"}
after C-e:       {"sel":"The quick brown fox jumps over the lazy dog."}
after C-n:       {"sel":"...dog.\nSecond line of the playbook body here.","menuHidden":false}
after C-g:       {"sel":"","mark":false,"msg":"mark cleared"}
post-cancel →:   {"sel":""}
TEXT UNCHANGED:  True

Each motion also checked individually (C-b, C-p, C-a, C-e, M-f, M-b, Home, arrows), plus no-regression checks: typing replaces the region and disarms, click disarms, mouse drag still selects natively, Escape cancels, copy returns the region and disarms.

One thing worth naming: reversing direction across the mark (C-e then C-a) selects the whole line rather than collapsing. That's Chrome's own Selection.modify behavior under macOS editing semantics — a plain contenteditable does exactly the same on Shift+EndShift+Home, verified side by side. Matching the platform is the right answer here, so it's left alone.

Test

crates/e2e/tests/web_smoke.rs gains a mark section that drives set → extend → cancel with real CDP key events (added press_key_chord, since press_key can't carry modifiers), asserting defaultPrevented, the extended text, the selection menu, and that the document never changes.

Confirmed non-vacuous: with the C-Space branch commented out, the new assertions fail —

assertion failed: C-Space must reach the Playbook handler and be prevented:
{"mark": false, "menuVisible": false, "selection": "", "setMarkPrevented": false}

(Also caught that web_smoke was silently skipping locally on a stale chromiumoxide-runner/SingletonLock — worth knowing when reading a green local run of that file.)

cargo test --workspace green: 57 targets, 0 failures.

Binary

Web-UI only — the change is crates/daemon/assets/index.html, embedded into the daemon, which lives in the construct binary. Nothing else moves.

The web Playbook editor had no mark at all. `C-Space` fell through
`playbookOnKeyDown` — which bound only `C-f` out of the TUI's movement
keys — to the contenteditable underneath, where Chrome took it as a
literal space and typed one into the document. The TUI has had the full
emacs contract for this since the Playbook popup shipped
(`begin_playbook_selection` + `update_playbook_selection_head`), so the
two clients disagreed on a binding the shared keymap implies.

Give the web editor the same contract, built on the browser's own
selection rather than a shadow offset pair: the DOM selection's anchor
*is* the mark, so extending is `Selection.modify("extend", …)` and
nothing has to stay in sync with a DOM the renderer rewrites.

- `C-Space` arms the region at the caret, with `preventDefault` so the
  contenteditable never sees the space. Verified against a trusted CDP
  key event, not a synthesized one — the default action is the bug.
- While armed, every motion extends from the mark: arrows, Home/End,
  `C-f`/`C-b`/`C-n`/`C-p`/`C-a`/`C-e`, and word / line-boundary /
  document-boundary motions under each platform's modifier. Alt combos
  match on `code` because macOS turns `M-f` into `ƒ`.
- `C-g` and `Escape` cancel, leaving the caret put and the document
  untouched. Copy and cut act on the region and then disarm the mark.
- An edit, a pointer press, blur, or mounting another session's Playbook
  ends the region; native mouse and shift selection are untouched.

Adds spec 0206 for the cross-client contract, and a web_smoke section
that drives real CDP key events through set → extend → cancel. Confirmed
non-vacuous: disabling the `C-Space` branch fails the new assertions.
@edwin-zvs
edwin-zvs merged commit 86295d0 into main Aug 21, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the agentd/sbecd9e8f8fea4e49bab84f79ec995c12 branch August 21, 2026 16:29
edwin-zvs added a commit that referenced this pull request Aug 21, 2026
0206 was taken by the Playbook mark-and-region spec (#1298) while this
branch was open.
edwin-zvs added a commit that referenced this pull request Aug 21, 2026
* feat(tui): replay the focus sweep when typing resumes after idle

Focus acquisition answers "where did my focus go" at the instant it
moves. It says nothing to a user coming back to a terminal they left
minutes ago, where a fleet of similar panes gives the eye nothing to
lock onto. Replay the same sweep on the already-focused pane when a
keystroke reaches it after a long silence.

The transition animates, not the typing: every key the focused pane
consumes refreshes the idle clock, and a key landing while a sweep is
already on screen is absorbed, so focus gain and idle resume can never
stack. Keys swallowed by a modal, popup, or the prompt strip are not
pane input and animate nothing.

Threshold: 120s, in a named constant beside the other sweep constants,
with a CONSTRUCT_FOCUS_SWEEP_IDLE_MS override for recordings.

* chore: temporary PR media for the idle focus sweep

* chore: drop temporary PR media

* chore(specs): renumber the idle focus-sweep spec to 0207

0206 was taken by the Playbook mark-and-region spec (#1298) while this
branch was open.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant