feat(tui): every visible control takes a click — menu rows, run-mode pills, the dial - #173
Open
plombeer31 wants to merge 3 commits into
Open
Conversation
…he prompt
The TUI had no mouse layer at all: Ink parses stdin as keystrokes only,
and `tui-command.ts` deliberately left SGR mouse tracking off because
capture takes the terminal's own drag-to-select away (Apple Terminal has
no Shift-bypass). That trade-off is real, but it is a *setting*, not a
reason to have no mouse at all — so mouse reporting is now on by default
with three ways to turn it off, and everything the keyboard can reach is
clickable.
New `src/tui/mouse/`:
- `mouse-tracking.ts` — enables 1000 + 1006 (button events + SGR
coordinates). Motion tracking (1002/1003) is deliberately not
requested: nothing hovers or drags. Paired with a `process.on("exit")`
restore so a crash never leaves the terminal reporting clicks into the
user's shell.
- `parse-mouse-events.ts` — pure decoder for SGR and legacy X10 reports.
Buffers a report split across two reads; passes a lone trailing ESC
straight through, since holding it would delay the Escape key by one
keystroke.
- `mouse-stdin.ts` — hands Ink a stream with the mouse bytes removed
(they would otherwise be typed into the chat buffer as mojibake) while
proxying isTTY / setRawMode / ref / unref to the real stdin.
- `mouse-registry.ts` — hit testing. Ink exposes no absolute positions,
but every node keeps its Yoga node, so `absoluteRect` sums
getComputedLeft/Top up the parent chain — the same walk the renderer
does when painting. Ancestors with `overflow: hidden` clip the result.
- `mouse-context.tsx` / `mouse-list-row.tsx` — React glue plus the shared
row: first click selects, a second click on the selected row activates.
Both activation and the wheel are routed through each panel's existing
`*-key-bindings.ts` handler with a synthetic Enter / arrow, so the
mouse cannot drift from the keyboard.
Wired up: Run / Observe / Manage pills and the sub-tab strip, sidebar
sessions and tasks, skills, skills hub, tasks, memory, MCP, LLM,
providers and local-model rows, the session / theme / slash pickers,
approval decision buttons, tool cards (the per-card toggle had no key
binding at all until now), clickable hotkey chips, click-to-place-caret
in the prompt, and wheel scrolling.
Delta-only cursors gained absolute `*_cursor_set` actions (sidebar,
session picker, theme picker, providers, local models, skills hub).
`isPanelModalOpen` and `decideApproval` were extracted from
`app-key-bindings.ts` so the mouse gates and resolves on exactly the same
predicates as the keyboard.
Off switch: `tui.mouse` (config v38, default true), `--mouse` /
`--no-mouse`, and `/mouse on|off` at runtime. With it off, behaviour is
byte-for-byte what it was before — alternate-scroll turns the wheel into
cursor keys.
…pills, the dial Hand-testing feedback: "mouse clicks need to work in the menu and in the popup nav menu". They did not, and the reason turned out to be one bug with three symptoms. **Modal surfaces have to register their targets at MOUSE_LAYER_MODAL.** TuiApp raises the registry's layer floor whenever a modal opens, so any target left at the base layer is ignored. The ctrl+p menu and the run-mode picker both rendered perfectly and swallowed every click. This is the load-bearing fix; the rest is wiring. Menu (#171 + #165): - Rows take a click. One click acts, unlike the two-step select-then-activate rule the rest of the mouse layer uses: there a mis-click starts a download or switches sessions, whereas here the operator opened a menu specifically to pick something. A submenu row opens the submenu, the way the right arrow does. Run modes (#163 + #165): - The Local / Cloud / Fusion strip is per-item boxes now, so each pill is measurable and clickable — the same shape the nav pills took in #165. - Clicking the mode already in effect opens the dial rather than re-applying it. Re-applying is a wasted provider swap, and on Fusion the dial is otherwise unreachable with the mouse. - The dial is a slider, so clicking column N sets the share that column represents rather than nudging by a fixed step. - Picker rows keep the two-step rule: applying a mode swaps providers. Status bar (#172 + #165): - #172 retired the section pills for a breadcrumb, which removed the only mouse route into navigation. The breadcrumb is clickable and opens the menu, so "everything visible is clickable" still holds. Five of #165's mouse tests waited on the "pill Run" marker #172 removed; they go through the breadcrumb now. Why this branch exists: no single PR can hold these changes. The mouse layer is #165, the menu is #170/#171, the run modes are #161-#163, and none of those branches contains the others — the surfaces only meet here. Same reason #172 exists.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this branch exists
Hand testing turned up "mouse clicks need to work in the menu and in the popup nav menu". No single existing PR can carry that fix: the mouse layer is #165, the menu is #170/#171, the run modes are #161–#163, and none of those branches contains the others. The surfaces only exist together here — the same reason #172 exists.
The one bug behind three symptoms
TuiAppraises the mouse registry's layer floor whenever a modal opens, so a click can never reach the list rendered behind it. Neither thectrl+pmenu nor the run-mode picker registered its own targets atMOUSE_LAYER_MODAL, so both rendered perfectly and swallowed every click that landed on them. Everything else here is wiring on top of that.What became clickable
→Local · Cloud · Fusion)Why the menu is the exception to the two-step rule. Everywhere else in the mouse layer, the first click selects and the second activates, because a mis-click there starts a model download or switches sessions. In a menu the operator has opened it specifically to pick something, so making them click twice is the surprising choice.
Why the breadcrumb is clickable. #172 retired the Run / Observe / Manage pills in favour of a breadcrumb, which removed the only mouse route into navigation. A click on it opens the menu, so "everything you can see, you can click" still holds.
Tests
▸ Runpill feat(tui): Local · Cloud · Fusion in the ctrl+p menu, with ctrl+g 1/2/3 to pick one #172 removed; they go through the breadcrumb now.npm run lintclean. Full suite: 4288 passed / 5 failed — three are environment-dependent on this machine (fs-glob-reallooks for a CV in$HOME,send-message-concurrencyandpersist-embedding-hybrid-recallflake under parallel load) and two (chat-log,splash-banner) are the pre-existing stale-copy failures refactor(tui): one menu registry — the slash palette becomes a projection of it #170's body already lists formain. No new failures.Verified in a real terminal
Driven through a PTY against the built
dist/at 120×40, 100×24, 80×30 and 60×20, rendered through a terminal emulator: the menu is centred on both axes at every size, a click on a menu row navigates, a click on a pill switches mode, and a click on the active pill opens the dial.