Skip to content

feat: Let's make a nice TUI#309

Open
daniloc wants to merge 18 commits intomainfrom
tui
Open

feat: Let's make a nice TUI#309
daniloc wants to merge 18 commits intomainfrom
tui

Conversation

@daniloc
Copy link
Collaborator

@daniloc daniloc commented Mar 2, 2026

Premise

The core architectural principle: the rendered screen is a pure function of session state. Nobody imperatively pushes screens around. Business logic sets state through store setters, and the router derives which screen should be active. If a screen appears at the wrong time, the bug is in a predicate – not in a scattered clack call somewhere.

Session: source of truth

Everything the wizard needs to render lives in one typed object: WizardSession. It gets populated in layers – CLI args fill what they can, auto-detection fills more, TUI screens collect the rest, OAuth provides credentials. By the time the agent starts, the session is populated.

Business logic reads session fields and writes them through the WizardUI interface (getUI().setCredentials(), getUI().startRun(), etc.), which bridges to store setters.

Session fields that affect screen resolution – cloudRegion, runPhase, credentials, frameworkConfig, outroData, serviceStatus, loginUrl, detectedFrameworkLabel, frameworkContext — are only mutated through explicit store setters like store.setRunPhase(), store.setCredentials(), etc. Direct session mutation outside the store is a bug.

Router: reactive resolution plus overlays

The WizardRouter owns two things: flow definitions (declarative pipelines) and overlay interrupts (outage, auth-expired, etc.).

Flows are arrays of entries, each with a screen and an isComplete predicate:

[Flow.Wizard]: [
  { screen: Screen.Boot,  isComplete: (s) => s.frameworkConfig !== null },
  { screen: Screen.Intro, isComplete: (s) => s.cloudRegion !== null },
  { screen: Screen.Setup, show: needsSetup, isComplete: (s) => !needsSetup(s) },
  { screen: Screen.Auth,  isComplete: (s) => s.credentials !== null },
  { screen: Screen.Run,   isComplete: (s) => s.runPhase === Completed || s.runPhase === Error },
  { screen: Screen.Mcp,   isComplete: (s) => s.runPhase === Done },
  { screen: Screen.Outro },
]

The router's resolve(session) method walks the array, skipping entries where show() returns false or isComplete() returns true, and returns the first incomplete screen. There is no cursor. There is no advance(). The active screen is resolved fresh on every React render cycle from the current session state. Bugs live in the completion predicate, not buried in some surprising crevice.

Overlays

Overlays are a separate stack for interrupts. store.pushOverlay(Overlay.Outage) puts the outage screen on top of whatever flow screen is active. store.popOverlay() dismisses it and the flow resumes. Overlays don't break the active flow.

The visible screen is: top of overlay stack if non-empty, otherwise the resolved flow screen.

Store: setters + change notification

WizardStore is an EventEmitter that React components subscribe to via useSyncExternalStore. It holds the router, the session, and observable agent state (tasks, status messages).

Every session mutation that affects screen resolution goes through an explicit setter: setCloudRegion(), setRunPhase(), setCredentials(), setFrameworkConfig(), setDetectedFramework(), setLoginUrl(), setServiceStatus(), setOutroData(), setFrameworkContext(). Each setter mutates the session field and calls emitChange(), which increments the version counter and triggers React re-renders. On the next render, store.currentScreen calls router.resolve(session) and returns the correct screen.

Screens never call navigation methods. They set the session fields they own through store setters, and the router calculates the rest.

Screen registry + flow

Screens are registered in screen-registry.tsx, a factory function that maps screen names to React components and wires up services. App.tsx is a thin shell that calls the factory. Adding a new screen means:

  1. Create the component in screens/
  2. Add an entry to screen-registry.tsx
  3. Add a FlowEntry with an isComplete predicate to the flow in router.ts

App.tsx never changes. If a new screen needs new reactive session state, add a setter to the store.

Screen names, overlay names, flow names, run phases, and outro kinds are all TypeScript enums — no string comparisons anywhere in the navigation or session layer.

Is there a skill file for this?

Come on.

Thoughts?

What do you think? It's working pretty well and I like how tidy changes are, but it's a big shift, so lmk how this lands.

@daniloc daniloc requested a review from a team March 2, 2026 23:30
@daniloc daniloc changed the title Let's make a nice TUI feat: Let's make a nice TUI Mar 3, 2026
@gewenyu99
Copy link
Contributor

This is gonna be such a fun review tmr 😈

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