feat(tui): Local · Cloud · Fusion in the ctrl+p menu, with ctrl+g 1/2/3 to pick one - #172
Open
plombeer31 wants to merge 6 commits into
Open
feat(tui): Local · Cloud · Fusion in the ctrl+p menu, with ctrl+g 1/2/3 to pick one#172plombeer31 wants to merge 6 commits into
plombeer31 wants to merge 6 commits into
Conversation
…tion of it The TUI has no keymap, no help overlay and no keybinding doc; what it has instead is several hand-kept parallel lists of the same surface, which have measurably drifted apart. Two of the five TUI test failures on main right now are exactly that: a Shift+Tab test still asserting the pre-`import`/`privacy` tab order, and a splash banner asserting `/observe /manage /run` which the splash stopped printing. This lands the single list those surfaces should be derived from, and converts the first consumer. `src/tui/menu/menu-registry.ts` declares every destination and every verb once: id, label, group, optional `ctrl+g` chord, optional slash command. Three node kinds cover the whole TUI — `place` (a section or a tab), `submenu` (exactly one level deep), `action` (a verb). Nodes are pure data: a node that does something carries a slash name and is activated by running that command, so the menu will never grow a second dispatch path alongside `slash-command-handler.ts`. `SLASH_COMMANDS` is now `toSlashCommands()` rather than a literal. Palette order is user-visible — an empty query lists the registry as-is and fuzzy-search ties break by index — so it is carried explicitly on `MenuSlash.rank` and preserved exactly. No behaviour changes. `menu-registry.test.ts` pins the derived palette against a snapshot of the v0.2.2 list, so "no visible change" is checked by the suite rather than promised in a description. The remaining tests turn the properties the old lists could not enforce into build failures: unique ids, unique chords, unique slash names and aliases, unique ranks, every parent a real submenu, no tree deeper than one level, no empty submenu. The `chord` fields are declared here and consumed in a follow-up that adds the `ctrl+g` leader; the uniqueness test is live from this commit. Verified: `npm run lint` clean; `npx vitest run src/tui` shows the same five pre-existing failures as main, plus three that pass in isolation and fail only under parallel load (`llm-health-poller` ×2, one `tui-app` smoke).
…here Reaching Manage → Privacy from chat took fourteen Tab presses, and the fourteen destinations plus every verb were discoverable only by reading the source. This adds the browsable half of the navigation surface, rendered from the registry landed in the previous commit. **ctrl+p** opens a menu above the prompt, on the prompt's own left rail rather than as a full-screen takeover, so it reads as belonging to the input you were already typing in. Groups come from the registry; `Go` mirrors the product's own Run / Observe / Manage split, and Observe / Manage are submenus exactly one level deep. Destinations carry live counts read from the same state slices the sub-tab strip already counts, so opening the menu costs a few array lengths and never a refresh. **Typing flattens the tree.** Hierarchy is for browsing; a query ranks across the whole registry and drops whatever submenu you had walked into, with a breadcrumb on each hit. This is why the list is navigated with the arrows only and never with j/k — the letters belong to the search box. **ctrl+g then a key** jumps directly. The leader exists so the chord namespace stays disjoint from the panels' own letter hotkeys (`r` refresh, `a` add, `d` remove …) — nothing had to be renamed to make room. An unclaimed chord is swallowed rather than passed on, so a mistyped leader cannot leak a letter into the prompt or trip a panel hotkey. `ctrl+g` rather than the `ctrl+x` opencode uses: `ctrl+x` is emacs' prefix and some terminals eat it. Activation runs the node's slash command where it has one, so the menu is a second door onto `slash-command-handler.ts` and never a second dispatch path. `/` keeps working unchanged. **The backdrop dims** while the menu is open. Implemented as one flag on the `theme` proxy — the same read-at-render machinery that makes `/theme` live-preview repaint everything — rather than threading a `dimmed` prop through every component. Every colour collapses to the active theme's `muted`: a terminal has no alpha channel, so "faded" has to mean one low-contrast tone. The menu reads `chromeTheme`, which ignores the flag, and stays at full contrast. Verified against a real terminal: distinct foreground colours drop from four to two when the menu opens. The hint strip's `/ commands` chip becomes `ctrl+p menu` — the menu is a superset, and the strip is capped at six chips. ### The Ink hazard this had to be built around Ink delivers every keypress to *every* live `useInput`, **child first**, so the prompt editor's handler runs before the app's and a `return true` upstream cannot stop it. A chord letter would therefore be typed into the prompt as well as consumed. The editor is unfocused while the menu is open *and* while the leader is armed; `tui-app.test.tsx` asserts the letter never lands in the buffer, so a regression here fails the build rather than being noticed later. ### Verification - `npm run lint` clean. - 14 new unit tests (`menu-behaviour.test.ts`) plus 4 integration tests in `tui-app.test.tsx`, covering open, search, submenu in/out, activation, key swallowing, paste bursts, escape-fragment rejection, and the chord path. - `npx vitest run src/tui`: the same five pre-existing failures as main, plus two that pass in isolation and fail only under parallel load. No new failures. - Run against a real PTY at 100×30: menu renders, search filters, `ctrl+g t` lands on Manage → Tasks, backdrop dims. One bug this caught during development: the search box originally accepted only single characters, so a paste — which arrives as one input event — was silently swallowed. It now takes a whole burst and rejects escape-sequence fragments per code point.
The menu was rendered inline in the content column, so opening it pushed the chat log and everything below it around. A popup should composite on top, the way a modal does in a browser. It now sits in the content pane with `position="absolute"`, anchored to the pane's bottom edge so it still hangs off the prompt, and it caps its own height to the rows the pane actually has. Terminals have no compositing and Ink has no z-index, so occlusion has to be earned: every interior line is padded to the popup's exact inner width, which paints spaces over whatever was underneath. That is also why the rows are laid out as fixed-width columns instead of with `flexGrow` — a flexed row stops at its content and lets the background bleed through. Ink's own `paddingX` is not painted by our rows either; it leaves real gaps at both edges that the backdrop showed through as a ragged column of debris down each side. The one-column gutter is now baked into the padded strings instead. A background colour would do the same job in a line, but only by choosing a colour, and the TUI ships eleven themes across light and dark grounds. Spaces are theme-agnostic. `tui-app.test.tsx` pins the property: opening the menu must not change the frame's row count or its last line, so an inline regression fails the build. Verified in a real PTY at 100×30: with the menu open the splash art behind it stays exactly where it was, the prompt and hint strip do not move, and the popup shrinks around a search result instead of resizing the screen.
The three-section pill row (`Run · Observe · Manage`) was a menu drawn into the header — and a bad one, because it could only ever list three of the fifteen destinations and had no keys attached to it. The menu now lives behind `ctrl+p`, where it holds all of them. What is left is a breadcrumb — `Manage › Tasks` — which is the one thing the popup cannot tell you, because you have to open it to read it. The tab half comes from the registry (`menuPlaceByTab`), so a renamed destination renames in the header too. The smoke tests were using the pill row as their way to detect the active section, so they now assert the breadcrumb instead. One of them asserts the pills are *gone*, which is the actual behaviour change.
Merges feat/run-mode-tui (#163) into the menu branch so Local / Cloud / Fusion are reachable the same way everything else is: Run type fusion · 40% cloud Run type > Local ctrl+g 1 Cloud ctrl+g 2 Fusion ctrl+g 3 Adds a second activation channel to the registry: `command` runs a literal line (`/run fusion`) where `slash` runs the bare one. The palette lists /run once, not once per mode. Conflicts resolved: slash-commands.ts keeps the derived form and gains /run through the registry (and `chat` loses its `run` alias, matching #163); hotkey-hint keeps the ctrl+p chip and #163's note about ctrl+r. The palette test is repointed from a v0.2.2 snapshot to #163's own hand-written list, so it now cross-checks two independent views of the command surface.
Hand-testing feedback: the popup should read as a modal — the app fades and the menu appears in the middle of the window, the way a web app would do it — rather than hanging off the prompt like a dropdown. So it is centred in the content pane on both axes. Two details: - Centring measures the chat column, not the terminal. With the sidebar on screen the old width ran the popup under the rail and clipped its right border, which looked like a rendering bug. - Vertical centring uses the same pane rows the overlay already sizes itself against, so nothing else had to move. Also fixes the two assertions #170's body named as evidence that the TUI kept several hand-maintained parallel lists which had drifted, and deliberately left alone because "fixing them belongs with the commit that changes the behaviour they describe". This branch is that commit — it is what replaced the section pills with a breadcrumb: - Shift+Tab from Run wraps to the last Manage tab, which has been Privacy since MANAGE_TABS gained import and privacy — not Telegram. - The LLM panel test wanted "Active chat route", "Mode:" and "Press left/right to switch mode"; with no provider configured the panel shows its section headings and a compact footer instead.
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.
Closes the gap you flagged: the run modes existed (#161 → #162 → #163) but the operator menu did not know about them, so there was no way to reach them by browsing and no direct key to pick one.
What you get
Run type ▸, one level deep like Observe and Manage.runin the menu surfaces all three with aRun typebreadcrumb.Run typerow shows the mode in force (fusion · 40% cloud), so the menu answers "what am I on" as well as "what can I switch to".ctrl+g 1/2/3. Digits were unclaimed in the leader namespace and read naturally as "pick option N".ctrl+r(cycle) and/run(dial picker) from feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial #163 are untouched.How it is wired
The registry gains a second activation channel.
slashmeans this node is listed in the palette and activated by running/name; the newcommandmeans activated by running this literal line, and is not listed:So the palette still lists
/runonce rather than once per mode, and the menu still has exactly one dispatch path —slash-command-handler.ts. Nothing in this PR reimplements run-mode logic; every entry goes through #163's own command, including its degradation rules.Conflicts resolved
Two, both in files the menu stack had already touched:
slash-commands.ts— kept the derived form from refactor(tui): one menu registry — the slash palette becomes a projection of it #170;/runnow arrives through the registry, andchatloses itsrunalias to match feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial #163.hotkey-hint.tsx— kept thectrl+p menuchip and feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial #163's note thatctrl+rstays unadvertised because the mode strip above the chat is its visible entry point.The palette test is now a cross-check, not a snapshot
#170's test pinned the derived palette against a v0.2.2 snapshot. Here the palette legitimately changes, so rather than blessing my own output I extracted #163's hand-written
SLASH_COMMANDSand made that the expected value:If the registry and #163 ever disagree about a command, its description, its aliases or its position, this fails. It passes exactly today.
Verification
npm run lintclean.npx vitest run src/tui— 1041 passing; the same pre-existing failures asmain, none new. feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial #163's own run-mode suites (run-mode-nav,run-mode-reducer,run-mode-key-bindings) all pass unchanged.runlists all three modes with their chords, andctrl+g 3with no cloud provider configured produces feat(tui): Run submenu — Local · Cloud · Fusion with a fusion share dial #163's own degradation message — "Fusion needs a cloud orchestrator — no cloud provider is configured. Staying on local." — which is the proof the chord dispatches through the real path rather than a parallel one.