Skip to content

feat(input): unify TUI and web shortcuts, add project cycling and tab moves - #33

Merged
code0xff merged 17 commits into
code0xff:devfrom
whackur:feat/semantic-shortcuts
Sep 2, 2026
Merged

feat(input): unify TUI and web shortcuts, add project cycling and tab moves#33
code0xff merged 17 commits into
code0xff:devfrom
whackur:feat/semantic-shortcuts

Conversation

@whackur

@whackur whackur commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

What

Gives the same commands the same meaning in the TUI and the browser, and adds two ways to move between and rearrange projects.

TUI

  • Ctrl+Shift+Left / Ctrl+Shift+Right step to the previous/next project, wrapping. The F-keys jump to a tab by number, which does not reach past ten and says nothing about where the neighbours are.
  • <leader> [ / <leader> ] move the active tab one slot, without wrapping — wrapping is what the stepping chord means, and a held key would otherwise shuffle the strip. Bare brackets still belong to the PTY.

Web viewer

  • A leader (Ctrl+F by default) with the same follow-up keys, plus the project chords.
  • Commands are named as semantic actions in one registry rather than copied from the Rust key table. The registry records, per command, whether the browser keeps the TUI's meaning, reinterprets it, or has no answer — f becomes panel maximize and terminal zoom rather than F11, and redraw, detach and the bare F1F10 tab selection are deliberately unbound.
  • A shortcut sheet generated from that registry, so a new action cannot be added without appearing in it. Every row runs its action, which is the only non-keyboard path to focusing the list and the content pane. The leader can be rebound or switched off, and the collision with the browser's Find is stated rather than silently taken.

Why it is shaped this way

The physical keys do not survive the trip to a browser: F-keys belong to the browser, Ctrl+F to Find, Ctrl+Shift+Arrow to word selection inside a field. Copying the TUI table would have made the two drift and claimed keys the page never receives.

There is exactly one keyboard decision point, a capture-phase listener on document. Two listeners cannot agree on whether a key was claimed, and the one that loses either eats a keystroke the pane needed or leaks a command into the shell as an escape sequence. Because xterm reads keys from its own textarea below document, stopping there is what keeps a claimed chord from reaching the PTY, while an unclaimed one is left completely untouched.

Every action binds to the handler its button already calls. Panel commands live below the page, so the panel registers them on a bus — which is also what makes availability answerable from one place, so the keyboard and the sheet agree about what works on the current screen.

Switching and reordering stay requests. Nothing is rearranged locally; the tabs move when the daemon rebroadcasts the set.

Fixes found along the way

  • The front tab did not follow its repository across a reorder. Which project is in front is tracked apart from the order, but while nothing has been focused it falls back to whichever repository is served first — so reordering handed the front tab to a different project. A fresh session started with --repo and a single move was enough. The web viewer's drag-and-drop reorder went through the same operation and had the same defect.
  • The pin was applied on the wrong side of the catalog mutation. The watcher reads the session on its own tick, so one landing between the two statements broadcast the new order while the fallback still named the old first tab.
  • The keyboard's list switch was weaker than the buttons'. <prefix> l / <prefix> b reached a setter that left the previous list's diff in the content pane and did not reset the log snapshot — one command with two implementations, now one pure function both callers go through.
  • The leader stayed armed when the keyboard was switched off, so a session that expired mid-sequence swallowed the first key typed after signing back in.

ClientMessage::ReorderRepos had no test coverage at all, and nothing watched a request leave the client; both gaps are closed.

Notes for review

  • aria-keyshortcuts is emitted only for real chords. A space in it means alternative, so writing a leader sequence there would tell assistive technology that the follow-up key works alone. The sequence lives in the title and the sheet instead.
  • The leader preference is per-browser and stays in browser storage, not viewer.json — it is not shared session state.
  • Known and not addressed: with a whole-order protocol, a client whose tab list is a beat behind can send an order that pushes a repository it has not seen yet to the back of the strip. A relative-move message or a generation guard would fix it, and both are protocol changes.

Verification

cargo fmt --all --check, cargo build, cargo test (1760 passed), cargo clippy --all-targets --all-features -- -D warnings, npm --prefix viewer-ui test (659 passed), npm --prefix viewer-ui run build, and docker compose run --rm unix-gate. viewer-ui/dist was rebuilt and a repeat build reproduces it byte for byte.

Stepping between project tabs had no chord of its own: the F-keys jump
to a tab by number, which does not reach past ten and says nothing about
where the neighbours are.

The step is sent as a direction, not an index. The handler holds one
project, so the wrap needs the tab count and which tab is in front, and
both are only known where the tab list is. Switching stays a request:
the tab moves when the daemon rebroadcasts the set.
The browser had no way to reach a neighbouring project from the
keyboard. The TUI answers with bare F-keys, which the browser and the OS
have already claimed, so the web takes the same chord the TUI now uses
for stepping instead of copying its key table.

The switch goes through the existing selectRepo, so pane clear, active
repo persistence and the per-project view restore are the same as a
click. The listener captures on document: xterm reads keys from its own
textarea below it, so stopping there is what keeps a claimed chord from
reaching the PTY, while an unclaimed one is left completely untouched.
Copying the TUI key table into the browser would make the two drift, and
the physical keys do not survive the trip: F-keys belong to the browser,
Ctrl+F to Find, and Ctrl+Shift+Arrow to word selection inside a field.

