Skip to content

feat(tui): every visible control takes a click — menu rows, run-mode pills, the dial - #173

Open
plombeer31 wants to merge 3 commits into
integration/menu-plus-run-modesfrom
integration/mouse-menu-run-modes
Open

feat(tui): every visible control takes a click — menu rows, run-mode pills, the dial#173
plombeer31 wants to merge 3 commits into
integration/menu-plus-run-modesfrom
integration/mouse-menu-run-modes

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

Stacked on #172, and merges #165 (feat/tui-mouse) so the two can meet. Review #172 first; the commits that belong to this PR are the last two.

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

TuiApp raises the mouse registry's layer floor whenever a modal opens, so a click can never reach the list rendered behind it. Neither the ctrl+p menu nor the run-mode picker registered its own targets at MOUSE_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

Surface Gesture
Menu rows One click acts. A submenu row opens the submenu, same as
Run-mode pills (Local · Cloud · Fusion) Click switches mode
The pill already in effect Opens the dial instead of re-applying the mode
The cloud-share dial It is a slider — clicking column N sets the share that column represents
Run-mode picker rows Two-step: click selects, second click applies
The breadcrumb Opens the menu

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

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.

Valerii and others added 3 commits August 19, 2026 05:29
…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.
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