Skip to content

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
feat/run-mode-tuifrom
integration/menu-plus-run-modes
Open

feat(tui): Local · Cloud · Fusion in the ctrl+p menu, with ctrl+g 1/2/3 to pick one#172
plombeer31 wants to merge 6 commits into
feat/run-mode-tuifrom
integration/menu-plus-run-modes

Conversation

@plombeer31

Copy link
Copy Markdown
Collaborator

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.

Stacking. This is a diamond: it needs both #163 (the run modes) and #170#171 (the menu) as ancestors, and a PR can only have one base. Base is feat/run-mode-tui, so GitHub shows the menu stack in the diff as well. The reviewable delta is the single integrate: commit — everything before it is #170 and #171, already reviewed there.

What you get

RUN
  Run type…                                     
  Local                  Run type      ctrl+g 1
  Cloud                  Run type      ctrl+g 2
  Fusion                 Run type      ctrl+g 3
  • Browsable under Run type ▸, one level deep like Observe and Manage.
  • Searchable — typing run in the menu surfaces all three with a Run type breadcrumb.
  • Live state — the Run type row shows the mode in force (fusion · 40% cloud), so the menu answers "what am I on" as well as "what can I switch to".
  • Direct keysctrl+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. slash means this node is listed in the palette and activated by running /name; the new command means activated by running this literal line, and is not listed:

{ kind: "action", id: "run.mode.fusion", label: "Fusion",
  group: "run", parent: "run.mode", chord: "3", command: "/run fusion" },

So the palette still lists /run once 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:

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_COMMANDS and made that the expected value:

it("derives exactly the palette #163 declares — same commands, same order")

If the registry and #163 ever disagree about a command, its description, its aliases or its position, this fails. It passes exactly today.

Verification

…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.
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