So the registry names user actions instead of keys, and records for each
whether the browser keeps the TUI's meaning, reinterprets it, or has
none. Keyboard, help and buttons can then read one table. The classifier
and the leader reducer are pure and DOM-free: IME composition, key
repeat, exact modifiers and every way a leader must disarm are decided
where they can be tested without a browser.
Which project is in front is tracked apart from the order, but while
nothing has been focused it falls back to whichever repository is served
first — so reordering handed the front tab to a different project. A
fresh session started with --repo and a single move was enough: the tab
moved and the front moved with the slot instead of with the repository.

The fallback is now resolved before the slots move and pinned to what it
named, conditionally, so a focus arriving in between still wins. The web
viewer's drag-and-drop reorder went through the same operation and had
the same defect.
Tab order was reachable only from the browser's drag-and-drop. The TUI
could switch among tabs but not rearrange them, and the daemon already
owned the whole contract.

The keys sit behind the leader, never bare: unmodified characters belong
to the PTY, and taking the brackets there would break terminal input.
The whole new order is asked for and nothing is rearranged locally, as
switching does — the tabs move when the daemon rebroadcasts the set.
Neither end wraps; wrapping is what the stepping chord means, and a held
key would otherwise shuffle the strip.
The registry and the classifier had no caller. This wires them up and
folds the project-cycling chord in, so there is exactly one listener:
two of them cannot agree on whether a key was claimed, and the one that
loses either eats a keystroke the pane needed or leaks a command into
the shell as an escape sequence.

Every action binds to the handler its button already calls. Panel
commands live below the page, so the panel registers them on a bus,
which is also what makes availability answerable — the keyboard and the
help sheet read the same answer. The leader clears itself on focus loss,
on a dialog, on a project switch and on a reconnect, so the key after it
is never swallowed. Pressing it twice sends the literal chord to the
pane, as the TUI does.
A shortcut nobody can find is not a feature, and the default leader is
the browser's Find key — taking it silently would be worse than not
taking it at all.

The sheet is generated from the registry, so a new action cannot be
added without appearing in it, and it names what the browser
reinterprets and what it has no answer for. Every row is a button that
runs its action: that is the non-keyboard path for focusing the list and
the content pane, which have no other control. Rows unavailable on the
current screen say so, from the same answer the keyboard reads.

aria-keyshortcuts is emitted only for real chords. A space in it means
alternative, so writing a leader sequence there would tell assistive
technology that the follow-up key works alone, which it does not; the
sequence is in the title and the sheet instead.
The web viewer binds the same commands to different keys, which is a
thing a reader has to be told rather than left to discover: the keys the
TUI uses are free because the terminal hands them over, and a browser
has already spent most of them.
The pin was applied after the catalog was reordered. The watcher reads
the session on its own tick, so one landing between the two statements
broadcast the new order while the fallback still named the old first
tab — the front-tab jump the pinning exists to prevent, adopted by every
client and corrected a tick later.

Also drops a panic path in the client's order arithmetic: both ends are
now bounded by the order just built, so the backward step cannot address
past it. And a request that leaves a tab out is explained by what the
session actually does with it — omitted repositories are appended, not
dropped, as the comments claimed.

The new interface had no test that watched a request leave the client at
all, so the two added here drive the link against a real daemon and read
the tab strip that comes back.
session_link.rs was six lines under the cap, so the next change to it
would have breached one. The four free functions there are pure or take
only the workspace and touch nothing on the link, which is what lets the
tests drive them without a daemon — the split says so.
Both were implemented without being written down: the architecture index
still described the leader as the only place app commands live, and the
web surface said nothing about where a keystroke is decided or which
side owns the leader preference.
Choosing a list from the keyboard reached a weaker setter than the tab
buttons do, so the previous list's diff stayed in the content pane, an
in-flight pane request could land after the switch, and leaving the log
did not reset its snapshot. The claim that a shortcut runs the control
the button runs was not true here, and one command had two
implementations — the order now lives in one pure function that both
callers go through.

The leader also stayed armed when the keyboard was switched off. The
login screen renders without unmounting the layer, so a session that
expired mid-sequence swallowed the first key typed after signing back
in.
The pane-swap row was a button that closed the sheet and did nothing:
the command arms a second step, and a click has no next key to offer.
The registry now records that one action as keyboard-only and the row
renders as text, so availability and what a click does agree again.

The flag is deliberately narrow — arming a second step is the only
reason a row is not a button, and an ordinary action whose row does
nothing is a missing handler, which is a bug to fix rather than a state
to describe.
The panel's call sites had been compressed onto single lines to keep the
file under the cap, against the convention in the rest of it. Restoring
the formatting put it over, so the socket and view wiring moves out —
the seam the file was already asking for.

The move keeps hook call order identical, which matters here because
effects run in that order.
@code0xff
code0xff merged commit 3b130ba into code0xff:dev Sep 2, 2026
6 checks passed
@whackur
whackur deleted the feat/semantic-shortcuts branch September 2, 2026 01:12
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.

2 participants