From b6798bdf89875c2f3e1af37650cda8ac93c32766 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Fri, 18 Sep 2026 19:48:26 -0400 Subject: [PATCH] chore(store): register flows into the store and move presentation out The store takes a Flow (steps with opaque screen keys) instead of importing the program registry; flow resolution replaces WizardRouter and the overlay stack becomes an interrupt stack with the same screen names. Presentation state (status bar, learn card, token HUD, transition direction) moves to a UiStore that watches the store. InkUI becomes StoreUI; console renderers live under tui/console and the entry point installs LoggingUI. Program content decks move to tui/programs behind PROGRAM_PRESENTATION. Every frame, flow trace, keyboard, and analytics golden is unchanged. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- bin.ts | 5 + .../__tests__/e2e-flow-snapshot.test.ts | 5 +- .../__tests__/wizard-ci-driver.test.ts | 9 +- e2e-harness/wizard-ci-driver.ts | 18 +- scripts/__fixtures__/chunk-manifest.ci.json | 439 ++++++++++------- scripts/__fixtures__/chunk-manifest.prod.json | 444 +++++++++++------- scripts/tui-host.no-jest.ts | 2 +- .../architecture/import-boundaries.test.ts | 10 +- .../architecture/known-violations.json | 20 +- src/commands/basic-integration/ci-install.ts | 2 +- src/commands/cli/__tests__/add.test.ts | 2 +- src/commands/cli/add.ts | 2 +- src/commands/doctor.ts | 2 +- src/commands/mcp/add.ts | 2 +- src/commands/mcp/remove.ts | 2 +- src/commands/mcp/tutorial.ts | 2 +- src/commands/provision.ts | 2 +- src/commands/slack.ts | 2 +- .../__tests__/flow-resolution.test.ts} | 71 ++- src/lib/flow-resolution.ts | 88 ++++ src/lib/flow.ts | 79 ++++ src/lib/interrupts.ts | 15 + .../programs/__tests__/error-tracking.test.ts | 2 +- .../programs/__tests__/flow-traces.test.ts | 14 +- .../programs/__tests__/program-step.test.ts | 17 +- .../__tests__/self-driving-detect.test.ts | 7 +- src/lib/programs/agent-skill/index.ts | 2 - src/lib/programs/ai-observability/index.ts | 2 - .../index.ts | 2 - src/lib/programs/error-tracking/index.ts | 4 - src/lib/programs/flow-for.ts | 20 + src/lib/programs/metrics/index.ts | 2 - src/lib/programs/migration/index.ts | 2 - src/lib/programs/posthog-integration/index.ts | 2 - src/lib/programs/program-registry.ts | 2 - src/lib/programs/program-step.ts | 161 +------ src/lib/programs/revenue-analytics/index.ts | 2 - src/lib/programs/self-driving/index.ts | 6 +- src/lib/programs/warehouse-source/index.ts | 2 - src/lib/run-failure.ts | 11 + .../runners/__tests__/mint-recovery.test.ts | 4 +- src/lib/runners/run-non-interactive.ts | 7 +- src/lib/runners/run-wizard.ts | 2 +- src/ui/index.ts | 9 +- src/ui/mint-failure.ts | 12 +- src/ui/null-ui.ts | 99 ++++ src/ui/store-ui.ts | 285 +++++++++++ src/ui/tui/App.tsx | 6 +- .../tui/__tests__/MintFailureScreen.test.tsx | 6 +- src/ui/tui/__tests__/WizardAskScreen.test.ts | 4 +- src/ui/tui/__tests__/exit-line.test.ts | 41 +- src/ui/tui/__tests__/frames.test.tsx | 6 +- .../{ => tui}/__tests__/headless-ui.test.ts | 11 +- .../helpers/render-screen.no-jest.tsx | 7 +- .../__tests__/keyboard-equivalence.test.tsx | 11 +- src/ui/tui/__tests__/programs.test.ts | 16 +- .../tui}/__tests__/self-driving-deck.test.ts | 2 +- src/ui/tui/__tests__/store-invariants.test.ts | 102 ++-- src/ui/tui/__tests__/store.test.ts | 111 ++--- src/ui/tui/__tests__/task-notice.test.ts | 13 +- src/ui/tui/__tests__/ui-store.test.ts | 89 ++++ src/ui/tui/components/LearnCard.tsx | 10 +- src/ui/tui/components/StatusPeekTrigger.tsx | 10 +- src/ui/{ => tui/console}/headless-ui.ts | 4 +- src/ui/{ => tui/console}/logging-ui.ts | 2 +- src/ui/tui/constants.ts | 9 +- src/ui/tui/exit-line.ts | 11 +- src/ui/tui/hooks/useUiStore.ts | 14 + src/ui/tui/ink-ui.ts | 295 +----------- src/ui/tui/playground/PlaygroundApp.tsx | 5 +- src/ui/tui/playground/demos/AiOptInDemo.tsx | 4 +- src/ui/tui/playground/demos/LearnDeckDemo.tsx | 11 +- src/ui/tui/playground/demos/RunScreenDemo.tsx | 12 +- src/ui/tui/playground/start-playground.ts | 7 +- src/ui/tui/primitives/ScreenContainer.tsx | 31 +- src/ui/tui/primitives/TabContainer.tsx | 11 +- .../programs/agent-skill/content/index.tsx | 0 .../content/index.tsx | 2 +- .../programs/error-tracking/content/index.tsx | 2 +- .../programs/error-tracking/content/tips.ts | 0 .../programs/migration/content/free-tier.tsx | 0 .../tui}/programs/migration/content/index.tsx | 6 +- .../migration/content/pricing-structure.tsx | 0 .../migration/content/vendor-stack.tsx | 0 .../posthog-integration/content/data-flow.tsx | 0 .../posthog-integration/content/funnel.tsx | 0 .../posthog-integration/content/index.tsx | 0 .../content/line-chart.tsx | 0 .../content/product-suite.tsx | 0 src/ui/tui/programs/presentation.ts | 53 +++ .../revenue-analytics/content/index.tsx | 2 +- .../programs/self-driving/content/index.tsx | 0 .../self-driving/content/pipeline-diagram.tsx | 0 .../programs/self-driving/content/pricing.ts | 0 .../programs/self-driving/content/tips.ts | 4 +- .../warehouse-source/content/index.tsx | 2 +- src/ui/tui/router.ts | 156 +----- src/ui/tui/screen-registry.tsx | 2 +- src/ui/tui/screen-sequences.ts | 44 +- .../tui/screens/McpSuggestedPromptsScreen.tsx | 4 +- .../screens/PostHogIntegrationIntroScreen.tsx | 3 +- src/ui/tui/screens/RunScreen.tsx | 22 +- src/ui/tui/screens/SelfDrivingIntroScreen.tsx | 2 +- src/ui/tui/screens/audit/AuditRunScreen.tsx | 3 +- src/ui/tui/start-tui.ts | 9 +- src/ui/tui/store.ts | 226 +++------ src/ui/tui/ui-store.ts | 99 ++++ 107 files changed, 1873 insertions(+), 1530 deletions(-) rename src/{ui/tui/__tests__/router.test.ts => lib/__tests__/flow-resolution.test.ts} (83%) create mode 100644 src/lib/flow-resolution.ts create mode 100644 src/lib/flow.ts create mode 100644 src/lib/interrupts.ts create mode 100644 src/lib/programs/flow-for.ts create mode 100644 src/lib/run-failure.ts create mode 100644 src/ui/null-ui.ts create mode 100644 src/ui/store-ui.ts rename src/ui/{ => tui}/__tests__/headless-ui.test.ts (64%) rename src/{lib/programs => ui/tui}/__tests__/self-driving-deck.test.ts (95%) create mode 100644 src/ui/tui/__tests__/ui-store.test.ts rename src/ui/{ => tui/console}/headless-ui.ts (91%) rename src/ui/{ => tui/console}/logging-ui.ts (99%) create mode 100644 src/ui/tui/hooks/useUiStore.ts rename src/{lib => ui/tui}/programs/agent-skill/content/index.tsx (100%) rename src/{lib => ui/tui}/programs/error-tracking-upload-source-maps/content/index.tsx (99%) rename src/{lib => ui/tui}/programs/error-tracking/content/index.tsx (84%) rename src/{lib => ui/tui}/programs/error-tracking/content/tips.ts (100%) rename src/{lib => ui/tui}/programs/migration/content/free-tier.tsx (100%) rename src/{lib => ui/tui}/programs/migration/content/index.tsx (96%) rename src/{lib => ui/tui}/programs/migration/content/pricing-structure.tsx (100%) rename src/{lib => ui/tui}/programs/migration/content/vendor-stack.tsx (100%) rename src/{lib => ui/tui}/programs/posthog-integration/content/data-flow.tsx (100%) rename src/{lib => ui/tui}/programs/posthog-integration/content/funnel.tsx (100%) rename src/{lib => ui/tui}/programs/posthog-integration/content/index.tsx (100%) rename src/{lib => ui/tui}/programs/posthog-integration/content/line-chart.tsx (100%) rename src/{lib => ui/tui}/programs/posthog-integration/content/product-suite.tsx (100%) create mode 100644 src/ui/tui/programs/presentation.ts rename src/{lib => ui/tui}/programs/revenue-analytics/content/index.tsx (73%) rename src/{lib => ui/tui}/programs/self-driving/content/index.tsx (100%) rename src/{lib => ui/tui}/programs/self-driving/content/pipeline-diagram.tsx (100%) rename src/{lib => ui/tui}/programs/self-driving/content/pricing.ts (100%) rename src/{lib => ui/tui}/programs/self-driving/content/tips.ts (93%) rename src/{lib => ui/tui}/programs/warehouse-source/content/index.tsx (73%) create mode 100644 src/ui/tui/ui-store.ts diff --git a/bin.ts b/bin.ts index 17e41e8e4..b30137d41 100644 --- a/bin.ts +++ b/bin.ts @@ -84,6 +84,11 @@ import { errorTrackingCommand } from './src/commands/error-tracking'; import { skillCommand } from './src/commands/skill'; import { cliCommand } from './src/commands/cli'; import { recoverOrphanedSettingsBackups } from './src/lib/claude-settings'; +import { setUI } from './src/ui'; +import { LoggingUI } from './src/ui/tui/console/logging-ui'; + +// The entry point owns the default renderer; @ui ships with none. +setUI(new LoggingUI()); // Heal any .claude/settings backup a previous interrupted run left orphaned, // before anything else reads Claude settings — conflict detection, OAuth, and diff --git a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts index 243d4f830..13335afd7 100644 --- a/e2e-harness/__tests__/e2e-flow-snapshot.test.ts +++ b/e2e-harness/__tests__/e2e-flow-snapshot.test.ts @@ -31,6 +31,7 @@ import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '@lib/programs/self-driving/dete import { WizardCiDriver } from '../wizard-ci-driver'; import { decideE2eAction, type WizardE2eProfile } from '../e2e-profile'; import { profileFor } from '../profiles'; +import { flowFor } from '@lib/programs/flow-for'; /** * Walk a program flow offline using an e2e profile, injecting the external @@ -47,7 +48,7 @@ function traceFlow( action: string; params?: Record; }> { - const store = new WizardStore(program); + const store = new WizardStore(flowFor(program).flow); setUI(new InkUI(store)); const session = buildSession({ installDir: '/tmp/e2e-snap', ci: true }); if (integration) { @@ -117,7 +118,7 @@ function traceFlow( // `run` thunk, e.g. self-driving's integrate-run) and the program's own // run. Complete the active run step the way the runner would: a composed // step via completeRunStep, the main run via runPhase. - const steps = getProgramConfig(store.router.activeProgram).steps; + const steps = getProgramConfig(store.activeProgram).steps; const runStep = steps.find( (s) => s.screenId === 'run' && diff --git a/e2e-harness/__tests__/wizard-ci-driver.test.ts b/e2e-harness/__tests__/wizard-ci-driver.test.ts index 2edf4553e..01127030d 100644 --- a/e2e-harness/__tests__/wizard-ci-driver.test.ts +++ b/e2e-harness/__tests__/wizard-ci-driver.test.ts @@ -23,9 +23,10 @@ import { WizardCiDriver, UnknownActionError } from '../wizard-ci-driver'; import { ACTION_REGISTRY, NO_ACTION_SCREENS } from '../action-registry'; import { SOURCE_MAPS_CONTEXT_KEYS } from '@lib/programs/error-tracking-upload-source-maps/index'; import { OutroKind } from '@lib/wizard-session'; +import { flowFor } from '@lib/programs/flow-for'; function freshStore(): WizardStore { - const store = new WizardStore(Program.PostHogIntegration); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); // Headless: a real store + InkUI (which only forwards to the store), no Ink // render. setUI so any getUI() path the store touches resolves. setUI(new InkUI(store)); @@ -199,7 +200,7 @@ describe('WizardCiDriver — wizard_ask overlay', () => { describe('WizardCiDriver — self-driving integration check', () => { function selfDrivingStore(): WizardStore { - const store = new WizardStore(Program.SelfDriving); + const store = new WizardStore(flowFor(Program.SelfDriving).flow); setUI(new InkUI(store)); store.session = buildSession({ installDir: '/tmp/ci-driver-sd', ci: true }); return store; @@ -239,7 +240,9 @@ describe('WizardCiDriver — self-driving integration check', () => { describe('WizardCiDriver — source-maps project pick', () => { function sourceMapsStore(): WizardStore { - const store = new WizardStore(Program.ErrorTrackingUploadSourceMaps); + const store = new WizardStore( + flowFor(Program.ErrorTrackingUploadSourceMaps).flow, + ); setUI(new InkUI(store)); store.session = buildSession({ installDir: '/tmp/ci-driver-sm', ci: true }); return store; diff --git a/e2e-harness/wizard-ci-driver.ts b/e2e-harness/wizard-ci-driver.ts index 37e0235b7..79a5873fb 100644 --- a/e2e-harness/wizard-ci-driver.ts +++ b/e2e-harness/wizard-ci-driver.ts @@ -102,10 +102,10 @@ export class WizardCiDriver { /** Snapshot the committed state plus the derived screen. */ readState(): CiState { const s = this.store.session; - const screen = this.store.currentScreen; + const screen = this.store.currentScreen as ScreenName; return { currentScreen: screen, - hasOverlay: this.store.router.hasOverlay, + hasOverlay: this.store.hasInterrupt, runPhase: s.runPhase, session: { installDir: s.installDir, @@ -148,11 +148,13 @@ export class WizardCiDriver { /** Exposed through read_state.actions; there is no list_actions MCP tool. */ listActions(): ActionView[] { - return actionsForScreen(this.store.currentScreen).map((a) => ({ - id: a.id, - description: a.description, - ...(a.params ? { params: a.params } : {}), - })); + return actionsForScreen(this.store.currentScreen as ScreenName).map( + (a) => ({ + id: a.id, + description: a.description, + ...(a.params ? { params: a.params } : {}), + }), + ); } /** @@ -164,7 +166,7 @@ export class WizardCiDriver { actionId: string, params: Record = {}, ): CiState { - const screen = this.store.currentScreen; + const screen = this.store.currentScreen as ScreenName; const action = actionsForScreen(screen).find((a) => a.id === actionId); if (!action) throw new UnknownActionError(actionId, screen); action.apply(this.store, params); // may throw MissingParamError diff --git a/scripts/__fixtures__/chunk-manifest.ci.json b/scripts/__fixtures__/chunk-manifest.ci.json index 7ef40a63d..e2c426171 100644 --- a/scripts/__fixtures__/chunk-manifest.ci.json +++ b/scripts/__fixtures__/chunk-manifest.ci.json @@ -17,16 +17,16 @@ "debug.js", "defaults.js", "rolldown-runtime.js", - "telemetry.js" + "telemetry.js", + "ui.js" ] }, "agent-interface.js": { "sources": [ - "src/lib/agent/agent-env-isolation.ts", + "src/lib/agent-env-isolation.ts", "src/lib/agent/agent-interface.ts", "src/lib/agent/agent-phase.ts", "src/lib/agent/bash-fence.ts", - "src/lib/agent/claude-settings.ts", "src/lib/agent/commandments.ts", "src/lib/agent/output-signals.ts", "src/lib/agent/runner/harness/pi/gateway.ts", @@ -35,6 +35,7 @@ "src/lib/agent/stored-login.ts", "src/lib/agent/triage-provider.ts", "src/lib/auth-session-state.ts", + "src/lib/claude-settings.ts", "src/lib/fetch-retry.ts", "src/lib/safe-tools.ts", "src/lib/secret-vault.ts", @@ -48,11 +49,13 @@ "imports": [ "analytics.js", "bounded-fs.js", + "constants.js", "debug.js", "errors.js", "gateway-session.js", "queue-tools.js", "rolldown-runtime.js", + "ui.js", "wizard-abort.js", "wizard-session.js", "yara-hooks.js" @@ -80,7 +83,6 @@ "src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts", "src/lib/agent/runner/sequence/orchestrator/run-metrics.ts", "src/lib/agent/runner/sequence/orchestrator/seeded-deps.ts", - "src/lib/agent/runner/shared/authenticate.ts", "src/lib/agent/runner/shared/bootstrap.ts", "src/lib/agent/runner/shared/errors.ts", "src/lib/agent/runner/switchboard/flags/index.ts", @@ -90,11 +92,6 @@ "src/lib/agent/runner/switchboard/harness.ts", "src/lib/agent/runner/switchboard/index.ts", "src/lib/agent/runner/switchboard/sequence.ts", - "src/lib/agent/token-pricing.ts", - "src/lib/detection/agentic.ts", - "src/lib/detection/context.ts", - "src/lib/detection/features.ts", - "src/lib/detection/framework.ts", "src/lib/middleware/benchmark.ts", "src/lib/middleware/benchmarks/cache-tracker.ts", "src/lib/middleware/benchmarks/compaction-tracker.ts", @@ -110,18 +107,13 @@ "src/lib/middleware/phase-detector.ts", "src/lib/middleware/pipeline.ts", "src/lib/programs/audit/ledger-watcher.ts", - "src/lib/programs/posthog-integration/detect.ts", - "src/lib/programs/shared/package-scanning.ts", - "src/lib/programs/warehouse-source/detect.ts", - "src/lib/warehouse-sources/detect.ts", - "src/lib/warehouse-sources/registry.ts", "src/utils/terminal-bell.ts" ], "imports": [ "agent-interface.js", "agent-prompt-loader.js", "analytics.js", - "bounded-fs.js", + "constants.js", "debug.js", "environment.js", "errors.js", @@ -130,11 +122,14 @@ "package-manager.js", "pi.js", "queue-tools.js", + "readiness.js", "registry.js", "rolldown-runtime.js", "setup-utils.js", + "token-pricing.js", + "ui.js", + "version.js", "wizard-abort.js", - "wizard-session.js", "yara-hooks.js" ] }, @@ -144,7 +139,9 @@ "src/utils/ci-flag-overrides.ts" ], "imports": [ + "constants.js", "debug.js", + "version.js", "wizard-session.js" ] }, @@ -154,7 +151,7 @@ ], "imports": [ "analytics.js", - "debug.js", + "constants.js", "rolldown-runtime.js" ] }, @@ -191,12 +188,16 @@ "src/commands/slack.ts", "src/commands/upload-sourcemaps.ts", "src/commands/warehouse.ts", + "src/lib/programs/ai-opt-in-gate.ts", "src/lib/programs/dispatch-family.ts", + "src/lib/programs/flow-for.ts", + "src/lib/programs/run-config.ts", "src/lib/runners/resolve-no-telemetry.ts", "src/lib/runners/run-non-interactive.ts", "src/lib/runners/run-wizard-ci.ts", "src/lib/runners/run-wizard-headless.ts", "src/lib/runners/run-wizard.ts", + "src/ui/mint-failure.ts", "src/ui/tui/hooks/keyboard-hints-utils.ts", "src/ui/tui/hooks/useKeyBindings.ts", "src/ui/tui/hooks/useKeyboardHints.tsx", @@ -204,7 +205,10 @@ "src/ui/tui/primitives/ConfirmButton.tsx", "src/ui/tui/primitives/PickerMenu.tsx", "src/ui/tui/primitives/PromptLabel.tsx", + "src/ui/tui/primitives/content-types.ts", + "src/ui/tui/primitives/layout-helpers.ts", "src/ui/tui/primitives/picker-filter.ts", + "src/ui/tui/styles.ts", "src/wizard.ts" ], "imports": [ @@ -214,6 +218,7 @@ "analytics.js", "api.js", "ci-install.js", + "constants.js", "debug.js", "env-api-key.js", "environment.js", @@ -223,16 +228,20 @@ "headless-ui.js", "interactive.js", "local-dev.js", - "mint-failure.js", + "logging-ui.js", "non-interactive.js", "playground.js", "posthog.js", "provisioning.js", + "run-failure.js", "setup-utils.js", "start-tui.js", "store.js", "task-stream.js", "telemetry.js", + "token-pricing.js", + "ui.js", + "version.js", "wizard-abort.js", "wizard-session.js", "yara-hooks.js" @@ -253,29 +262,26 @@ ], "imports": [ "bin.js", - "debug.js", "errors.js", - "mint-failure.js", - "provisioning.js" + "logging-ui.js", + "provisioning.js", + "run-failure.js", + "ui.js" ] }, - "debug.js": { + "constants.js": { "sources": [ - "src/env.ts", - "src/lib/constants.ts", - "src/lib/headless-mode.ts", - "src/lib/health-checks/endpoints.ts", - "src/lib/health-checks/readiness.ts", - "src/lib/version.ts", - "src/ui/index.ts", - "src/ui/logging-ui.ts", - "src/ui/wizard-ui.ts", - "src/utils/debug.ts", - "src/utils/paths.ts" + "src/lib/constants.ts" ], "imports": [ - "analytics.js", - "local-dev.js" + "local-dev.js", + "version.js" + ] + }, + "debug.js": { + "sources": [], + "imports": [ + "debug.js" ] }, "defaults.js": { @@ -285,6 +291,15 @@ ], "imports": [] }, + "endpoints.js": { + "sources": [ + "src/lib/health-checks/endpoints.ts" + ], + "imports": [ + "constants.js", + "debug.js" + ] + }, "env-api-key.js": { "sources": [ "src/utils/env-api-key.ts" @@ -304,7 +319,7 @@ }, "errors.js": { "sources": [ - "src/lib/agent/signals.ts", + "src/lib/agent-signals.ts", "src/lib/errors/agent-map.ts", "src/lib/errors/auth.ts", "src/lib/errors/catalog.ts", @@ -317,17 +332,17 @@ ], "imports": [] }, - "file-watcher.js": { + "file.js": { "sources": [ - "src/lib/file-watcher.ts" + "src/lib/task-stream/destinations/file.ts" ], "imports": [ "debug.js" ] }, - "file.js": { + "file-watcher.js": { "sources": [ - "src/lib/task-stream/destinations/file.ts" + "src/lib/file-watcher.ts" ], "imports": [ "debug.js" @@ -340,16 +355,17 @@ "imports": [ "analytics.js", "debug.js", + "endpoints.js", "errors.js", "wizard-abort.js" ] }, "headless-ui.js": { "sources": [ - "src/ui/headless-ui.ts" + "src/ui/tui/console/headless-ui.ts" ], "imports": [ - "debug.js" + "logging-ui.js" ] }, "interactive.js": { @@ -358,7 +374,7 @@ ], "imports": [ "bin.js", - "mint-failure.js" + "run-failure.js" ] }, "local-dev.js": { @@ -367,118 +383,34 @@ ], "imports": [] }, - "mcp.js": { + "logging-ui.js": { "sources": [ - "src/lib/agent/runner/harness/pi/mcp.ts" + "src/ui/tui/console/logging-ui.ts" ], "imports": [ - "debug.js" + "constants.js", + "readiness.js" ] }, - "mcp-prompt-streaming.js": { + "mcp.js": { "sources": [ - "src/lib/agent/mcp-prompt-streaming.ts" + "src/lib/agent/runner/harness/pi/mcp.ts" ], "imports": [ - "agent-interface.js", - "analytics.js", "debug.js", - "gateway-session.js" + "version.js" ] }, - "mint-failure.js": { + "mcp-prompt-streaming.js": { "sources": [ - "src/lib/detection/project-scope.ts", - "src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/agent-skill/index.ts", - "src/lib/programs/agent-skill/steps.ts", - "src/lib/programs/ai-observability/index.ts", - "src/lib/programs/audit/detect.ts", - "src/lib/programs/audit/index.ts", - "src/lib/programs/audit/seed.ts", - "src/lib/programs/error-tracking-upload-source-maps/content/index.tsx", - "src/lib/programs/error-tracking-upload-source-maps/detect.ts", - "src/lib/programs/error-tracking-upload-source-maps/index.ts", - "src/lib/programs/error-tracking-upload-source-maps/prompt.ts", - "src/lib/programs/error-tracking-upload-source-maps/steps.ts", - "src/lib/programs/error-tracking/content/index.tsx", - "src/lib/programs/error-tracking/content/tips.ts", - "src/lib/programs/error-tracking/detect-agentic.ts", - "src/lib/programs/error-tracking/index.ts", - "src/lib/programs/events-audit/index.ts", - "src/lib/programs/events-audit/seed.ts", - "src/lib/programs/events-audit/steps.ts", - "src/lib/programs/mcp-analytics/index.ts", - "src/lib/programs/mcp/index.ts", - "src/lib/programs/metrics/index.ts", - "src/lib/programs/migration/content/free-tier.tsx", - "src/lib/programs/migration/content/index.tsx", - "src/lib/programs/migration/content/pricing-structure.tsx", - "src/lib/programs/migration/content/vendor-stack.tsx", - "src/lib/programs/migration/index.ts", - "src/lib/programs/migration/steps.ts", - "src/lib/programs/posthog-doctor/fetch.ts", - "src/lib/programs/posthog-doctor/index.ts", - "src/lib/programs/posthog-doctor/kind-metadata.ts", - "src/lib/programs/posthog-doctor/steps.ts", - "src/lib/programs/posthog-doctor/types.ts", - "src/lib/programs/posthog-integration/content/data-flow.tsx", - "src/lib/programs/posthog-integration/content/funnel.tsx", - "src/lib/programs/posthog-integration/content/index.tsx", - "src/lib/programs/posthog-integration/content/line-chart.tsx", - "src/lib/programs/posthog-integration/content/product-suite.tsx", - "src/lib/programs/posthog-integration/handoff.ts", - "src/lib/programs/posthog-integration/index.ts", - "src/lib/programs/posthog-integration/steps.ts", - "src/lib/programs/program-registry.ts", - "src/lib/programs/replay-vision/index.ts", - "src/lib/programs/revenue-analytics/detect.ts", - "src/lib/programs/revenue-analytics/index.ts", - "src/lib/programs/revenue-analytics/steps.ts", - "src/lib/programs/self-driving/content/index.tsx", - "src/lib/programs/self-driving/content/pipeline-diagram.tsx", - "src/lib/programs/self-driving/content/pricing.ts", - "src/lib/programs/self-driving/content/tips.ts", - "src/lib/programs/self-driving/detect-agentic.ts", - "src/lib/programs/self-driving/detect.ts", - "src/lib/programs/self-driving/index.ts", - "src/lib/programs/self-driving/prompt.ts", - "src/lib/programs/self-driving/step-keys.ts", - "src/lib/programs/self-driving/steps.ts", - "src/lib/programs/shared/health-check-step.ts", - "src/lib/programs/shared/posthog-cli-preinstall.ts", - "src/lib/programs/slack/index.ts", - "src/lib/programs/warehouse-source/index.ts", - "src/lib/programs/warehouse-source/steps.ts", - "src/lib/programs/web-analytics-doctor/detect.ts", - "src/lib/programs/web-analytics-doctor/index.ts", - "src/lib/programs/web-analytics-doctor/steps.ts", - "src/steps/install-cli-steering/index.ts", - "src/ui/mint-failure.ts", - "src/ui/tui/components/StatusPeekTrigger.tsx", - "src/ui/tui/primitives/TextBlock.tsx", - "src/ui/tui/primitives/content-types.ts", - "src/ui/tui/primitives/layout-helpers.ts", - "src/ui/tui/primitives/text-helpers.ts", - "src/ui/tui/styles.ts" + "src/lib/agent/mcp-prompt-streaming.ts" ], "imports": [ "agent-interface.js", - "agent-runner.js", "analytics.js", - "api.js", + "constants.js", "debug.js", - "errors.js", - "package-json.js", - "provisioning.js", - "queue-tools.js", - "registry.js", - "setup-utils.js", - "steps.js", - "telemetry.js", - "wizard-abort.js", - "wizard-session.js", - "yara-hooks.js" + "gateway-session.js" ] }, "non-interactive.js": { @@ -486,8 +418,8 @@ "src/commands/basic-integration/non-interactive.ts" ], "imports": [ - "debug.js", - "errors.js" + "errors.js", + "ui.js" ] }, "orchestrator-tools.js": { @@ -525,6 +457,7 @@ "imports": [ "agent-interface.js", "analytics.js", + "constants.js", "debug.js", "errors.js", "gateway-session.js", @@ -534,6 +467,8 @@ "task.js", "tasks.js", "tools.js", + "ui.js", + "version.js", "yara-hooks.js" ] }, @@ -564,10 +499,12 @@ "imports": [ "bin.js", "debug.js", - "mint-failure.js", + "readiness.js", + "run-failure.js", "setup-utils.js", "store.js", - "terminal.js" + "terminal.js", + "version.js" ] }, "posthog.js": { @@ -585,6 +522,7 @@ ], "imports": [ "analytics.js", + "constants.js", "debug.js", "rolldown-runtime.js" ] @@ -598,7 +536,16 @@ ], "imports": [ "analytics.js", - "debug.js" + "constants.js" + ] + }, + "readiness.js": { + "sources": [ + "src/lib/health-checks/readiness.ts" + ], + "imports": [ + "debug.js", + "endpoints.js" ] }, "registry.js": { @@ -651,17 +598,93 @@ ], "imports": [ "bounded-fs.js", - "debug.js", "package-json.js", "package-manager.js", "rolldown-runtime.js", - "setup-utils.js" + "setup-utils.js", + "ui.js" ] }, "rolldown-runtime.js": { "sources": [], "imports": [] }, + "run-failure.js": { + "sources": [ + "src/lib/detection/project-scope.ts", + "src/lib/programs/agent-skill/index.ts", + "src/lib/programs/agent-skill/steps.ts", + "src/lib/programs/ai-observability/index.ts", + "src/lib/programs/audit/detect.ts", + "src/lib/programs/audit/index.ts", + "src/lib/programs/audit/seed.ts", + "src/lib/programs/error-tracking-upload-source-maps/detect.ts", + "src/lib/programs/error-tracking-upload-source-maps/index.ts", + "src/lib/programs/error-tracking-upload-source-maps/prompt.ts", + "src/lib/programs/error-tracking-upload-source-maps/steps.ts", + "src/lib/programs/error-tracking/detect-agentic.ts", + "src/lib/programs/error-tracking/index.ts", + "src/lib/programs/events-audit/index.ts", + "src/lib/programs/events-audit/seed.ts", + "src/lib/programs/events-audit/steps.ts", + "src/lib/programs/mcp-analytics/index.ts", + "src/lib/programs/mcp/index.ts", + "src/lib/programs/metrics/index.ts", + "src/lib/programs/migration/index.ts", + "src/lib/programs/migration/steps.ts", + "src/lib/programs/posthog-doctor/fetch.ts", + "src/lib/programs/posthog-doctor/index.ts", + "src/lib/programs/posthog-doctor/kind-metadata.ts", + "src/lib/programs/posthog-doctor/steps.ts", + "src/lib/programs/posthog-doctor/types.ts", + "src/lib/programs/posthog-integration/handoff.ts", + "src/lib/programs/posthog-integration/index.ts", + "src/lib/programs/posthog-integration/steps.ts", + "src/lib/programs/program-registry.ts", + "src/lib/programs/replay-vision/index.ts", + "src/lib/programs/revenue-analytics/detect.ts", + "src/lib/programs/revenue-analytics/index.ts", + "src/lib/programs/revenue-analytics/steps.ts", + "src/lib/programs/self-driving/detect-agentic.ts", + "src/lib/programs/self-driving/detect.ts", + "src/lib/programs/self-driving/index.ts", + "src/lib/programs/self-driving/prompt.ts", + "src/lib/programs/self-driving/step-keys.ts", + "src/lib/programs/self-driving/steps.ts", + "src/lib/programs/shared/health-check-step.ts", + "src/lib/programs/shared/posthog-cli-preinstall.ts", + "src/lib/programs/slack/index.ts", + "src/lib/programs/warehouse-source/index.ts", + "src/lib/programs/warehouse-source/steps.ts", + "src/lib/programs/web-analytics-doctor/detect.ts", + "src/lib/programs/web-analytics-doctor/index.ts", + "src/lib/programs/web-analytics-doctor/steps.ts", + "src/lib/run-failure.ts", + "src/steps/install-cli-steering/index.ts", + "src/ui/tui/programs/self-driving/content/pricing.ts" + ], + "imports": [ + "agent-interface.js", + "analytics.js", + "api.js", + "constants.js", + "debug.js", + "errors.js", + "package-json.js", + "provisioning.js", + "queue-tools.js", + "readiness.js", + "registry.js", + "setup-utils.js", + "steps.js", + "telemetry.js", + "token-pricing.js", + "ui.js", + "wizard-abort.js", + "wizard-session.js", + "yara-hooks.js" + ] + }, "security.js": { "sources": [ "src/lib/agent/runner/harness/pi/security.ts" @@ -688,11 +711,13 @@ "analytics.js", "api.js", "bounded-fs.js", + "constants.js", "debug.js", "local-dev.js", "provisioning.js", "rolldown-runtime.js", "telemetry.js", + "ui.js", "wizard-abort.js" ] }, @@ -702,11 +727,11 @@ "src/lib/wizard-spellbook.ts", "src/steps/add-mcp-server-to-clients/browser-client.ts", "src/steps/add-mcp-server-to-clients/login-client.ts", + "src/ui/store-ui.ts", "src/ui/tui/App.tsx", "src/ui/tui/components/PrivacyPanel.tsx", "src/ui/tui/exit-line.ts", "src/ui/tui/hooks/useGithubConnection.ts", - "src/ui/tui/ink-ui.ts", "src/ui/tui/posthog-integration-intro.ts", "src/ui/tui/screen-registry.tsx", "src/ui/tui/screens/AgentSkillIntroScreen.tsx", @@ -767,22 +792,25 @@ "imports": [ "add-mcp-server-to-clients.js", "agent-interface.js", - "agent-runner.js", "analytics.js", "api.js", "bin.js", "bounded-fs.js", + "constants.js", "debug.js", "defaults.js", "errors.js", "mcp-prompt-streaming.js", - "mint-failure.js", "provisioning.js", + "readiness.js", "registry.js", + "run-failure.js", "setup-utils.js", "store.js", "telemetry.js", "terminal.js", + "token-pricing.js", + "ui.js", "wizard-abort.js", "wizard-session.js", "yara-hooks.js" @@ -796,25 +824,24 @@ ], "imports": [ "analytics.js", - "debug.js", - "telemetry.js" + "telemetry.js", + "ui.js" ] }, "store.js": { "sources": [ - "src/lib/programs/ai-opt-in-gate.ts", - "src/lib/programs/program-step.ts", - "src/ui/tui/router.ts", - "src/ui/tui/screen-sequences.ts", + "src/lib/flow-resolution.ts", "src/ui/tui/store.ts" ], "imports": [ - "agent-runner.js", "analytics.js", "debug.js", - "mint-failure.js", + "readiness.js", "rolldown-runtime.js", - "wizard-session.js" + "run-failure.js", + "token-pricing.js", + "wizard-session.js", + "wizard-ui.js" ] }, "subagent.js": { @@ -833,6 +860,7 @@ "agent-interface.js", "agent-prompt-loader.js", "analytics.js", + "constants.js", "debug.js", "errors.js", "gateway-session.js", @@ -842,6 +870,7 @@ "queue-tools.js", "security.js", "tools.js", + "ui.js", "yara-hooks.js" ] }, @@ -864,7 +893,7 @@ "src/lib/agent/runner/harness/pi/tasks.ts" ], "imports": [ - "debug.js" + "ui.js" ] }, "telemetry.js": { @@ -885,6 +914,7 @@ "src/ui/tui/components/LearnCard.tsx", "src/ui/tui/components/PhaseVisuals.tsx", "src/ui/tui/components/ServiceHealthList.tsx", + "src/ui/tui/components/StatusPeekTrigger.tsx", "src/ui/tui/components/TipsCard.tsx", "src/ui/tui/components/TitleBar.tsx", "src/ui/tui/components/TokenCostHud.tsx", @@ -897,8 +927,10 @@ "src/ui/tui/components/visualizer/grid.ts", "src/ui/tui/components/visualizer/palette.ts", "src/ui/tui/components/visualizer/panel.tsx", + "src/ui/tui/constants.ts", "src/ui/tui/hooks/useDismissOnAnyKey.ts", "src/ui/tui/hooks/useTick.ts", + "src/ui/tui/hooks/useUiStore.ts", "src/ui/tui/primitives/CardLayout.tsx", "src/ui/tui/primitives/ConfirmationInput.tsx", "src/ui/tui/primitives/ContentSequencer.tsx", @@ -919,8 +951,27 @@ "src/ui/tui/primitives/ScreenErrorBoundary.tsx", "src/ui/tui/primitives/SplitView.tsx", "src/ui/tui/primitives/TabContainer.tsx", + "src/ui/tui/primitives/TextBlock.tsx", "src/ui/tui/primitives/ViewportTooSmall.tsx", "src/ui/tui/primitives/link-helpers.ts", + "src/ui/tui/primitives/text-helpers.ts", + "src/ui/tui/programs/agent-skill/content/index.tsx", + "src/ui/tui/programs/error-tracking-upload-source-maps/content/index.tsx", + "src/ui/tui/programs/error-tracking/content/index.tsx", + "src/ui/tui/programs/error-tracking/content/tips.ts", + "src/ui/tui/programs/migration/content/free-tier.tsx", + "src/ui/tui/programs/migration/content/index.tsx", + "src/ui/tui/programs/migration/content/pricing-structure.tsx", + "src/ui/tui/programs/migration/content/vendor-stack.tsx", + "src/ui/tui/programs/posthog-integration/content/data-flow.tsx", + "src/ui/tui/programs/posthog-integration/content/funnel.tsx", + "src/ui/tui/programs/posthog-integration/content/index.tsx", + "src/ui/tui/programs/posthog-integration/content/line-chart.tsx", + "src/ui/tui/programs/posthog-integration/content/product-suite.tsx", + "src/ui/tui/programs/presentation.ts", + "src/ui/tui/programs/self-driving/content/index.tsx", + "src/ui/tui/programs/self-driving/content/pipeline-diagram.tsx", + "src/ui/tui/programs/self-driving/content/tips.ts", "src/ui/tui/screens/AiOptInRequiredScreen.tsx", "src/ui/tui/screens/McpScreen.tsx", "src/ui/tui/screens/McpSuggestedPromptsScreen.tsx", @@ -945,23 +996,55 @@ "src/ui/tui/screens/audit/slides/uploadNotebook.tsx", "src/ui/tui/screens/audit/slides/writeReport.tsx", "src/ui/tui/screens/doctor/IssueTable.tsx", - "src/ui/tui/terminal.ts" + "src/ui/tui/terminal.ts", + "src/ui/tui/ui-store.ts" ], "imports": [ "agent-interface.js", - "agent-runner.js", "analytics.js", "api.js", "bin.js", + "constants.js", "debug.js", "defaults.js", - "mint-failure.js", + "readiness.js", + "run-failure.js", "setup-utils.js", "store.js", "telemetry.js", + "token-pricing.js", "yara-hooks.js" ] }, + "token-pricing.js": { + "sources": [ + "src/lib/agent/runner/shared/authenticate.ts", + "src/lib/ask-policy.ts", + "src/lib/detection/agentic.ts", + "src/lib/detection/context.ts", + "src/lib/detection/features.ts", + "src/lib/detection/framework.ts", + "src/lib/programs/posthog-integration/detect.ts", + "src/lib/programs/shared/package-scanning.ts", + "src/lib/programs/warehouse-source/detect.ts", + "src/lib/token-pricing.ts", + "src/lib/warehouse-sources/detect.ts", + "src/lib/warehouse-sources/registry.ts" + ], + "imports": [ + "agent-interface.js", + "analytics.js", + "bounded-fs.js", + "constants.js", + "debug.js", + "errors.js", + "package-manager.js", + "registry.js", + "setup-utils.js", + "ui.js", + "wizard-session.js" + ] + }, "tools.js": { "sources": [ "src/lib/agent/runner/harness/pi/tools.ts" @@ -976,20 +1059,42 @@ "yara-hooks.js" ] }, + "ui.js": { + "sources": [ + "src/ui/index.ts", + "src/ui/null-ui.ts" + ], + "imports": [] + }, + "version.js": { + "sources": [ + "src/lib/version.ts" + ], + "imports": [] + }, "wizard-abort.js": { - "sources": [], + "sources": [ + "src/utils/wizard-abort.ts" + ], "imports": [ - "wizard-abort.js" + "analytics.js", + "debug.js", + "errors.js", + "ui.js" ] }, "wizard-session.js": { - "sources": [ - "src/lib/wizard-session.ts" - ], + "sources": [], "imports": [ - "local-dev.js" + "wizard-session.js" ] }, + "wizard-ui.js": { + "sources": [ + "src/ui/wizard-ui.ts" + ], + "imports": [] + }, "yara-hooks.js": { "sources": [], "imports": [ diff --git a/scripts/__fixtures__/chunk-manifest.prod.json b/scripts/__fixtures__/chunk-manifest.prod.json index d93825d0a..a451e3364 100644 --- a/scripts/__fixtures__/chunk-manifest.prod.json +++ b/scripts/__fixtures__/chunk-manifest.prod.json @@ -17,16 +17,16 @@ "debug.js", "defaults.js", "rolldown-runtime.js", - "telemetry.js" + "telemetry.js", + "ui.js" ] }, "agent-interface.js": { "sources": [ - "src/lib/agent/agent-env-isolation.ts", + "src/lib/agent-env-isolation.ts", "src/lib/agent/agent-interface.ts", "src/lib/agent/agent-phase.ts", "src/lib/agent/bash-fence.ts", - "src/lib/agent/claude-settings.ts", "src/lib/agent/commandments.ts", "src/lib/agent/output-signals.ts", "src/lib/agent/runner/harness/pi/gateway.ts", @@ -35,6 +35,7 @@ "src/lib/agent/stored-login.ts", "src/lib/agent/triage-provider.ts", "src/lib/auth-session-state.ts", + "src/lib/claude-settings.ts", "src/lib/fetch-retry.ts", "src/lib/safe-tools.ts", "src/lib/secret-vault.ts", @@ -48,11 +49,13 @@ "imports": [ "analytics.js", "bounded-fs.js", + "constants.js", "debug.js", "errors.js", "gateway-session.js", "queue-tools.js", "rolldown-runtime.js", + "ui.js", "wizard-abort.js", "wizard-session.js", "yara-hooks.js" @@ -80,7 +83,6 @@ "src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts", "src/lib/agent/runner/sequence/orchestrator/run-metrics.ts", "src/lib/agent/runner/sequence/orchestrator/seeded-deps.ts", - "src/lib/agent/runner/shared/authenticate.ts", "src/lib/agent/runner/shared/bootstrap.ts", "src/lib/agent/runner/shared/errors.ts", "src/lib/agent/runner/switchboard/flags/index.ts", @@ -90,11 +92,6 @@ "src/lib/agent/runner/switchboard/harness.ts", "src/lib/agent/runner/switchboard/index.ts", "src/lib/agent/runner/switchboard/sequence.ts", - "src/lib/agent/token-pricing.ts", - "src/lib/detection/agentic.ts", - "src/lib/detection/context.ts", - "src/lib/detection/features.ts", - "src/lib/detection/framework.ts", "src/lib/middleware/benchmark.ts", "src/lib/middleware/benchmarks/cache-tracker.ts", "src/lib/middleware/benchmarks/compaction-tracker.ts", @@ -110,18 +107,13 @@ "src/lib/middleware/phase-detector.ts", "src/lib/middleware/pipeline.ts", "src/lib/programs/audit/ledger-watcher.ts", - "src/lib/programs/posthog-integration/detect.ts", - "src/lib/programs/shared/package-scanning.ts", - "src/lib/programs/warehouse-source/detect.ts", - "src/lib/warehouse-sources/detect.ts", - "src/lib/warehouse-sources/registry.ts", "src/utils/terminal-bell.ts" ], "imports": [ "agent-interface.js", "agent-prompt-loader.js", "analytics.js", - "bounded-fs.js", + "constants.js", "debug.js", "environment.js", "errors.js", @@ -130,18 +122,27 @@ "package-manager.js", "pi.js", "queue-tools.js", + "readiness.js", "registry.js", "rolldown-runtime.js", "setup-utils.js", + "token-pricing.js", + "ui.js", + "version.js", "wizard-abort.js", - "wizard-session.js", "yara-hooks.js" ] }, "analytics.js": { - "sources": [], + "sources": [ + "src/utils/analytics.ts", + "src/utils/ci-flag-overrides.ts" + ], "imports": [ - "analytics.js" + "constants.js", + "debug.js", + "version.js", + "wizard-session.js" ] }, "api.js": { @@ -150,7 +151,7 @@ ], "imports": [ "analytics.js", - "debug.js", + "constants.js", "rolldown-runtime.js" ] }, @@ -187,12 +188,16 @@ "src/commands/slack.ts", "src/commands/upload-sourcemaps.ts", "src/commands/warehouse.ts", + "src/lib/programs/ai-opt-in-gate.ts", "src/lib/programs/dispatch-family.ts", + "src/lib/programs/flow-for.ts", + "src/lib/programs/run-config.ts", "src/lib/runners/resolve-no-telemetry.ts", "src/lib/runners/run-non-interactive.ts", "src/lib/runners/run-wizard-ci.ts", "src/lib/runners/run-wizard-headless.ts", "src/lib/runners/run-wizard.ts", + "src/ui/mint-failure.ts", "src/ui/tui/hooks/keyboard-hints-utils.ts", "src/ui/tui/hooks/useKeyBindings.ts", "src/ui/tui/hooks/useKeyboardHints.tsx", @@ -200,7 +205,10 @@ "src/ui/tui/primitives/ConfirmButton.tsx", "src/ui/tui/primitives/PickerMenu.tsx", "src/ui/tui/primitives/PromptLabel.tsx", + "src/ui/tui/primitives/content-types.ts", + "src/ui/tui/primitives/layout-helpers.ts", "src/ui/tui/primitives/picker-filter.ts", + "src/ui/tui/styles.ts", "src/wizard.ts" ], "imports": [ @@ -210,6 +218,7 @@ "analytics.js", "api.js", "ci-install.js", + "constants.js", "debug.js", "env-api-key.js", "environment.js", @@ -219,16 +228,20 @@ "headless-ui.js", "interactive.js", "local-dev.js", - "mint-failure.js", + "logging-ui.js", "non-interactive.js", "playground.js", "posthog.js", "provisioning.js", + "run-failure.js", "setup-utils.js", "start-tui.js", "store.js", "task-stream.js", "telemetry.js", + "token-pricing.js", + "ui.js", + "version.js", "wizard-abort.js", "wizard-session.js", "yara-hooks.js" @@ -249,29 +262,26 @@ ], "imports": [ "bin.js", - "debug.js", "errors.js", - "mint-failure.js", - "provisioning.js" + "logging-ui.js", + "provisioning.js", + "run-failure.js", + "ui.js" ] }, - "debug.js": { + "constants.js": { "sources": [ - "src/env.ts", - "src/lib/constants.ts", - "src/lib/headless-mode.ts", - "src/lib/health-checks/endpoints.ts", - "src/lib/health-checks/readiness.ts", - "src/lib/version.ts", - "src/ui/index.ts", - "src/ui/logging-ui.ts", - "src/ui/wizard-ui.ts", - "src/utils/debug.ts", - "src/utils/paths.ts" + "src/lib/constants.ts" ], "imports": [ - "analytics.js", - "local-dev.js" + "local-dev.js", + "version.js" + ] + }, + "debug.js": { + "sources": [], + "imports": [ + "debug.js" ] }, "defaults.js": { @@ -281,6 +291,15 @@ ], "imports": [] }, + "endpoints.js": { + "sources": [ + "src/lib/health-checks/endpoints.ts" + ], + "imports": [ + "constants.js", + "debug.js" + ] + }, "env-api-key.js": { "sources": [ "src/utils/env-api-key.ts" @@ -300,7 +319,7 @@ }, "errors.js": { "sources": [ - "src/lib/agent/signals.ts", + "src/lib/agent-signals.ts", "src/lib/errors/agent-map.ts", "src/lib/errors/auth.ts", "src/lib/errors/catalog.ts", @@ -336,16 +355,17 @@ "imports": [ "analytics.js", "debug.js", + "endpoints.js", "errors.js", "wizard-abort.js" ] }, "headless-ui.js": { "sources": [ - "src/ui/headless-ui.ts" + "src/ui/tui/console/headless-ui.ts" ], "imports": [ - "debug.js" + "logging-ui.js" ] }, "interactive.js": { @@ -354,7 +374,7 @@ ], "imports": [ "bin.js", - "mint-failure.js" + "run-failure.js" ] }, "local-dev.js": { @@ -363,118 +383,34 @@ ], "imports": [] }, - "mcp.js": { + "logging-ui.js": { "sources": [ - "src/lib/agent/runner/harness/pi/mcp.ts" + "src/ui/tui/console/logging-ui.ts" ], "imports": [ - "debug.js" + "constants.js", + "readiness.js" ] }, - "mcp-prompt-streaming.js": { + "mcp.js": { "sources": [ - "src/lib/agent/mcp-prompt-streaming.ts" + "src/lib/agent/runner/harness/pi/mcp.ts" ], "imports": [ - "agent-interface.js", - "analytics.js", "debug.js", - "gateway-session.js" + "version.js" ] }, - "mint-failure.js": { + "mcp-prompt-streaming.js": { "sources": [ - "src/lib/detection/project-scope.ts", - "src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/agent-skill/index.ts", - "src/lib/programs/agent-skill/steps.ts", - "src/lib/programs/ai-observability/index.ts", - "src/lib/programs/audit/detect.ts", - "src/lib/programs/audit/index.ts", - "src/lib/programs/audit/seed.ts", - "src/lib/programs/error-tracking-upload-source-maps/content/index.tsx", - "src/lib/programs/error-tracking-upload-source-maps/detect.ts", - "src/lib/programs/error-tracking-upload-source-maps/index.ts", - "src/lib/programs/error-tracking-upload-source-maps/prompt.ts", - "src/lib/programs/error-tracking-upload-source-maps/steps.ts", - "src/lib/programs/error-tracking/content/index.tsx", - "src/lib/programs/error-tracking/content/tips.ts", - "src/lib/programs/error-tracking/detect-agentic.ts", - "src/lib/programs/error-tracking/index.ts", - "src/lib/programs/events-audit/index.ts", - "src/lib/programs/events-audit/seed.ts", - "src/lib/programs/events-audit/steps.ts", - "src/lib/programs/mcp-analytics/index.ts", - "src/lib/programs/mcp/index.ts", - "src/lib/programs/metrics/index.ts", - "src/lib/programs/migration/content/free-tier.tsx", - "src/lib/programs/migration/content/index.tsx", - "src/lib/programs/migration/content/pricing-structure.tsx", - "src/lib/programs/migration/content/vendor-stack.tsx", - "src/lib/programs/migration/index.ts", - "src/lib/programs/migration/steps.ts", - "src/lib/programs/posthog-doctor/fetch.ts", - "src/lib/programs/posthog-doctor/index.ts", - "src/lib/programs/posthog-doctor/kind-metadata.ts", - "src/lib/programs/posthog-doctor/steps.ts", - "src/lib/programs/posthog-doctor/types.ts", - "src/lib/programs/posthog-integration/content/data-flow.tsx", - "src/lib/programs/posthog-integration/content/funnel.tsx", - "src/lib/programs/posthog-integration/content/index.tsx", - "src/lib/programs/posthog-integration/content/line-chart.tsx", - "src/lib/programs/posthog-integration/content/product-suite.tsx", - "src/lib/programs/posthog-integration/handoff.ts", - "src/lib/programs/posthog-integration/index.ts", - "src/lib/programs/posthog-integration/steps.ts", - "src/lib/programs/program-registry.ts", - "src/lib/programs/replay-vision/index.ts", - "src/lib/programs/revenue-analytics/detect.ts", - "src/lib/programs/revenue-analytics/index.ts", - "src/lib/programs/revenue-analytics/steps.ts", - "src/lib/programs/self-driving/content/index.tsx", - "src/lib/programs/self-driving/content/pipeline-diagram.tsx", - "src/lib/programs/self-driving/content/pricing.ts", - "src/lib/programs/self-driving/content/tips.ts", - "src/lib/programs/self-driving/detect-agentic.ts", - "src/lib/programs/self-driving/detect.ts", - "src/lib/programs/self-driving/index.ts", - "src/lib/programs/self-driving/prompt.ts", - "src/lib/programs/self-driving/step-keys.ts", - "src/lib/programs/self-driving/steps.ts", - "src/lib/programs/shared/health-check-step.ts", - "src/lib/programs/shared/posthog-cli-preinstall.ts", - "src/lib/programs/slack/index.ts", - "src/lib/programs/warehouse-source/index.ts", - "src/lib/programs/warehouse-source/steps.ts", - "src/lib/programs/web-analytics-doctor/detect.ts", - "src/lib/programs/web-analytics-doctor/index.ts", - "src/lib/programs/web-analytics-doctor/steps.ts", - "src/steps/install-cli-steering/index.ts", - "src/ui/mint-failure.ts", - "src/ui/tui/components/StatusPeekTrigger.tsx", - "src/ui/tui/primitives/TextBlock.tsx", - "src/ui/tui/primitives/content-types.ts", - "src/ui/tui/primitives/layout-helpers.ts", - "src/ui/tui/primitives/text-helpers.ts", - "src/ui/tui/styles.ts" + "src/lib/agent/mcp-prompt-streaming.ts" ], "imports": [ "agent-interface.js", - "agent-runner.js", "analytics.js", - "api.js", + "constants.js", "debug.js", - "errors.js", - "package-json.js", - "provisioning.js", - "queue-tools.js", - "registry.js", - "setup-utils.js", - "steps.js", - "telemetry.js", - "wizard-abort.js", - "wizard-session.js", - "yara-hooks.js" + "gateway-session.js" ] }, "non-interactive.js": { @@ -482,8 +418,8 @@ "src/commands/basic-integration/non-interactive.ts" ], "imports": [ - "debug.js", - "errors.js" + "errors.js", + "ui.js" ] }, "orchestrator-tools.js": { @@ -521,6 +457,7 @@ "imports": [ "agent-interface.js", "analytics.js", + "constants.js", "debug.js", "errors.js", "gateway-session.js", @@ -530,6 +467,8 @@ "task.js", "tasks.js", "tools.js", + "ui.js", + "version.js", "yara-hooks.js" ] }, @@ -560,10 +499,12 @@ "imports": [ "bin.js", "debug.js", - "mint-failure.js", + "readiness.js", + "run-failure.js", "setup-utils.js", "store.js", - "terminal.js" + "terminal.js", + "version.js" ] }, "posthog.js": { @@ -581,6 +522,7 @@ ], "imports": [ "analytics.js", + "constants.js", "debug.js", "rolldown-runtime.js" ] @@ -594,7 +536,16 @@ ], "imports": [ "analytics.js", - "debug.js" + "constants.js" + ] + }, + "readiness.js": { + "sources": [ + "src/lib/health-checks/readiness.ts" + ], + "imports": [ + "debug.js", + "endpoints.js" ] }, "registry.js": { @@ -647,17 +598,93 @@ ], "imports": [ "bounded-fs.js", - "debug.js", "package-json.js", "package-manager.js", "rolldown-runtime.js", - "setup-utils.js" + "setup-utils.js", + "ui.js" ] }, "rolldown-runtime.js": { "sources": [], "imports": [] }, + "run-failure.js": { + "sources": [ + "src/lib/detection/project-scope.ts", + "src/lib/programs/agent-skill/index.ts", + "src/lib/programs/agent-skill/steps.ts", + "src/lib/programs/ai-observability/index.ts", + "src/lib/programs/audit/detect.ts", + "src/lib/programs/audit/index.ts", + "src/lib/programs/audit/seed.ts", + "src/lib/programs/error-tracking-upload-source-maps/detect.ts", + "src/lib/programs/error-tracking-upload-source-maps/index.ts", + "src/lib/programs/error-tracking-upload-source-maps/prompt.ts", + "src/lib/programs/error-tracking-upload-source-maps/steps.ts", + "src/lib/programs/error-tracking/detect-agentic.ts", + "src/lib/programs/error-tracking/index.ts", + "src/lib/programs/events-audit/index.ts", + "src/lib/programs/events-audit/seed.ts", + "src/lib/programs/events-audit/steps.ts", + "src/lib/programs/mcp-analytics/index.ts", + "src/lib/programs/mcp/index.ts", + "src/lib/programs/metrics/index.ts", + "src/lib/programs/migration/index.ts", + "src/lib/programs/migration/steps.ts", + "src/lib/programs/posthog-doctor/fetch.ts", + "src/lib/programs/posthog-doctor/index.ts", + "src/lib/programs/posthog-doctor/kind-metadata.ts", + "src/lib/programs/posthog-doctor/steps.ts", + "src/lib/programs/posthog-doctor/types.ts", + "src/lib/programs/posthog-integration/handoff.ts", + "src/lib/programs/posthog-integration/index.ts", + "src/lib/programs/posthog-integration/steps.ts", + "src/lib/programs/program-registry.ts", + "src/lib/programs/replay-vision/index.ts", + "src/lib/programs/revenue-analytics/detect.ts", + "src/lib/programs/revenue-analytics/index.ts", + "src/lib/programs/revenue-analytics/steps.ts", + "src/lib/programs/self-driving/detect-agentic.ts", + "src/lib/programs/self-driving/detect.ts", + "src/lib/programs/self-driving/index.ts", + "src/lib/programs/self-driving/prompt.ts", + "src/lib/programs/self-driving/step-keys.ts", + "src/lib/programs/self-driving/steps.ts", + "src/lib/programs/shared/health-check-step.ts", + "src/lib/programs/shared/posthog-cli-preinstall.ts", + "src/lib/programs/slack/index.ts", + "src/lib/programs/warehouse-source/index.ts", + "src/lib/programs/warehouse-source/steps.ts", + "src/lib/programs/web-analytics-doctor/detect.ts", + "src/lib/programs/web-analytics-doctor/index.ts", + "src/lib/programs/web-analytics-doctor/steps.ts", + "src/lib/run-failure.ts", + "src/steps/install-cli-steering/index.ts", + "src/ui/tui/programs/self-driving/content/pricing.ts" + ], + "imports": [ + "agent-interface.js", + "analytics.js", + "api.js", + "constants.js", + "debug.js", + "errors.js", + "package-json.js", + "provisioning.js", + "queue-tools.js", + "readiness.js", + "registry.js", + "setup-utils.js", + "steps.js", + "telemetry.js", + "token-pricing.js", + "ui.js", + "wizard-abort.js", + "wizard-session.js", + "yara-hooks.js" + ] + }, "security.js": { "sources": [ "src/lib/agent/runner/harness/pi/security.ts" @@ -684,11 +711,13 @@ "analytics.js", "api.js", "bounded-fs.js", + "constants.js", "debug.js", "local-dev.js", "provisioning.js", "rolldown-runtime.js", "telemetry.js", + "ui.js", "wizard-abort.js" ] }, @@ -698,11 +727,11 @@ "src/lib/wizard-spellbook.ts", "src/steps/add-mcp-server-to-clients/browser-client.ts", "src/steps/add-mcp-server-to-clients/login-client.ts", + "src/ui/store-ui.ts", "src/ui/tui/App.tsx", "src/ui/tui/components/PrivacyPanel.tsx", "src/ui/tui/exit-line.ts", "src/ui/tui/hooks/useGithubConnection.ts", - "src/ui/tui/ink-ui.ts", "src/ui/tui/posthog-integration-intro.ts", "src/ui/tui/screen-registry.tsx", "src/ui/tui/screens/AgentSkillIntroScreen.tsx", @@ -763,22 +792,25 @@ "imports": [ "add-mcp-server-to-clients.js", "agent-interface.js", - "agent-runner.js", "analytics.js", "api.js", "bin.js", "bounded-fs.js", + "constants.js", "debug.js", "defaults.js", "errors.js", "mcp-prompt-streaming.js", - "mint-failure.js", "provisioning.js", + "readiness.js", "registry.js", + "run-failure.js", "setup-utils.js", "store.js", "telemetry.js", "terminal.js", + "token-pricing.js", + "ui.js", "wizard-abort.js", "wizard-session.js", "yara-hooks.js" @@ -792,25 +824,24 @@ ], "imports": [ "analytics.js", - "debug.js", - "telemetry.js" + "telemetry.js", + "ui.js" ] }, "store.js": { "sources": [ - "src/lib/programs/ai-opt-in-gate.ts", - "src/lib/programs/program-step.ts", - "src/ui/tui/router.ts", - "src/ui/tui/screen-sequences.ts", + "src/lib/flow-resolution.ts", "src/ui/tui/store.ts" ], "imports": [ - "agent-runner.js", "analytics.js", "debug.js", - "mint-failure.js", + "readiness.js", "rolldown-runtime.js", - "wizard-session.js" + "run-failure.js", + "token-pricing.js", + "wizard-session.js", + "wizard-ui.js" ] }, "subagent.js": { @@ -829,6 +860,7 @@ "agent-interface.js", "agent-prompt-loader.js", "analytics.js", + "constants.js", "debug.js", "errors.js", "gateway-session.js", @@ -838,6 +870,7 @@ "queue-tools.js", "security.js", "tools.js", + "ui.js", "yara-hooks.js" ] }, @@ -860,7 +893,7 @@ "src/lib/agent/runner/harness/pi/tasks.ts" ], "imports": [ - "debug.js" + "ui.js" ] }, "telemetry.js": { @@ -881,6 +914,7 @@ "src/ui/tui/components/LearnCard.tsx", "src/ui/tui/components/PhaseVisuals.tsx", "src/ui/tui/components/ServiceHealthList.tsx", + "src/ui/tui/components/StatusPeekTrigger.tsx", "src/ui/tui/components/TipsCard.tsx", "src/ui/tui/components/TitleBar.tsx", "src/ui/tui/components/TokenCostHud.tsx", @@ -893,8 +927,10 @@ "src/ui/tui/components/visualizer/grid.ts", "src/ui/tui/components/visualizer/palette.ts", "src/ui/tui/components/visualizer/panel.tsx", + "src/ui/tui/constants.ts", "src/ui/tui/hooks/useDismissOnAnyKey.ts", "src/ui/tui/hooks/useTick.ts", + "src/ui/tui/hooks/useUiStore.ts", "src/ui/tui/primitives/CardLayout.tsx", "src/ui/tui/primitives/ConfirmationInput.tsx", "src/ui/tui/primitives/ContentSequencer.tsx", @@ -915,8 +951,27 @@ "src/ui/tui/primitives/ScreenErrorBoundary.tsx", "src/ui/tui/primitives/SplitView.tsx", "src/ui/tui/primitives/TabContainer.tsx", + "src/ui/tui/primitives/TextBlock.tsx", "src/ui/tui/primitives/ViewportTooSmall.tsx", "src/ui/tui/primitives/link-helpers.ts", + "src/ui/tui/primitives/text-helpers.ts", + "src/ui/tui/programs/agent-skill/content/index.tsx", + "src/ui/tui/programs/error-tracking-upload-source-maps/content/index.tsx", + "src/ui/tui/programs/error-tracking/content/index.tsx", + "src/ui/tui/programs/error-tracking/content/tips.ts", + "src/ui/tui/programs/migration/content/free-tier.tsx", + "src/ui/tui/programs/migration/content/index.tsx", + "src/ui/tui/programs/migration/content/pricing-structure.tsx", + "src/ui/tui/programs/migration/content/vendor-stack.tsx", + "src/ui/tui/programs/posthog-integration/content/data-flow.tsx", + "src/ui/tui/programs/posthog-integration/content/funnel.tsx", + "src/ui/tui/programs/posthog-integration/content/index.tsx", + "src/ui/tui/programs/posthog-integration/content/line-chart.tsx", + "src/ui/tui/programs/posthog-integration/content/product-suite.tsx", + "src/ui/tui/programs/presentation.ts", + "src/ui/tui/programs/self-driving/content/index.tsx", + "src/ui/tui/programs/self-driving/content/pipeline-diagram.tsx", + "src/ui/tui/programs/self-driving/content/tips.ts", "src/ui/tui/screens/AiOptInRequiredScreen.tsx", "src/ui/tui/screens/McpScreen.tsx", "src/ui/tui/screens/McpSuggestedPromptsScreen.tsx", @@ -941,23 +996,55 @@ "src/ui/tui/screens/audit/slides/uploadNotebook.tsx", "src/ui/tui/screens/audit/slides/writeReport.tsx", "src/ui/tui/screens/doctor/IssueTable.tsx", - "src/ui/tui/terminal.ts" + "src/ui/tui/terminal.ts", + "src/ui/tui/ui-store.ts" ], "imports": [ "agent-interface.js", - "agent-runner.js", "analytics.js", "api.js", "bin.js", + "constants.js", "debug.js", "defaults.js", - "mint-failure.js", + "readiness.js", + "run-failure.js", "setup-utils.js", "store.js", "telemetry.js", + "token-pricing.js", "yara-hooks.js" ] }, + "token-pricing.js": { + "sources": [ + "src/lib/agent/runner/shared/authenticate.ts", + "src/lib/ask-policy.ts", + "src/lib/detection/agentic.ts", + "src/lib/detection/context.ts", + "src/lib/detection/features.ts", + "src/lib/detection/framework.ts", + "src/lib/programs/posthog-integration/detect.ts", + "src/lib/programs/shared/package-scanning.ts", + "src/lib/programs/warehouse-source/detect.ts", + "src/lib/token-pricing.ts", + "src/lib/warehouse-sources/detect.ts", + "src/lib/warehouse-sources/registry.ts" + ], + "imports": [ + "agent-interface.js", + "analytics.js", + "bounded-fs.js", + "constants.js", + "debug.js", + "errors.js", + "package-manager.js", + "registry.js", + "setup-utils.js", + "ui.js", + "wizard-session.js" + ] + }, "tools.js": { "sources": [ "src/lib/agent/runner/harness/pi/tools.ts" @@ -972,6 +1059,19 @@ "yara-hooks.js" ] }, + "ui.js": { + "sources": [ + "src/ui/index.ts", + "src/ui/null-ui.ts" + ], + "imports": [] + }, + "version.js": { + "sources": [ + "src/lib/version.ts" + ], + "imports": [] + }, "wizard-abort.js": { "sources": [], "imports": [ @@ -979,17 +1079,31 @@ ] }, "wizard-session.js": { - "sources": [ - "src/lib/wizard-session.ts" - ], + "sources": [], "imports": [ - "local-dev.js" + "wizard-session.js" ] }, + "wizard-ui.js": { + "sources": [ + "src/ui/wizard-ui.ts" + ], + "imports": [] + }, "yara-hooks.js": { - "sources": [], + "sources": [ + "src/lib/programs/audit/types.ts", + "src/lib/programs/events-audit/constants.ts", + "src/lib/programs/posthog-integration/constants.ts", + "src/lib/skill-install.ts", + "src/lib/yara-hooks.ts", + "src/lib/yara-policy.ts" + ], "imports": [ - "yara-hooks.js" + "analytics.js", + "bounded-fs.js", + "debug.js", + "ui.js" ] } } diff --git a/scripts/tui-host.no-jest.ts b/scripts/tui-host.no-jest.ts index 90c744e09..358bd3589 100644 --- a/scripts/tui-host.no-jest.ts +++ b/scripts/tui-host.no-jest.ts @@ -465,7 +465,7 @@ async function main() { const signature = () => JSON.stringify({ screen: store.currentScreen, - overlay: store.router.hasOverlay, + overlay: store.hasInterrupt, tasks: store.tasks.map((t) => [t.label, t.status, t.done]), phase: store.session.runPhase, // Values, not just keys: a screen rerendering from an artifact updated diff --git a/src/__tests__/architecture/import-boundaries.test.ts b/src/__tests__/architecture/import-boundaries.test.ts index 7fa8942bb..09639c6e5 100644 --- a/src/__tests__/architecture/import-boundaries.test.ts +++ b/src/__tests__/architecture/import-boundaries.test.ts @@ -24,11 +24,7 @@ const SURFACE_RULES: ReadonlyArray boolean]> = 'tui', (p) => p.startsWith('src/ui/tui/') || - p === 'src/ui/logging-ui.ts' || - p === 'src/ui/headless-ui.ts' || - p === 'src/commands/factories/family-picker.tsx' || - /^src\/lib\/programs\/[^/]+\/content\//.test(p) || - /^src\/lib\/programs\/[^/]+\/tips\.ts$/.test(p), + p === 'src/commands/factories/family-picker.tsx', ], [ 'cli', @@ -404,7 +400,9 @@ describe('surface classification', () => { 'tui', ); expect( - classifySurface('src/lib/programs/posthog-integration/content/index.tsx'), + classifySurface( + 'src/ui/tui/programs/posthog-integration/content/index.tsx', + ), ).toBe('tui'); expect( classifySurface('src/lib/programs/posthog-integration/index.ts'), diff --git a/src/__tests__/architecture/known-violations.json b/src/__tests__/architecture/known-violations.json index 017c5c5b9..0409c46d1 100644 --- a/src/__tests__/architecture/known-violations.json +++ b/src/__tests__/architecture/known-violations.json @@ -5,25 +5,9 @@ "src/lib/agent/mcp-prompt-streaming.ts -> src/ui/tui/services/mcp-suggested-prompts-services.ts", "src/lib/detection/agentic.ts -> src/lib/agent/agent-interface.ts", "src/lib/detection/project-scope.ts -> src/lib/agent/runner/shared/authenticate.ts", - "src/lib/programs/agent-skill/index.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/ai-observability/index.ts -> src/lib/programs/agent-skill/content/index.tsx", "src/lib/programs/dispatch-family.ts -> src/commands/command.ts", "src/lib/programs/dispatch-family.ts -> src/commands/factories/shared.ts", - "src/lib/programs/error-tracking-upload-source-maps/index.ts -> src/lib/programs/error-tracking-upload-source-maps/content/index.tsx", - "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/index.tsx", - "src/lib/programs/error-tracking/index.ts -> src/lib/programs/error-tracking/content/tips.ts", - "src/lib/programs/metrics/index.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/migration/index.ts -> src/lib/programs/migration/content/index.tsx", - "src/lib/programs/posthog-integration/index.ts -> src/lib/programs/posthog-integration/content/index.tsx", - "src/lib/programs/program-registry.ts -> src/lib/programs/agent-skill/content/index.tsx", - "src/lib/programs/program-step.ts -> src/ui/tui/components/TipsCard.tsx", - "src/lib/programs/program-step.ts -> src/ui/tui/primitives/index.ts", - "src/lib/programs/program-step.ts -> src/ui/tui/store.ts", - "src/lib/programs/revenue-analytics/index.ts -> src/lib/programs/revenue-analytics/content/index.tsx", - "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/index.tsx", - "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/pricing.ts", - "src/lib/programs/self-driving/index.ts -> src/lib/programs/self-driving/content/tips.ts", - "src/lib/programs/warehouse-source/index.ts -> src/lib/programs/warehouse-source/content/index.tsx", + "src/lib/programs/self-driving/index.ts -> src/ui/tui/programs/self-driving/content/pricing.ts", "src/lib/task-stream/event-plan-watcher.ts -> src/ui/tui/store.ts", "src/lib/task-stream/task-stream-push.ts -> src/ui/tui/store.ts", "src/lib/wizard-tools/index.ts -> src/lib/wizard-tools/mcp.ts", @@ -32,7 +16,7 @@ "src/steps/add-or-update-environment-variables.ts -> src/telemetry.ts", "src/steps/run-prettier.ts -> src/telemetry.ts", "src/steps/upload-environment-variables/index.ts -> src/telemetry.ts", - "src/ui/index.ts -> src/ui/logging-ui.ts", + "src/ui/store-ui.ts -> src/ui/tui/store.ts", "src/ui/tui/components/PhaseVisuals.tsx -> src/lib/agent/agent-phase.ts", "src/ui/tui/playground/demos/RunScreenDemo.tsx -> src/lib/agent/agent-phase.ts", "src/ui/tui/services/mcp-suggested-prompts-services.ts -> src/lib/agent/mcp-prompt-streaming.ts", diff --git a/src/commands/basic-integration/ci-install.ts b/src/commands/basic-integration/ci-install.ts index ba04ed382..3956ef0ca 100644 --- a/src/commands/basic-integration/ci-install.ts +++ b/src/commands/basic-integration/ci-install.ts @@ -1,6 +1,6 @@ import type { Arguments } from 'yargs'; import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import { API_KEY_HINT, runWizardCI, runWizardHeadless } from '@lib/runners'; import type { NonInteractiveMode } from '@lib/runners'; import { provisionNewAccount } from '@utils/provisioning'; diff --git a/src/commands/cli/__tests__/add.test.ts b/src/commands/cli/__tests__/add.test.ts index 7700a3751..44f3914cd 100644 --- a/src/commands/cli/__tests__/add.test.ts +++ b/src/commands/cli/__tests__/add.test.ts @@ -44,7 +44,7 @@ vi.mock('@ui', () => ({ getUI: () => mockCliAddUi, setUI: mockCliAddSetUI, })); -vi.mock('@ui/logging-ui', () => ({ +vi.mock('@ui/tui/console/logging-ui', () => ({ LoggingUI: vi.fn(), })); vi.mock('@utils/analytics', () => ({ diff --git a/src/commands/cli/add.ts b/src/commands/cli/add.ts index f6ea13153..e500d7044 100644 --- a/src/commands/cli/add.ts +++ b/src/commands/cli/add.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import * as readline from 'node:readline/promises'; import type { Arguments } from 'yargs'; import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import { analytics } from '@utils/analytics'; import { CLI_STEERING_TARGETS, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 4884df234..a663eaad2 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,5 +1,5 @@ import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import { readApiKeyFromEnv } from '@utils/env-api-key'; import { ErrorCodes } from '@lib/errors'; import { emitWizardError } from '@lib/errors'; diff --git a/src/commands/mcp/add.ts b/src/commands/mcp/add.ts index f1bf00116..49c91827b 100644 --- a/src/commands/mcp/add.ts +++ b/src/commands/mcp/add.ts @@ -1,6 +1,6 @@ import type { Arguments } from 'yargs'; import { setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import { headlessOption, isHeadless } from '@lib/headless-mode'; import { Program } from '@lib/programs/program-registry'; import { VERSION } from '@lib/version'; diff --git a/src/commands/mcp/remove.ts b/src/commands/mcp/remove.ts index 7769056b2..47ea3dddc 100644 --- a/src/commands/mcp/remove.ts +++ b/src/commands/mcp/remove.ts @@ -1,6 +1,6 @@ import type { Arguments } from 'yargs'; import { setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import { headlessOption, isHeadless } from '@lib/headless-mode'; import { Program } from '@lib/programs/program-registry'; import { VERSION } from '@lib/version'; diff --git a/src/commands/mcp/tutorial.ts b/src/commands/mcp/tutorial.ts index 66e2443fe..5f300e07f 100644 --- a/src/commands/mcp/tutorial.ts +++ b/src/commands/mcp/tutorial.ts @@ -1,6 +1,6 @@ import type { Arguments } from 'yargs'; import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import { Program } from '@lib/programs/program-registry'; import { VERSION } from '@lib/version'; import { ErrorCodes } from '@lib/errors'; diff --git a/src/commands/provision.ts b/src/commands/provision.ts index aa60fd1f4..a113e8829 100644 --- a/src/commands/provision.ts +++ b/src/commands/provision.ts @@ -1,6 +1,6 @@ import type { Arguments } from 'yargs'; import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import type { ProvisioningResult } from '@utils/provisioning'; import type { Command } from './command'; diff --git a/src/commands/slack.ts b/src/commands/slack.ts index 4f0bf7743..1b7f3735f 100644 --- a/src/commands/slack.ts +++ b/src/commands/slack.ts @@ -1,6 +1,6 @@ import type { Arguments } from 'yargs'; import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import { Program } from '@lib/programs/program-registry'; import { VERSION } from '@lib/version'; import { ErrorCodes } from '@lib/errors'; diff --git a/src/ui/tui/__tests__/router.test.ts b/src/lib/__tests__/flow-resolution.test.ts similarity index 83% rename from src/ui/tui/__tests__/router.test.ts rename to src/lib/__tests__/flow-resolution.test.ts index 2d893da7e..278a759b7 100644 --- a/src/ui/tui/__tests__/router.test.ts +++ b/src/lib/__tests__/flow-resolution.test.ts @@ -6,7 +6,28 @@ import { } from '@lib/wizard-session'; import { HostResolution } from '@lib/host-resolution'; import { WizardReadiness } from '@lib/health-checks/readiness'; -import { WizardRouter, ScreenId, Overlay, Program } from '@ui/tui/router'; +import { ScreenId, Overlay, Program, type ProgramId } from '@ui/tui/router'; +import { flowEntries, resolveActiveScreen } from '@lib/flow-resolution'; +import { flowFor } from '@lib/programs/flow-for'; +import type { WizardSession } from '@lib/wizard-session'; +import type { Interrupt } from '@lib/interrupts'; + +/** The old router surface over the pure resolver, so the cases port as-is. */ +function routerFor(program: ProgramId) { + const flow = flowFor(program).flow; + const interrupts: Interrupt[] = []; + return { + pushOverlay: (interrupt: Interrupt) => void interrupts.push(interrupt), + popOverlay: () => void interrupts.pop(), + resolve: (session: WizardSession) => + resolveActiveScreen(flow, session, interrupts), + get activeScreen() { + return interrupts.length > 0 + ? interrupts[interrupts.length - 1] + : flowEntries(flow)[0].id; + }, + }; +} import { Integration } from '@lib/constants'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; @@ -28,11 +49,11 @@ function failedRunSession() { return session; } -describe('WizardRouter', () => { +describe('flow resolution', () => { it.each(PROGRAM_REGISTRY.map((program) => program.id))( 'shows a failed run over every step and overlay in %s', (program) => { - const router = new WizardRouter(program); + const router = routerFor(program); router.pushOverlay(Overlay.WizardAsk); const session = failedRunSession(); session.outroDismissed = true; @@ -41,7 +62,7 @@ describe('WizardRouter', () => { ); it('continues a failed run through the post-run steps, then exits', () => { - const router = new WizardRouter(Program.SelfDriving); + const router = routerFor(Program.SelfDriving); const session = failedRunSession(); session.mintHandoff = 'continue'; expect(router.resolve(session)).toBe(ScreenId.Mcp); @@ -57,7 +78,7 @@ describe('WizardRouter', () => { describe('resolve', () => { it('returns the first incomplete visible screen for the wizard flow', () => { - const router = new WizardRouter(Program.PostHogIntegration); + const router = routerFor(Program.PostHogIntegration); const session = baseWizardSession(); expect(router.resolve(session)).toBe(ScreenId.Intro); @@ -79,7 +100,7 @@ describe('WizardRouter', () => { }); it('skips the setup screen when there are no unanswered framework questions', () => { - const router = new WizardRouter(Program.PostHogIntegration); + const router = routerFor(Program.PostHogIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -106,7 +127,7 @@ describe('WizardRouter', () => { // step never completes — without the reroute the auth spinner stays up // and that wait deadlocks. it('routes a failed login to the error outro instead of parking on auth', () => { - const router = new WizardRouter(Program.PostHogIntegration); + const router = routerFor(Program.PostHogIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -126,7 +147,7 @@ describe('WizardRouter', () => { }); it('returns the last flow screen when every entry is complete', () => { - const router = new WizardRouter(Program.PostHogIntegration); + const router = routerFor(Program.PostHogIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -149,7 +170,7 @@ describe('WizardRouter', () => { }); it('gives the topmost overlay precedence over the flow screen', () => { - const router = new WizardRouter(Program.PostHogIntegration); + const router = routerFor(Program.PostHogIntegration); const session = baseWizardSession(); router.pushOverlay(Overlay.SettingsOverride); @@ -165,7 +186,7 @@ describe('WizardRouter', () => { // On OAuth timeout the user has no credentials, so the auth step's // isComplete gate never passes and resolve() is pinned on Auth. The // overlay must take precedence, otherwise the spinner shows forever. - const router = new WizardRouter(Program.PostHogIntegration); + const router = routerFor(Program.PostHogIntegration); const session = baseWizardSession(); session.setupConfirmed = true; @@ -183,13 +204,13 @@ describe('WizardRouter', () => { describe('activeScreen', () => { it('defaults to the first screen in the active flow', () => { - const router = new WizardRouter(Program.McpRemove); + const router = routerFor(Program.McpRemove); expect(router.activeScreen).toBe(ScreenId.McpRemove); }); it('returns the top overlay when overlays are active', () => { - const router = new WizardRouter(Program.PostHogIntegration); + const router = routerFor(Program.PostHogIntegration); router.pushOverlay(Overlay.ManagedSettings); @@ -199,12 +220,12 @@ describe('WizardRouter', () => { describe('McpAdd flow', () => { it('starts at McpAdd', () => { - const router = new WizardRouter(Program.McpAdd); + const router = routerFor(Program.McpAdd); expect(router.activeScreen).toBe(ScreenId.McpAdd); }); it('exits after install when MCP install was skipped', () => { - const router = new WizardRouter(Program.McpAdd); + const router = routerFor(Program.McpAdd); const session = baseWizardSession(); session.mcpComplete = true; session.mcpOutcome = McpOutcome.Skipped; @@ -215,7 +236,7 @@ describe('WizardRouter', () => { }); it('advances to SlackConnect after a successful install', () => { - const router = new WizardRouter(Program.McpAdd); + const router = routerFor(Program.McpAdd); const session = baseWizardSession(); session.mcpComplete = true; session.mcpOutcome = McpOutcome.Installed; @@ -226,7 +247,7 @@ describe('WizardRouter', () => { }); it('advances to McpSuggestedPrompts once the Slack step is dismissed', () => { - const router = new WizardRouter(Program.McpAdd); + const router = routerFor(Program.McpAdd); const session = baseWizardSession(); session.mcpComplete = true; session.mcpOutcome = McpOutcome.Installed; @@ -236,7 +257,7 @@ describe('WizardRouter', () => { }); it('exits once the tutorial step is dismissed', () => { - const router = new WizardRouter(Program.McpAdd); + const router = routerFor(Program.McpAdd); const session = baseWizardSession(); session.mcpComplete = true; session.mcpOutcome = McpOutcome.Installed; @@ -247,7 +268,7 @@ describe('WizardRouter', () => { }); it('skips the Slack step when MCP install was skipped', () => { - const router = new WizardRouter(Program.McpAdd); + const router = routerFor(Program.McpAdd); const session = baseWizardSession(); session.mcpComplete = true; session.mcpOutcome = McpOutcome.Skipped; @@ -266,7 +287,7 @@ describe('WizardRouter', () => { } it('asks "set up PostHog?" when none detected and undecided', () => { - const router = new WizardRouter(Program.SelfDriving); + const router = routerFor(Program.SelfDriving); const session = confirmed(); // integrate null, postHogPresent unset expect(router.resolve(session)).toBe( ScreenId.SelfDrivingIntegrationCheck, @@ -274,14 +295,14 @@ describe('WizardRouter', () => { }); it('skips the question when PostHog is already detected', () => { - const router = new WizardRouter(Program.SelfDriving); + const router = routerFor(Program.SelfDriving); const session = confirmed(); session.frameworkContext.postHogPresent = true; expect(router.resolve(session)).toBe(ScreenId.HealthCheck); }); it('skips the question when --integrate pre-decided it', () => { - const router = new WizardRouter(Program.SelfDriving); + const router = routerFor(Program.SelfDriving); const session = confirmed(); session.integrate = true; expect(router.resolve(session)).toBe(ScreenId.HealthCheck); @@ -305,7 +326,7 @@ describe('WizardRouter', () => { } it('shows the detect+pick screen after auth, before a project is picked', () => { - const router = new WizardRouter(Program.SelfDriving); + const router = routerFor(Program.SelfDriving); const session = readyToIntegrate(); // integration still null expect(router.resolve(session)).toBe( ScreenId.SelfDrivingIntegrationDetect, @@ -313,7 +334,7 @@ describe('WizardRouter', () => { }); it('advances to the integration run once a project is picked', () => { - const router = new WizardRouter(Program.SelfDriving); + const router = routerFor(Program.SelfDriving); const session = readyToIntegrate(); session.integration = Integration.javascriptNode; // picked session.frameworkConfig = FRAMEWORK_REGISTRY[Integration.javascriptNode]; @@ -341,12 +362,12 @@ describe('WizardRouter', () => { } it('shows the project picker after login, before a project is picked', () => { - const router = new WizardRouter(Program.ErrorTracking); + const router = routerFor(Program.ErrorTracking); expect(router.resolve(loggedIn())).toBe(ScreenId.ErrorTrackingDetect); }); it('advances to the run once a project is picked', () => { - const router = new WizardRouter(Program.ErrorTracking); + const router = routerFor(Program.ErrorTracking); const session = loggedIn(); session.integration = Integration.nextjs; session.frameworkConfig = FRAMEWORK_REGISTRY[Integration.nextjs]; diff --git a/src/lib/flow-resolution.ts b/src/lib/flow-resolution.ts new file mode 100644 index 000000000..7967e9322 --- /dev/null +++ b/src/lib/flow-resolution.ts @@ -0,0 +1,88 @@ +/** + * Which step key is active: interrupts first, then the first visible + * incomplete step of the flow. Pure over (flow, session, interrupts). + */ + +import { RunPhase, type WizardSession } from '@lib/wizard-session'; +import type { Flow } from './flow.js'; +import { isRunFailure } from './run-failure.js'; + +/** Step keys the resolver itself reads. Flows use the same strings. */ +export const FLOW_KEY = { + Auth: 'auth', + Run: 'run', + Outro: 'outro', + Exit: 'exit', + MintFailure: 'mint-failure', + Mcp: 'mcp', + SlackConnect: 'slack-connect', + KeepSkills: 'keep-skills', +} as const; + +export interface FlowEntry { + id: string; + show?: (session: WizardSession) => boolean; + isComplete?: (session: WizardSession) => boolean; +} + +/** Post-run steps a mint-failure handoff continues through; ends on exit. */ +export const MINT_HANDOFF_SEQUENCE: FlowEntry[] = [ + { id: FLOW_KEY.Mcp, isComplete: (s) => s.mcpComplete }, + { id: FLOW_KEY.SlackConnect, isComplete: (s) => s.slackStepDismissed }, + { id: FLOW_KEY.KeepSkills, isComplete: (s) => s.skillsComplete }, + { id: FLOW_KEY.Exit }, +]; + +const entriesByFlow = new WeakMap(); + +/** + * The flow's steps that own a screen key, narrowed to what resolution reads. + * Headless steps are omitted and the exit key is appended. + */ +export function flowEntries(flow: Flow): FlowEntry[] { + const cached = entriesByFlow.get(flow); + if (cached) return cached; + const entries: FlowEntry[] = flow.steps + .filter((step) => step.screenId != null) + .map((step) => ({ + id: step.screenId!, + show: step.show, + isComplete: step.isComplete ?? step.gate, + })); + entries.push({ id: FLOW_KEY.Exit }); + entriesByFlow.set(flow, entries); + return entries; +} + +export function resolveActiveScreen( + flow: Flow, + session: WizardSession, + interrupts: readonly string[], +): string { + // A failed agent run interrupts every program until the user leaves the + // handoff screen: exit, or continue through the post-run steps. + const runFailed = isRunFailure(session); + if (runFailed && session.mintHandoff === 'exit') return FLOW_KEY.Exit; + if (runFailed && !session.mintHandoff) return FLOW_KEY.MintFailure; + + if (interrupts.length > 0) return interrupts[interrupts.length - 1]; + + const sequence = runFailed ? MINT_HANDOFF_SEQUENCE : flowEntries(flow); + for (const entry of sequence) { + if (entry.show && !entry.show(session)) continue; + if (entry.isComplete && entry.isComplete(session)) continue; + // A failed login aborts the run: the auth step only completes on + // credentials, which an aborted login never set, so route to the outro + // where the error can be read and dismissed. Auth only. + if ( + entry.id === FLOW_KEY.Auth && + session.runPhase === RunPhase.Error && + session.outroData + ) { + return FLOW_KEY.Outro; + } + return entry.id; + } + + return sequence[sequence.length - 1].id; +} diff --git a/src/lib/flow.ts b/src/lib/flow.ts new file mode 100644 index 000000000..83255b766 --- /dev/null +++ b/src/lib/flow.ts @@ -0,0 +1,79 @@ +/** + * A flow is the ordered step list a store walks: which stage of a run is + * active, which gates the runner awaits, and which step key the TUI renders. + * The store owns this shape; programs build flows from their step lists. + */ + +import type { WizardSession, DiscoveredFeature } from '@lib/wizard-session'; +import type { WizardReadinessResult } from '@lib/health-checks/readiness'; +import type { Integration } from '@lib/constants'; +import type { FrameworkConfig } from '@lib/framework-config'; +import type { ProgramId } from '@lib/programs/program-registry'; + +/** Context passed to onInit callbacks, before the real session is assigned. */ +export interface StoreInitContext { + readonly session: WizardSession; + readonly setReadinessResult: (result: WizardReadinessResult | null) => void; + readonly setFrameworkContext: (key: string, value: unknown) => void; + readonly emitChange: () => void; +} + +/** Context passed to onReady callbacks, after the real session is assigned. */ +export interface ProgramReadyContext { + readonly session: WizardSession; + readonly setFrameworkContext: (key: string, value: unknown) => void; + readonly setFrameworkConfig: ( + integration: Integration, + config: FrameworkConfig, + ) => void; + readonly setDetectedFramework: (label: string) => void; + readonly setSkillId: (skillId: string | null) => void; + readonly setUnsupportedVersion: (info: { + current: string; + minimum: string; + docsUrl: string; + }) => void; + readonly addDiscoveredFeature: (feature: DiscoveredFeature) => void; + readonly setDetectionComplete: () => void; + readonly setPosthogSdkDetected: (detected: boolean) => void; +} + +export interface FlowStep { + /** Unique identifier for this step */ + id: string; + /** Human-readable label for progress display */ + label: string; + /** + * Opaque screen key this step owns, if any. The TUI maps it to a component; + * the store only compares it (e.g. 'intro', 'run', 'outro'). + */ + screenId?: string; + /** Whether this step is visible in the current flow. Omitted = always. */ + show?: (session: WizardSession) => boolean; + /** Exit condition for the step. Defaults to `gate` if unset. */ + isComplete?: (session: WizardSession) => boolean; + /** Blocking checkpoint: `store.getGate(stepId)` resolves once this is true. */ + gate?: (session: WizardSession) => boolean; + /** + * Called once when the TUI starts rendering, with the default session. Use + * for session-independent fire-and-forget work. Never fires for a store that + * isn't rendering screens (tests, playground). + */ + onInit?: (ctx: StoreInitContext) => void; + /** + * Called once after the real session is assigned, before any gate is + * awaited. Awaited in sequence with other steps' onReady callbacks. + */ + onReady?: (ctx: ProgramReadyContext) => void | Promise; + /** + * Report this step's analytics under a different program than its host, for + * steps shared across programs. Attribution only. Matched by `screenId`. + */ + reportsAsProgramId?: ProgramId; +} + +export interface Flow { + programId: ProgramId; + skillId: string | null; + steps: FlowStep[]; +} diff --git a/src/lib/interrupts.ts b/src/lib/interrupts.ts new file mode 100644 index 000000000..a786ec964 --- /dev/null +++ b/src/lib/interrupts.ts @@ -0,0 +1,15 @@ +/** + * Interrupts take over the active screen until dismissed. The string values + * are analytics event names, the `$screen_name` tag, and the control API's + * `currentScreen`; they are a contract. + */ +export enum Interrupt { + SettingsOverride = 'settings-override', + ManagedSettings = 'managed-settings', + PortConflict = 'port-conflict', + ManualAuthCode = 'manual-auth-code', + AuthError = 'auth-error', + SessionTimeout = 'session-timeout', + WizardAsk = 'wizard-ask', + TaskNotice = 'task-notice', +} diff --git a/src/lib/programs/__tests__/error-tracking.test.ts b/src/lib/programs/__tests__/error-tracking.test.ts index ec048f9fb..b2520dcf2 100644 --- a/src/lib/programs/__tests__/error-tracking.test.ts +++ b/src/lib/programs/__tests__/error-tracking.test.ts @@ -5,7 +5,7 @@ import { Integration } from '@lib/constants'; import type { AgenticDetectionReport } from '@lib/detection/agentic'; import { detectFramework } from '@lib/detection/index'; import { ErrorCodes } from '@lib/errors'; -import { ERROR_TRACKING_TIPS } from '@lib/programs/error-tracking/content/tips'; +import { ERROR_TRACKING_TIPS } from '@ui/tui/programs/error-tracking/content/tips'; import { ERROR_TRACKING_PROJECT_PATH_KEY, toErrorTrackingReport, diff --git a/src/lib/programs/__tests__/flow-traces.test.ts b/src/lib/programs/__tests__/flow-traces.test.ts index acf23d064..bdc5c825a 100644 --- a/src/lib/programs/__tests__/flow-traces.test.ts +++ b/src/lib/programs/__tests__/flow-traces.test.ts @@ -3,7 +3,8 @@ * by walking each program's steps through the store with a generic advance per * screen. Baseline for the surface split: must stay byte identical. */ -import { WizardStore, ScreenId, RunPhase, McpOutcome } from '@ui/tui/store'; +import { WizardStore, RunPhase, McpOutcome } from '@ui/tui/store'; +import { ScreenId } from '@ui/tui/router'; import { InkUI } from '@ui/tui/ink-ui'; import { setUI } from '@ui/index'; import { @@ -24,6 +25,7 @@ import { import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '../self-driving/detect'; import { ERROR_TRACKING_PROJECT_PATH_KEY } from '../error-tracking/detect-agentic'; import { SOURCE_MAPS_CONTEXT_KEYS } from '../error-tracking-upload-source-maps/detect'; +import { flowFor } from '@lib/programs/flow-for'; vi.mock('@utils/analytics', () => ({ analytics: { @@ -57,7 +59,7 @@ function screenEvents(): ScreenEvent[] { const NODE = FRAMEWORK_REGISTRY[Integration.javascriptNode]; function createStore(program: ProgramId, integration: Integration | null) { - const store = new WizardStore(program); + const store = new WizardStore(flowFor(program).flow); setUI(new InkUI(store)); const session = buildSession({ installDir: '/app', ci: false }); if (integration) { @@ -111,7 +113,7 @@ function advance(store: WizardStore, screen: string): boolean { return true; case ScreenId.Run: case ScreenId.AuditRun: { - const steps = getProgramConfig(store.router.activeProgram).steps; + const steps = getProgramConfig(store.activeProgram).steps; const runStep = steps.find( (st) => st.screenId === screen && @@ -183,7 +185,7 @@ function trace(program: ProgramId, integration: Integration | null) { const screens: string[] = []; let stoppedOn: string | null = null; for (let guard = 0; guard < 40; guard++) { - const screen = store.router.resolve(store.session); + const screen = store.currentScreen; screens.push(screen); if (screen === ScreenId.Exit) break; if (!advance(store, screen)) { @@ -215,7 +217,7 @@ describe('headless walk analytics', () => { for (const program of ['posthog-integration', 'audit'] as ProgramId[]) { it(`${program}: run phases without a TUI`, () => { wizardCapture.mockClear(); - const store = new WizardStore(program); + const store = new WizardStore(flowFor(program).flow); setUI(new InkUI(store)); store.session = buildSession({ installDir: '/app', ci: true }); store.setRunPhase(RunPhase.Running); @@ -223,7 +225,7 @@ describe('headless walk analytics', () => { store.setRunPhase(RunPhase.Completed); expect({ program, - screen: store.router.resolve(store.session), + screen: store.currentScreen, events: screenEvents(), }).toMatchSnapshot(); }); diff --git a/src/lib/programs/__tests__/program-step.test.ts b/src/lib/programs/__tests__/program-step.test.ts index 57c4ac054..c19bf1f14 100644 --- a/src/lib/programs/__tests__/program-step.test.ts +++ b/src/lib/programs/__tests__/program-step.test.ts @@ -1,9 +1,16 @@ -import { - createProgramSequence, - type ProgramStep, -} from '@lib/programs/program-step'; +import type { ProgramStep } from '@lib/programs/program-step'; +import type { Flow } from '@lib/flow'; +import { flowEntries } from '@lib/flow-resolution'; -describe('createProgramSequence', () => { +const flowOf = (steps: ProgramStep[]): Flow => ({ + programId: 'posthog-integration', + skillId: null, + steps, +}); +const createProgramSequence = (steps: ProgramStep[]) => + flowEntries(flowOf(steps)); + +describe('flowEntries', () => { it('filters out headless steps and keeps only screen-bearing ones', () => { const steps: ProgramStep[] = [ { id: 'detect', label: 'Detecting' }, // headless diff --git a/src/lib/programs/__tests__/self-driving-detect.test.ts b/src/lib/programs/__tests__/self-driving-detect.test.ts index bce8b973a..a5d7461fc 100644 --- a/src/lib/programs/__tests__/self-driving-detect.test.ts +++ b/src/lib/programs/__tests__/self-driving-detect.test.ts @@ -15,6 +15,7 @@ import { } from '@lib/programs/self-driving/detect'; import { getDetectedWarehouseSources } from '@lib/programs/warehouse-source/detect'; import { WizardStore } from '@ui/tui/store'; +import { PROGRAM_PRESENTATION } from '@ui/tui/programs/presentation'; import { SOURCE_DETECTORS } from '@lib/warehouse-sources/registry'; import type { DetectedSource } from '@lib/warehouse-sources/types'; import { toIntegrationReport } from '@lib/programs/self-driving/detect-agentic'; @@ -26,6 +27,7 @@ import { Integration } from '@lib/constants'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { buildSession } from '@lib/wizard-session'; import type { Mock } from 'vitest'; +import { flowFor } from '@lib/programs/flow-for'; function makeTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'self-driving-detect-')); @@ -131,7 +133,7 @@ describe('the detect step does not leak into the composed integration run', () = afterEach(() => cleanup(tmpDir)); it('stashes under its own key and leaves the warehouse key untouched', async () => { - const store = new WizardStore('self-driving'); + const store = new WizardStore(flowFor('self-driving').flow); store.session = buildSession({ installDir: tmpDir }); await store.runReadyHooks(); @@ -189,7 +191,8 @@ describe('selfDrivingConfig', () => { }); it('ships its own Learn deck ending on the self-driving closer', () => { - const blocks = selfDrivingConfig.getContentBlocks?.() ?? []; + const blocks = + PROGRAM_PRESENTATION['self-driving']?.getContentBlocks?.() ?? []; expect(blocks.length).toBeGreaterThan(0); const last = blocks[blocks.length - 1]; expect( diff --git a/src/lib/programs/agent-skill/index.ts b/src/lib/programs/agent-skill/index.ts index 8612e71a8..fd3da7f80 100644 --- a/src/lib/programs/agent-skill/index.ts +++ b/src/lib/programs/agent-skill/index.ts @@ -22,7 +22,6 @@ import type { ProgramConfig } from '@lib/programs/program-step'; import type { ProgramRun, AbortCase } from '@lib/program-run'; import { AGENT_SKILL_STEPS } from './steps.js'; -import { getContentBlocks } from './content/index.js'; export interface SkillProgramOptions { /** Context-mill skill ID to install */ @@ -58,7 +57,6 @@ export function createSkillProgram(opts: SkillProgramOptions): ProgramConfig { skillId: opts.skillId, steps: AGENT_SKILL_STEPS, reportFile: opts.reportFile, - getContentBlocks, run: { skillId: opts.skillId, integrationLabel: opts.integrationLabel, diff --git a/src/lib/programs/ai-observability/index.ts b/src/lib/programs/ai-observability/index.ts index a5fc70489..c9319e69f 100644 --- a/src/lib/programs/ai-observability/index.ts +++ b/src/lib/programs/ai-observability/index.ts @@ -1,6 +1,5 @@ import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index'; -import { getContentBlocks } from '@lib/programs/agent-skill/content/index'; const AI_OBSERVABILITY_STEPS: ProgramStep[] = AGENT_SKILL_STEPS.map((step) => step.id === 'intro' ? { ...step, screenId: 'ai-observability-intro' } : step, @@ -24,7 +23,6 @@ export const aiObservabilityConfig: ProgramConfig = { id: 'ai-observability', steps: AI_OBSERVABILITY_STEPS, reportFile: AI_OBSERVABILITY_REPORT_FILE, - getContentBlocks, run: { integrationLabel: 'ai-observability', // No `skillId`: linear.ts skips its pre-install step (see the gate on diff --git a/src/lib/programs/error-tracking-upload-source-maps/index.ts b/src/lib/programs/error-tracking-upload-source-maps/index.ts index 2b77c280e..fe2df3850 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/index.ts +++ b/src/lib/programs/error-tracking-upload-source-maps/index.ts @@ -13,7 +13,6 @@ import { VARIANTS_REQUIRING_POSTHOG_CLI, type SkillVariant, } from './detect.js'; -import { getContentBlocks } from './content/index.js'; import { getUI } from '@ui'; import { preinstallPostHogCliOnce } from '@lib/programs/shared/posthog-cli-preinstall'; @@ -38,7 +37,6 @@ export const errorTrackingUploadSourceMapsConfig: ProgramConfig = { requiresAi: true, steps: ERROR_TRACKING_UPLOAD_SOURCE_MAPS_PROGRAM, reportFile: REPORT_FILE, - getContentBlocks, requires: ['posthog-integration'], run: (_session: WizardSession): Promise => { diff --git a/src/lib/programs/error-tracking/index.ts b/src/lib/programs/error-tracking/index.ts index 82aa9359f..e52d37bf4 100644 --- a/src/lib/programs/error-tracking/index.ts +++ b/src/lib/programs/error-tracking/index.ts @@ -4,8 +4,6 @@ import { scopeInstallDirToProject } from '@lib/detection/project-scope'; import { FRAMEWORK_REGISTRY } from '@lib/registry'; import type { ProgramRun } from '@lib/program-run'; import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/steps'; -import { getContentBlocks } from '@lib/programs/error-tracking/content/index'; -import { getTips } from '@lib/programs/error-tracking/content/tips'; import { ERROR_TRACKING_UNSUPPORTED, errorTrackingProjectDir, @@ -171,8 +169,6 @@ export const errorTrackingConfig: ProgramConfig = { agentFlow: 'error-tracking', steps: ERROR_TRACKING_STEPS, reportFile: ERROR_TRACKING_REPORT_FILE, - getContentBlocks, - getTips, run: (session: WizardSession): Promise => { maybePreinstallPostHogCli(session.integration); diff --git a/src/lib/programs/flow-for.ts b/src/lib/programs/flow-for.ts new file mode 100644 index 000000000..705297194 --- /dev/null +++ b/src/lib/programs/flow-for.ts @@ -0,0 +1,20 @@ +import type { Flow } from '@lib/flow'; +import { getProgramConfig, type ProgramId } from './program-registry.js'; +import type { ProgramConfig } from './program-step.js'; +import { withAiOptInGate } from './ai-opt-in-gate.js'; + +/** The flow a store walks for a program, with the AI opt-in gate injected. */ +export function flowFor(programId: ProgramId): { + flow: Flow; + config: ProgramConfig; +} { + const config = getProgramConfig(programId); + return { + config, + flow: { + programId, + skillId: config.skillId ?? null, + steps: withAiOptInGate(config), + }, + }; +} diff --git a/src/lib/programs/metrics/index.ts b/src/lib/programs/metrics/index.ts index 859eb6d19..4430a72c0 100644 --- a/src/lib/programs/metrics/index.ts +++ b/src/lib/programs/metrics/index.ts @@ -1,6 +1,5 @@ import type { ProgramConfig, ProgramStep } from '@lib/programs/program-step'; import { AGENT_SKILL_STEPS } from '@lib/programs/agent-skill/index'; -import { getContentBlocks } from '@lib/programs/agent-skill/content/index'; const METRICS_STEPS: ProgramStep[] = AGENT_SKILL_STEPS.map((step) => step.id === 'intro' ? { ...step, screenId: 'metrics-intro' } : step, @@ -29,7 +28,6 @@ export const metricsConfig: ProgramConfig = { agentFlow: 'metrics', steps: METRICS_STEPS, reportFile: METRICS_REPORT_FILE, - getContentBlocks, run: { integrationLabel: 'metrics', // No `skillId`: the agent must load the menu and install the right diff --git a/src/lib/programs/migration/index.ts b/src/lib/programs/migration/index.ts index 63bcdf816..1c3605fbf 100644 --- a/src/lib/programs/migration/index.ts +++ b/src/lib/programs/migration/index.ts @@ -2,7 +2,6 @@ import type { ProgramConfig } from '@lib/programs/program-step'; import type { AbortCase } from '@lib/program-run'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { MIGRATION_PROGRAM } from './steps.js'; -import { getContentBlocks } from './content/index.js'; const MIGRATION_REPORT_FILE = 'migration-report.md'; @@ -31,7 +30,6 @@ export const migrationConfig: ProgramConfig = { skillId: DEFAULT_MIGRATE_SKILL_ID, steps: MIGRATION_PROGRAM, reportFile: MIGRATION_REPORT_FILE, - getContentBlocks, allowedTools: ['Agent'], disallowedTools: [WIZARD_TOOL_NAMES.wizardAsk], run: { diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index 274ca6aa3..bd78e52a5 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -23,7 +23,6 @@ import type { HostResolution } from '@lib/host-resolution'; import { getDetectedWarehouseSources } from '@lib/programs/warehouse-source/detect'; import { shouldDisableAsk } from '@lib/ask-policy'; import { POSTHOG_INTEGRATION_PROGRAM } from './steps.js'; -import { getContentBlocks } from './content/index.js'; import { buildCodingAgentPrompt } from './handoff.js'; import { EVENT_PLAN_FILE } from './constants.js'; @@ -203,7 +202,6 @@ export const posthogIntegrationConfig: ProgramConfig = { agentFlow: 'integration-v2', eventPlanFile: EVENT_PLAN_FILE, steps: POSTHOG_INTEGRATION_PROGRAM, - getContentBlocks, // Basic integration runs without structured user input; drop wizard_ask // so the model can't pop modal prompts mid-run. The runner forwards this // list to the general-purpose subagent as well, so dispatched subagents diff --git a/src/lib/programs/program-registry.ts b/src/lib/programs/program-registry.ts index 9bd564bb3..162c2436e 100644 --- a/src/lib/programs/program-registry.ts +++ b/src/lib/programs/program-registry.ts @@ -24,7 +24,6 @@ import { errorTrackingUploadSourceMapsConfig } from './error-tracking-upload-sou import { errorTrackingConfig } from './error-tracking/index.js'; import { selfDrivingConfig } from './self-driving/index.js'; import { AGENT_SKILL_STEPS } from './agent-skill/index.js'; -import { getContentBlocks as agentSkillContentBlocks } from './agent-skill/content/index.js'; import { mcpAddConfig, mcpRemoveConfig, @@ -50,7 +49,6 @@ export const agentSkillConfig: ProgramConfig = { id: 'agent-skill', description: 'Run an arbitrary context-mill skill', steps: AGENT_SKILL_STEPS, - getContentBlocks: agentSkillContentBlocks, allowedTools: ['Agent'], run: (session) => { const skillId = session.skillId ?? 'agent-skill'; diff --git a/src/lib/programs/program-step.ts b/src/lib/programs/program-step.ts index dc670f010..22c46ce51 100644 --- a/src/lib/programs/program-step.ts +++ b/src/lib/programs/program-step.ts @@ -1,11 +1,12 @@ -import type { WizardSession, DiscoveredFeature } from '@lib/wizard-session'; -import type { WizardReadinessResult } from '@lib/health-checks/readiness'; +import type { WizardSession } from '@lib/wizard-session'; +import type { + FlowStep, + StoreInitContext, + ProgramReadyContext, +} from '@lib/flow'; + +export type { StoreInitContext, ProgramReadyContext }; import type { ProgramRunConfig } from '@lib/program-run'; -import type { Integration } from '@lib/constants'; -import type { FrameworkConfig } from '@lib/framework-config'; -import type { ContentBlock } from '@ui/tui/primitives/index'; -import type { WizardStore } from '@ui/tui/store'; -import type { Tip } from '@ui/tui/components/TipsCard'; // Type-only — erased at compile time, so no runtime cycle with the // registry that imports `ProgramConfig` back from this module. import type { ProgramId } from './program-registry.js'; @@ -21,56 +22,7 @@ import type { ProgramId } from './program-registry.js'; * The PostHog integration program is one ordered list of steps. * Other programs (e.g. revenue analytics) register a different step list. */ -/** - * Context passed to onInit callbacks — fires when the TUI starts - * rendering, before bin.ts has assigned the real session. - */ -export interface StoreInitContext { - readonly session: WizardSession; - readonly setReadinessResult: (result: WizardReadinessResult | null) => void; - readonly setFrameworkContext: (key: string, value: unknown) => void; - readonly emitChange: () => void; -} - -/** - * Context passed to onReady callbacks — fires after bin.ts has assigned - * the real session, so reading `session.installDir` returns the target - * project. Use for async pre-program work like prerequisite detection. - */ -export interface ProgramReadyContext { - readonly session: WizardSession; - readonly setFrameworkContext: (key: string, value: unknown) => void; - - // Detection-specific methods — used by core-integration's detect step - readonly setFrameworkConfig: ( - integration: Integration, - config: FrameworkConfig, - ) => void; - readonly setDetectedFramework: (label: string) => void; - readonly setSkillId: (skillId: string | null) => void; - readonly setUnsupportedVersion: (info: { - current: string; - minimum: string; - docsUrl: string; - }) => void; - readonly addDiscoveredFeature: (feature: DiscoveredFeature) => void; - readonly setDetectionComplete: () => void; - readonly setPosthogSdkDetected: (detected: boolean) => void; -} - -export interface ProgramStep { - /** Unique identifier for this step */ - id: string; - - /** Human-readable label for progress display */ - label: string; - - /** - * TUI screen this step owns, if any. - * Matches the ScreenId enum values (e.g. 'intro', 'run', 'outro'). - */ - screenId?: string; - +export interface ProgramStep extends FlowStep { /** * For a run step (`screenId: 'run'`): the program whose agent this step runs, * composed into the host program's step list (self-driving runs the @@ -93,51 +45,6 @@ export interface ProgramStep { * dir for that run only. Defaults to `session.installDir`. */ targetDir?: (session: WizardSession) => string; - - /** - * Whether this step should be visible in the current program. - * If omitted, the step is always visible. - */ - show?: (session: WizardSession) => boolean; - - /** - * Exit condition for the screen. Router advances when true. - * Defaults to `gate` if unset. - */ - isComplete?: (session: WizardSession) => boolean; - - /** - * Define a gate if your screen needs to await user interactions. - * bin.ts can `await store.getGate(stepId)` to pause until the - * predicate becomes true. - */ - gate?: (session: WizardSession) => boolean; - - /** - * Called once when the TUI starts rendering, with the default - * session. Use for session-independent fire-and-forget work that - * should start as early as possible (e.g. health check kicked off - * while the user is still reading the intro screen). Never fires for - * a store that isn't rendering screens (tests, playground). - */ - onInit?: (ctx: StoreInitContext) => void; - - /** - * Called once after bin.ts has assigned the real session to the store, - * before any gate is awaited. Awaited in sequence with other steps' - * onReady callbacks. Use for session-dependent pre-program work like - * scanning the installDir for prerequisites. May be sync or async. - */ - onReady?: (ctx: ProgramReadyContext) => void | Promise; - - /** - * Report this step's analytics under a different program than its host, for - * steps shared across programs (the MCP tutorial is all of `mcp-tutorial` - * and the last step of `mcp-add`). Attribution only — scopes, bindings, and - * sequences still follow the host. Matched by `screenId`, so headless steps - * are unaffected. - */ - reportsAsProgramId?: ProgramId; } /** @@ -217,22 +124,6 @@ export interface ProgramConfig extends ProgramRunConfig { ciPreRun?: (session: WizardSession) => Promise; /** Prerequisites: other program ids that must have run first */ requires?: string[]; - /** - * LearnCard deck rendered in the shared `RunScreen` while the agent - * runs. Lives at `/content/index.tsx` by convention. - * Programs that ship a custom RunScreen variant (audit) or skip the - * run step (posthog-doctor) leave this unset. - */ - getContentBlocks?: (store?: WizardStore) => ContentBlock[]; - /** - * Tips shown in the run screen's right pane (the `Tips` sidebar) once - * the LearnCard finishes. Lets a program supply its own explainer copy - * (e.g. self-driving explaining what signal sources and scouts are) - * instead of the generic onboarding deck. Unset → `RunScreen` falls back - * to `DEFAULT_TIPS`, so every other program is unaffected. Lives at - * `/content/tips.ts` by convention. - */ - getTips?: (store?: WizardStore) => Tip[]; /** * Subcommand-specific CLI options. Spread into yargs `.options(...)` when the * program's subcommand is registered. Program-specific knowledge stays in @@ -253,37 +144,3 @@ export interface ProgramConfig extends ProgramRunConfig { */ cli?: ProgramCliSurface; } - -/** - * Project program steps into the narrower Screen shape the router consumes. - * - * Two things happen here: - * 1. Headless steps (no `screenId`) are filtered out. The router walks - * visible screens; gate-only steps like `detect` are store concerns. - * 2. The step is narrowed to just { id, show, isComplete } — the - * router has no business touching gate, onInit, or label. - * - * This intentional separation keeps the router focused on one question: - * "Which screen should be rendered right now?" - */ -export function createProgramSequence(steps: ProgramStep[]): Array<{ - id: string; - show?: (session: WizardSession) => boolean; - isComplete?: (session: WizardSession) => boolean; -}> { - const entries = steps - .filter((step) => step.screenId != null) - .map((step) => ({ - id: step.screenId!, - show: step.show, - // `isComplete` defaults to `gate` — for most steps they're the same - // predicate (e.g. intro: setupConfirmed unblocks bin.ts AND finishes - // the screen). Only override when the two conditions diverge. - isComplete: step.isComplete ?? step.gate, - })); - - // Every program ends with the exit screen. - entries.push({ id: 'exit', show: undefined, isComplete: undefined }); - - return entries; -} diff --git a/src/lib/programs/revenue-analytics/index.ts b/src/lib/programs/revenue-analytics/index.ts index f75778bd1..6478bbd97 100644 --- a/src/lib/programs/revenue-analytics/index.ts +++ b/src/lib/programs/revenue-analytics/index.ts @@ -2,7 +2,6 @@ import type { ProgramConfig } from '@lib/programs/program-step'; import { WIZARD_TOOL_NAMES } from '@lib/wizard-tools'; import { REVENUE_ANALYTICS_PROGRAM } from './steps.js'; import { REVENUE_ABORT_CASES } from './detect.js'; -import { getContentBlocks } from './content/index.js'; export const revenueAnalyticsConfig: ProgramConfig = { command: 'revenue-analytics', @@ -10,7 +9,6 @@ export const revenueAnalyticsConfig: ProgramConfig = { id: 'revenue-analytics-setup', skillId: 'revenue-analytics-setup', steps: REVENUE_ANALYTICS_PROGRAM, - getContentBlocks, allowedTools: ['Agent'], disallowedTools: [WIZARD_TOOL_NAMES.wizardAsk], run: { diff --git a/src/lib/programs/self-driving/index.ts b/src/lib/programs/self-driving/index.ts index 8878137f3..b0fd3b1f6 100644 --- a/src/lib/programs/self-driving/index.ts +++ b/src/lib/programs/self-driving/index.ts @@ -15,9 +15,7 @@ import { NO_DEFAULT_LIMIT, PRICE_PER_PR_USD, PRICING_LONG, -} from './content/pricing.js'; -import { getTips } from './content/tips.js'; -import { getContentBlocks } from './content/index.js'; +} from '@ui/tui/programs/self-driving/content/pricing.js'; export const SELF_DRIVING_SKILL_ID = 'self-driving-setup'; const REPORT_FILE = 'posthog-self-driving-report.md'; @@ -128,8 +126,6 @@ export const selfDrivingConfig: ProgramConfig = { }), steps: SELF_DRIVING_PROGRAM, run: buildRun, - getTips, - getContentBlocks, }; export { SELF_DRIVING_PROGRAM } from './steps.js'; diff --git a/src/lib/programs/warehouse-source/index.ts b/src/lib/programs/warehouse-source/index.ts index 94836a4c7..e98fc420f 100644 --- a/src/lib/programs/warehouse-source/index.ts +++ b/src/lib/programs/warehouse-source/index.ts @@ -7,7 +7,6 @@ import { WAREHOUSE_ABORT_CASES, getDetectedWarehouseSources, } from './detect.js'; -import { getContentBlocks } from './content/index.js'; /** * Inject the detected sources (and their creation mode) into the prompt so the @@ -47,7 +46,6 @@ export const warehouseSourceConfig: ProgramConfig = { id: 'warehouse-source', skillId: 'data-warehouse-source-setup', steps: WAREHOUSE_SOURCE_PROGRAM, - getContentBlocks, reportFile: 'posthog-warehouse-report.md', allowedTools: ['Agent'], run: (session: WizardSession): Promise => diff --git a/src/lib/run-failure.ts b/src/lib/run-failure.ts new file mode 100644 index 000000000..255a79faa --- /dev/null +++ b/src/lib/run-failure.ts @@ -0,0 +1,11 @@ +import { OutroKind, type WizardSession } from '@lib/wizard-session'; + +/** The agent run ended in an error, whatever the reason. Login failures also + * set an error outro but never had credentials, so they stay on the outro. */ +export function isRunFailure( + session: Pick, +): boolean { + return ( + session.outroData?.kind === OutroKind.Error && session.credentials !== null + ); +} diff --git a/src/lib/runners/__tests__/mint-recovery.test.ts b/src/lib/runners/__tests__/mint-recovery.test.ts index 24f9b5d83..fc5fe36b3 100644 --- a/src/lib/runners/__tests__/mint-recovery.test.ts +++ b/src/lib/runners/__tests__/mint-recovery.test.ts @@ -9,6 +9,8 @@ import { posthogIntegrationConfig } from '@lib/programs/posthog-integration'; import { ScreenId } from '@ui/tui/router'; import { HostResolution } from '@lib/host-resolution'; import { analytics } from '@utils/analytics'; +import { flowFor } from '@lib/programs/flow-for'; +import { Program } from '@lib/programs/program-registry'; vi.mock('@lib/agent/agent-runner', () => ({ runAgent: vi.fn() })); vi.mock('@ui/tui/start-tui', () => ({ startTUI: vi.fn() })); @@ -47,7 +49,7 @@ afterEach(() => { it.each(['continue', 'exit'] as const)( 'catches a failed run, shows the handoff screen, and exits 1 after %s', async (action) => { - const store = new WizardStore(); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); setUI(new InkUI(store)); vi.spyOn(store, 'runReadyHooks').mockResolvedValue(undefined); vi.spyOn(store, 'getGate').mockResolvedValue(undefined); diff --git a/src/lib/runners/run-non-interactive.ts b/src/lib/runners/run-non-interactive.ts index 6f0226acd..f017065a8 100644 --- a/src/lib/runners/run-non-interactive.ts +++ b/src/lib/runners/run-non-interactive.ts @@ -6,7 +6,7 @@ import { } from '@lib/local-dev'; import type { CloudRegion } from '@utils/types'; import { getUI, setUI } from '@ui'; -import { LoggingUI } from '@ui/logging-ui'; +import { LoggingUI } from '@ui/tui/console/logging-ui'; import type { ProgramConfig } from '@lib/programs/program-step'; import { runConfigFor } from '@lib/programs/run-config'; import { getAuditChecks } from '@lib/programs/audit/types'; @@ -22,6 +22,7 @@ import { emitWizardError, } from '@lib/errors'; import type { OutroData, RunPhase as RunPhaseT } from '@lib/wizard-session'; +import { flowFor } from '@lib/programs/flow-for'; /** * The two non-interactive run modes. Both drive the same pipeline today; the @@ -182,7 +183,7 @@ export function runNonInteractive( let taskStream: TaskStreamPush | null = null; { const { WizardStore } = await import('@ui/tui/store'); - const { HeadlessUI } = await import('@ui/headless-ui'); + const { HeadlessUI } = await import('@ui/tui/console/headless-ui'); const { TaskStreamPush, PostHogDestination, createFileDestination } = await import('@lib/task-stream/index'); @@ -202,7 +203,7 @@ export function runNonInteractive( ...(fileDestination ? [fileDestination] : []), ]; - const headlessStore = new WizardStore(config.id); + const headlessStore = new WizardStore(flowFor(config.id).flow); store = headlessStore; headlessStore.session = session; setUI(new HeadlessUI(headlessStore)); diff --git a/src/lib/runners/run-wizard.ts b/src/lib/runners/run-wizard.ts index 68124a9e4..6b4d37a58 100644 --- a/src/lib/runners/run-wizard.ts +++ b/src/lib/runners/run-wizard.ts @@ -196,7 +196,7 @@ export function runWizard( // Settle the pre-run screens; `integration-check` is a no-op gate here. await activeTui.store.getGate('intro'); - const active = activeTui.store.router.activeProgram; + const active = activeTui.store.activeProgram; if (active === config.id) break; config = getProgramConfig(active); } diff --git a/src/ui/index.ts b/src/ui/index.ts index 9688eaf6f..0b9daf04b 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -1,12 +1,9 @@ -/** - * UI singleton — provides getUI() and setUI() for the wizard. - * Default: LoggingUI. Swap to InkUI at startup for TUI mode. - */ +// UI singleton. The default renders nothing; hosts install their own renderer. import type { WizardUI } from './wizard-ui'; -import { LoggingUI } from './logging-ui'; +import { NullUI } from './null-ui'; -let currentUI: WizardUI = new LoggingUI(); +let currentUI: WizardUI = new NullUI(); export function getUI(): WizardUI { return currentUI; diff --git a/src/ui/mint-failure.ts b/src/ui/mint-failure.ts index 7207e0827..533354897 100644 --- a/src/ui/mint-failure.ts +++ b/src/ui/mint-failure.ts @@ -1,4 +1,4 @@ -import { OutroKind, type WizardSession } from '@lib/wizard-session'; +export { isRunFailure } from '@lib/run-failure'; export const MINT_FAILURE_MESSAGE = "The Wizard's a little busy"; @@ -7,13 +7,3 @@ export const MINT_FAILURE_BODY = export const MINT_FAILURE_CONTACT = 'Email wizard@posthog.com and tell us what happened. Please attach this log:'; - -/** The agent run ended in an error, whatever the reason. Login failures also - * set an error outro but never had credentials, so they stay on the outro. */ -export function isRunFailure( - session: Pick, -): boolean { - return ( - session.outroData?.kind === OutroKind.Error && session.credentials !== null - ); -} diff --git a/src/ui/null-ui.ts b/src/ui/null-ui.ts new file mode 100644 index 000000000..695c0c891 --- /dev/null +++ b/src/ui/null-ui.ts @@ -0,0 +1,99 @@ +// Inert default: the store never constructs a renderer, the entry point does. + +import type { WizardUI, SpinnerHandle } from './wizard-ui'; +import type { AskAnswers } from '@lib/wizard-session'; + +const noop = (): void => undefined; + +export class NullUI implements WizardUI { + readonly interactive = false; + + intro = noop; + outro = noop; + outroError = noop; + cancel = noop; + note = noop; + pushStatus = noop; + startRun = noop; + setCredentials = noop; + setAccessToken = noop; + setRoleAtOrganization = noop; + setApiUser = noop; + setReadinessWarnings = noop; + setDetectedFramework = noop; + onEnterScreen = noop; + setLoginUrl = noop; + setAuthorizeUrl = noop; + showAuthError = noop; + showSessionTimeout = noop; + cancelTaskNotice = noop; + cancelPendingQuestion = noop; + syncTodos = noop; + setEventPlan = noop; + setDashboardUrl = noop; + setStage = noop; + setNotebookUrl = noop; + setHandoffText = noop; + addTokenUsage = noop; + setFinalTokenCostUsd = noop; + setOutroData = noop; + setFrameworkContext = noop; + + log = { + info: noop, + warn: noop, + error: noop, + success: noop, + step: noop, + }; + + spinner(): SpinnerHandle { + return { start: noop, stop: noop, message: noop }; + } + + waitForOutroDismissed(): Promise { + return Promise.resolve(); + } + + waitForAiOptIn(): Promise { + return Promise.resolve(); + } + + waitForGate(): Promise { + return Promise.resolve(); + } + + showBlockingOutage(): Promise { + return Promise.resolve(); + } + + showPortConflict(): Promise { + return Promise.resolve(); + } + + showSettingsOverride(): Promise { + return Promise.resolve(); + } + + showTaskNotice(): Promise { + return Promise.resolve(false); + } + + waitForManualAuthCode(): Promise { + // No prompt to answer it, so the OAuth race is left to the callback server. + return new Promise(() => undefined); + } + + requestQuestion(): Promise { + return Promise.reject( + new Error( + 'wizard_ask is not available in CI / non-interactive mode. ' + + 'Re-run the wizard without --ci to answer interactively.', + ), + ); + } + + getFrameworkContext(): unknown { + return undefined; + } +} diff --git a/src/ui/store-ui.ts b/src/ui/store-ui.ts new file mode 100644 index 000000000..bef0a8cc0 --- /dev/null +++ b/src/ui/store-ui.ts @@ -0,0 +1,285 @@ +// Renders nothing: forwards every WizardUI call to store setters. + +import type { + WizardUI, + SpinnerHandle, + AuthErrorDetail, + TokenUsageDelta, +} from '@ui/wizard-ui'; +import type { WizardStore } from '@ui/tui/store'; +import type { SettingsConflict } from '@lib/claude-settings'; +import type { WizardReadinessResult } from '@lib/health-checks/readiness'; +import type { ApiUser } from '@lib/api'; +import type { + AskAnswers, + Credentials, + OutroData, + PendingQuestion, + TaskNotice, +} from '@lib/wizard-session'; +import { RunPhase, OutroKind } from '@lib/wizard-session'; + +// Strip ANSI escape codes (chalk formatting) from strings +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g; +function stripAnsi(s: string): string { + return s.replace(ANSI_RE, ''); +} + +export class StoreUI implements WizardUI { + readonly interactive = true; + constructor(private store: WizardStore) {} + + intro(message: string): void { + this.store.pushStatus(message); + } + + outro(message: string): void { + this.store.pushStatus(stripAnsi(message)); + + // Outro data is pushed by agent-runner via setOutroData() above. If it + // wasn't (e.g. CI path where outro is called directly with just a + // message), fall back to a minimal success record so the screen still + // renders something useful. + const existing = this.store.session.outroData; + if (!existing) { + this.store.setOutroData({ + kind: OutroKind.Success, + message: stripAnsi(message), + }); + } + + // Signal that the main work is done — router resolves to outro + if (this.store.session.runPhase === RunPhase.Running) { + this.store.setRunPhase(RunPhase.Completed); + } + } + + outroError(data: OutroData): void { + this.store.setOutroData(data); + // Advance router past the run step so the outro screen renders + if (this.store.session.runPhase !== RunPhase.Error) { + this.store.setRunPhase(RunPhase.Error); + } + } + + waitForOutroDismissed(): Promise { + return new Promise((resolve) => { + if (this.store.session.outroDismissed) { + resolve(); + return; + } + const unsub = this.store.subscribe(() => { + if (this.store.session.outroDismissed) { + unsub(); + resolve(); + } + }); + }); + } + + setCredentials(credentials: Credentials): void { + this.store.setCredentials(credentials); + } + + setAccessToken(credentials: Credentials): void { + this.store.setAccessToken(credentials); + } + + setRoleAtOrganization(role: string | null): void { + this.store.setRoleAtOrganization(role); + } + + setApiUser(user: ApiUser | null): void { + this.store.setApiUser(user); + } + + waitForAiOptIn(): Promise { + // Resolved immediately when no gate is registered (requiresAi: false, + // no auth step, or CI). Otherwise parks until _checkGates sees the + // org's approval flip to true — e.g. via [R]etry on the kill screen. + return this.store.getGate('ai-opt-in'); + } + + waitForGate(stepId: string): Promise { + return this.store.getGate(stepId); + } + + getFrameworkContext(key: string): unknown { + return this.store.session.frameworkContext[key]; + } + + setDetectedFramework(label: string): void { + this.store.setDetectedFramework(label); + } + + onEnterScreen(screen: string, fn: () => void): void { + this.store.onEnterScreen(screen, fn); + } + + setLoginUrl(url: string | null): void { + this.store.setLoginUrl(url); + } + + setAuthorizeUrl(url: string | null): void { + this.store.setAuthorizeUrl(url); + } + + showBlockingOutage(result: WizardReadinessResult): Promise { + // In the TUI, the HealthCheckScreen handles outage display. + // This is only called from agent-runner for the CI fallback path. + this.store.setReadinessResult(result); + return Promise.resolve(); + } + + setReadinessWarnings(result: WizardReadinessResult): void { + this.store.setReadinessResult(result); + } + + showPortConflict(processInfo: { + command: string; + pid: string; + port: number; + user: string; + }): Promise { + return this.store.showPortConflict(processInfo); + } + + waitForManualAuthCode(): Promise { + return this.store.waitForManualAuthCode(); + } + + showTaskNotice(notice: TaskNotice): Promise { + return this.store.showTaskNotice(notice); + } + + cancelTaskNotice(): void { + // Same path as pressing Skip: closes the overlay and resolves the pending + // showTaskNotice promise with false. + this.store.resolveTaskNotice(false); + } + + showSettingsOverride( + conflicts: SettingsConflict[], + backupAndFix: () => boolean, + ): Promise { + return this.store.showSettingsOverride(conflicts, backupAndFix); + } + + showAuthError(detail?: AuthErrorDetail): void { + this.store.showAuthError(detail); + } + + showSessionTimeout(): void { + this.store.showSessionTimeout(); + } + + requestQuestion(question: PendingQuestion): Promise { + return this.store.requestQuestion(question); + } + + cancelPendingQuestion(): void { + this.store.cancelPendingQuestion(); + } + + startRun(): void { + this.store.setRunPhase(RunPhase.Running); + } + + cancel(message: string): void { + this.store.pushStatus(message); + } + + log = { + info: (message: string): void => { + this.store.pushStatus(message); + }, + warn: (message: string): void => { + this.store.pushStatus(message); + }, + error: (message: string): void => { + this.store.pushStatus(message); + }, + success: (message: string): void => { + this.store.pushStatus(message); + }, + step: (message: string): void => { + this.store.pushStatus(message); + }, + }; + + note(message: string): void { + this.store.pushStatus(message); + } + + spinner(): SpinnerHandle { + return { + start: (message?: string) => { + if (message) this.store.pushStatus(message); + }, + stop: (message?: string) => { + if (message) this.store.pushStatus(message); + }, + message: (msg?: string) => { + if (msg) this.store.pushStatus(msg); + }, + }; + } + + pushStatus(message: string): void { + this.store.pushStatus(message); + } + + syncTodos( + todos: Array<{ content: string; status: string; activeForm?: string }>, + ): void { + this.store.syncTodos(todos); + } + + setEventPlan(events: Array<{ name: string; description: string }>): void { + this.store.setEventPlan(events); + } + + setDashboardUrl(url: string): void { + this.store.setDashboardUrl(url); + } + + setStage(stage: string): void { + this.store.setCurrentStage(stage); + } + + setNotebookUrl(url: string): void { + this.store.setNotebookUrl(url); + } + + setHandoffText(text: string): void { + this.store.setHandoffText(text); + } + + addTokenUsage(delta: TokenUsageDelta): void { + this.store.addTokenUsage(delta); + } + + setFinalTokenCostUsd(costUsd: number): void { + this.store.setFinalTokenCostUsd(costUsd); + } + + setOutroData(data: OutroData): void { + // Merge in URLs the agent emitted via `[DASHBOARD_URL]` / `[NOTEBOOK_URL]` + // markers. These land on the live store during the run; agent-runner's + // `session` snapshot misses them (setKey forks the reference). The live + // store wins over the `data` payload so a real emission always beats any + // fallback the program's buildOutroData may have computed from the stale + // snapshot (e.g. events-audit defaults dashboardUrl to `${cloudUrl}/dashboard`). + const live = this.store.session; + this.store.setOutroData({ + ...data, + dashboardUrl: live.dashboardUrl ?? data.dashboardUrl ?? undefined, + notebookUrl: live.notebookUrl ?? data.notebookUrl ?? undefined, + }); + } + + setFrameworkContext(key: string, value: unknown): void { + this.store.setFrameworkContext(key, value); + } +} diff --git a/src/ui/tui/App.tsx b/src/ui/tui/App.tsx index e6e7915a1..c5b1bbd13 100644 --- a/src/ui/tui/App.tsx +++ b/src/ui/tui/App.tsx @@ -1,18 +1,20 @@ import { useMemo } from 'react'; import { ScreenContainer } from './primitives/index.js'; import type { WizardStore } from './store.js'; +import type { UiStore } from './ui-store.js'; import { createScreens, createServices } from './screen-registry.js'; interface AppProps { store: WizardStore; + ui: UiStore; } -export const App = ({ store }: AppProps) => { +export const App = ({ store, ui }: AppProps) => { const services = useMemo(() => createServices(store), [store]); const screens = useMemo( () => createScreens(store, services), [store, services], ); - return ; + return ; }; diff --git a/src/ui/tui/__tests__/MintFailureScreen.test.tsx b/src/ui/tui/__tests__/MintFailureScreen.test.tsx index ffbb25578..f4614dacb 100644 --- a/src/ui/tui/__tests__/MintFailureScreen.test.tsx +++ b/src/ui/tui/__tests__/MintFailureScreen.test.tsx @@ -9,6 +9,8 @@ import { KeyboardHintsProvider } from '../hooks/useKeyboardHints'; import { OutroKind } from '@lib/wizard-session'; import { HostResolution } from '@lib/host-resolution'; import { ScreenId } from '../router'; +import { flowFor } from '@lib/programs/flow-for'; +import { Program } from '@lib/programs/program-registry'; vi.mock('ink', () => vi.importActual('../../../../node_modules/ink/build/index.js'), @@ -25,7 +27,7 @@ const saved = { const delay = () => new Promise((resolve) => setTimeout(resolve, 30)); function setup() { - const store = new WizardStore(); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); store.setCredentials({ accessToken: 'tok', projectApiKey: 'pk', @@ -83,7 +85,7 @@ it('reports the log, saves the skill, then continues setup', async () => { expect(store.session.mintHandoff).toBeNull(); await choose(0); expect(store.session.mintHandoff).toBe('continue'); - expect(store.router.resolve(store.session)).toBe(ScreenId.Mcp); + expect(store.currentScreen).toBe(ScreenId.Mcp); }); it.each(['save', 'open'] as const)( diff --git a/src/ui/tui/__tests__/WizardAskScreen.test.ts b/src/ui/tui/__tests__/WizardAskScreen.test.ts index b2b04a695..8968bb1e1 100644 --- a/src/ui/tui/__tests__/WizardAskScreen.test.ts +++ b/src/ui/tui/__tests__/WizardAskScreen.test.ts @@ -26,6 +26,8 @@ import { isRequiredButEmpty, shouldMaskAnswer, } from '@ui/tui/screens/WizardAskScreen'; +import { flowFor } from '@lib/programs/flow-for'; +import { Program } from '@lib/programs/program-registry'; const pending = { id: 'req-1', @@ -51,7 +53,7 @@ describe('handleAskKey', () => { }); it('declines the whole request end-to-end so the task can fall back', async () => { - const store = new WizardStore(); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); const answers = store.requestQuestion(pending); handleAskKey({ escape: true }, store); diff --git a/src/ui/tui/__tests__/exit-line.test.ts b/src/ui/tui/__tests__/exit-line.test.ts index f2ee5bc28..7402b3359 100644 --- a/src/ui/tui/__tests__/exit-line.test.ts +++ b/src/ui/tui/__tests__/exit-line.test.ts @@ -2,6 +2,8 @@ import { getExitLine } from '@ui/tui/exit-line'; import { WizardStore, Program } from '@ui/tui/store'; import { OutroKind } from '@lib/wizard-session'; import { HostResolution } from '@lib/host-resolution'; +import { flowFor } from '@lib/programs/flow-for'; +import { UiStore } from '@ui/tui/ui-store'; vi.mock('../../../utils/analytics.js', () => ({ analytics: { @@ -20,14 +22,25 @@ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); function storeWithOutro( data: Parameters[0], ): WizardStore { - const store = new WizardStore(Program.PostHogIntegration); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); store.setOutroData(data); return store; } +const uiFor = new WeakMap(); +function uiOf(store: WizardStore): UiStore { + let ui = uiFor.get(store); + if (!ui) { + ui = new UiStore(store); + uiFor.set(store, ui); + } + return ui; +} +const exitLine = (store: WizardStore) => getExitLine(store, uiOf(store)); + /** Force `tokenHudVisible` to `visible`, regardless of its IS_DEV default. */ function setHudVisible(store: WizardStore, visible: boolean): void { - if (store.tokenHudVisible !== visible) store.toggleTokenHud(); + if (uiOf(store).tokenHudVisible !== visible) uiOf(store).toggleTokenHud(); } describe('getExitLine', () => { @@ -41,7 +54,7 @@ describe('getExitLine', () => { projectId: 1, }); store.setSpellbook({ path, skillsIncluded: true }); - const line = stripAnsi(getExitLine(store)); + const line = stripAnsi(exitLine(store)); const lines = line.split('\n'); expect(line).toContain('Setup has not been completed.'); expect(line).toContain('Point your agent at this skill'); @@ -56,7 +69,7 @@ describe('getExitLine', () => { const prompt = 'Read `posthog-setup-report.md` and work through the checklist.'; const line = stripAnsi( - getExitLine( + exitLine( storeWithOutro({ kind: OutroKind.Success, message: 'Successfully installed PostHog!', @@ -74,7 +87,7 @@ describe('getExitLine', () => { it('omits the handoff block when no prompt is set', () => { const line = stripAnsi( - getExitLine( + exitLine( storeWithOutro({ kind: OutroKind.Success, message: 'Successfully installed PostHog!', @@ -89,7 +102,7 @@ describe('getExitLine', () => { it('echoes the primary link and next-steps so they survive in scrollback', () => { const line = stripAnsi( - getExitLine( + exitLine( storeWithOutro({ kind: OutroKind.Success, message: 'Self-driving is on.', @@ -119,7 +132,7 @@ describe('getExitLine', () => { it('appends the report suffix when the message does not already mention it', () => { const line = stripAnsi( - getExitLine( + exitLine( storeWithOutro({ kind: OutroKind.Success, message: 'Done!', @@ -132,14 +145,14 @@ describe('getExitLine', () => { it('falls back to a default headline when the outro has no message', () => { const line = stripAnsi( - getExitLine(storeWithOutro({ kind: OutroKind.Success })), + exitLine(storeWithOutro({ kind: OutroKind.Success })), ); expect(line).toMatch(/completed successfully\.$/); }); it('renders a plain "exited" line for non-success outcomes', () => { const line = stripAnsi( - getExitLine(storeWithOutro({ kind: OutroKind.Error, message: 'boom' })), + exitLine(storeWithOutro({ kind: OutroKind.Error, message: 'boom' })), ); expect(line).toMatch(/exited\.$/); expect(line).not.toContain('coding agent'); @@ -161,7 +174,7 @@ describe('getExitLine', () => { cacheCreation1h: 0, }); - const line = stripAnsi(getExitLine(store)); + const line = stripAnsi(exitLine(store)); expect(line).toContain('Cost (estimate): $3.00'); expect(line).toContain('in 1.00M'); @@ -183,7 +196,7 @@ describe('getExitLine', () => { }); store.setFinalTokenCostUsd(1.5); - const line = stripAnsi(getExitLine(store)); + const line = stripAnsi(exitLine(store)); expect(line).toContain('Final cost: $1.50'); }); @@ -200,7 +213,7 @@ describe('getExitLine', () => { cacheCreation1h: 0, }); - const line = stripAnsi(getExitLine(store)); + const line = stripAnsi(exitLine(store)); expect(line).toMatch(/exited\./); expect(line).toContain('Cost (estimate): $3.00'); @@ -213,7 +226,7 @@ describe('getExitLine', () => { }); setHudVisible(store, true); - const line = stripAnsi(getExitLine(store)); + const line = stripAnsi(exitLine(store)); expect(line).not.toContain('Cost'); }); @@ -236,7 +249,7 @@ describe('getExitLine', () => { cacheCreation1h: 0, }); - const line = stripAnsi(getExitLine(store)); + const line = stripAnsi(exitLine(store)); expect(line).not.toContain('Cost'); }); diff --git a/src/ui/tui/__tests__/frames.test.tsx b/src/ui/tui/__tests__/frames.test.tsx index bd7f5ca91..e81692c09 100644 --- a/src/ui/tui/__tests__/frames.test.tsx +++ b/src/ui/tui/__tests__/frames.test.tsx @@ -97,7 +97,8 @@ vi.mock('@lib/programs/posthog-doctor/fetch', () => ({ ]), })); -import { WizardStore, TaskStatus, type ScreenName } from '@ui/tui/store'; +import { WizardStore, TaskStatus } from '@ui/tui/store'; +import type { ScreenName } from '@ui/tui/router'; import { InkUI } from '@ui/tui/ink-ui'; import { setUI } from '@ui/index'; import { ScreenId, Overlay } from '@ui/tui/router'; @@ -128,6 +129,7 @@ import { screenShell, type TerminalSize, } from './helpers/render-screen.no-jest'; +import { flowFor } from '@lib/programs/flow-for'; const SIZES: TerminalSize[] = [ { columns: 120, rows: 40 }, @@ -203,7 +205,7 @@ const inertPromptsServices = { } as unknown as McpSuggestedPromptsServices; function makeStore(program: ProgramId): WizardStore { - const store = new WizardStore(program); + const store = new WizardStore(flowFor(program).flow); setUI(new InkUI(store)); store.version = '0.0.0-test'; store.session = buildSession({ installDir: '/app' }); diff --git a/src/ui/__tests__/headless-ui.test.ts b/src/ui/tui/__tests__/headless-ui.test.ts similarity index 64% rename from src/ui/__tests__/headless-ui.test.ts rename to src/ui/tui/__tests__/headless-ui.test.ts index 916c72405..4f8d27641 100644 --- a/src/ui/__tests__/headless-ui.test.ts +++ b/src/ui/tui/__tests__/headless-ui.test.ts @@ -1,11 +1,6 @@ -// Load @ui first so the logging-ui → readiness → debug → @ui import cycle -// resolves in the order the app uses (@ui before logging-ui). Importing -// HeadlessUI as the entry otherwise hits `new LoggingUI()` in @ui before -// logging-ui has finished initializing. -import '@ui'; -import { HeadlessUI } from '../headless-ui'; -import { TaskStatus } from '../wizard-ui'; -import type { WizardStore } from '../tui/store'; +import { HeadlessUI } from '../console/headless-ui'; +import { TaskStatus } from '@ui/wizard-ui'; +import type { WizardStore } from '../store'; describe('HeadlessUI', () => { it('forwards task updates to the store and still logs to the console', () => { diff --git a/src/ui/tui/__tests__/helpers/render-screen.no-jest.tsx b/src/ui/tui/__tests__/helpers/render-screen.no-jest.tsx index 60e16196b..7bd829a16 100644 --- a/src/ui/tui/__tests__/helpers/render-screen.no-jest.tsx +++ b/src/ui/tui/__tests__/helpers/render-screen.no-jest.tsx @@ -4,6 +4,7 @@ import { vi } from 'vitest'; import { ScreenContainer } from '@ui/tui/primitives/ScreenContainer'; import { createScreens, type ScreenServices } from '@ui/tui/screen-registry'; import type { WizardStore } from '@ui/tui/store'; +import { UiStore } from '@ui/tui/ui-store'; export interface TerminalSize { columns: number; @@ -39,7 +40,11 @@ export function screenShell( services: ScreenServices, ): ReactNode { return ( - + ); } diff --git a/src/ui/tui/__tests__/keyboard-equivalence.test.tsx b/src/ui/tui/__tests__/keyboard-equivalence.test.tsx index 330523f58..115411b09 100644 --- a/src/ui/tui/__tests__/keyboard-equivalence.test.tsx +++ b/src/ui/tui/__tests__/keyboard-equivalence.test.tsx @@ -8,12 +8,11 @@ import { render, cleanup } from 'ink-testing-library'; import { WizardStore, Program, - ScreenId, - Overlay, RunPhase, McpOutcome, type ProgramId, } from '../store'; +import { ScreenId, Overlay } from '../router'; import { InkUI } from '../ink-ui'; import { setUI } from '@ui/index'; import { @@ -34,6 +33,8 @@ import { type ScreenServices, } from '../screen-registry'; import { ACTION_REGISTRY } from '@e2e-harness/action-registry'; +import { flowFor } from '@lib/programs/flow-for'; +import { UiStore } from '../ui-store'; vi.mock('ink', () => vi.importActual('../../../../node_modules/ink/build/index.js'), @@ -360,7 +361,7 @@ const PAIRS: Pair[] = [ ]; function makeStore(pair: Pair): WizardStore { - const store = new WizardStore(pair.program); + const store = new WizardStore(flowFor(pair.program).flow); store.version = '0.0.0-test'; setUI(new InkUI(store)); const session = buildSession({ installDir: '/app', ci: false }); @@ -386,7 +387,7 @@ function snap(store: WizardStore): Snap { } return { screen: store.currentScreen, - overlay: store.router.hasOverlay, + overlay: store.hasInterrupt, session, }; } @@ -410,7 +411,7 @@ async function driveKeyboard(pair: Pair): Promise> { const services = { ...createServices(store), mcpInstaller: fakeInstaller }; const screens = createScreens(store, services); const { stdin, unmount } = render( - , + , ); await tick(60); expect(store.currentScreen).toBe(pair.screen); diff --git a/src/ui/tui/__tests__/programs.test.ts b/src/ui/tui/__tests__/programs.test.ts index 6030b70d2..27d18f415 100644 --- a/src/ui/tui/__tests__/programs.test.ts +++ b/src/ui/tui/__tests__/programs.test.ts @@ -1,12 +1,14 @@ import { buildSession, McpOutcome, RunPhase } from '@lib/wizard-session'; import { WizardReadiness } from '@lib/health-checks/readiness'; -import { PROGRAM_SEQUENCES, ScreenId } from '@ui/tui/screen-sequences'; +import { ScreenId } from '@ui/tui/screen-sequences'; +import { flowEntries } from '@lib/flow-resolution'; +import { flowFor } from '@lib/programs/flow-for'; import { Program, type ProgramId } from '@lib/programs/program-registry'; +const sequenceOf = (program: ProgramId) => flowEntries(flowFor(program).flow); + function getEntry(program: ProgramId, id: ScreenId) { - const entry = PROGRAM_SEQUENCES[program].find( - (candidate) => candidate.id === id, - ); + const entry = sequenceOf(program).find((candidate) => candidate.id === id); if (!entry) { throw new Error(`Missing program entry for ${program}:${id}`); } @@ -102,7 +104,7 @@ describe('PROGRAM_SEQUENCES', () => { describe('Source maps flow', () => { const sourceMapsScreens = () => - PROGRAM_SEQUENCES[Program.ErrorTrackingUploadSourceMaps].map((s) => s.id); + sequenceOf(Program.ErrorTrackingUploadSourceMaps).map((s) => s.id); it('logs in, then detects: intro → auth → detect → run, no health-check', () => { const screens = sourceMapsScreens(); @@ -191,7 +193,7 @@ describe('PROGRAM_SEQUENCES', () => { it('is omitted entirely from programs with requiresAi: false', () => { // posthog-doctor sets requiresAi: false — withAiOptInGate should skip // injection so the gate never appears in the sequence. - const entry = PROGRAM_SEQUENCES[Program.PosthogDoctor].find( + const entry = sequenceOf(Program.PosthogDoctor).find( (e) => e.id === ScreenId.AiOptIn, ); expect(entry).toBeUndefined(); @@ -288,7 +290,7 @@ describe('PROGRAM_SEQUENCES', () => { // after the tutorial would also bury Slack discovery behind a tutorial // dismissal screen. it('runs install → slack-connect → mcp-suggested-prompts', () => { - const order = PROGRAM_SEQUENCES[Program.McpAdd] + const order = sequenceOf(Program.McpAdd) .map((entry) => entry.id) .filter((id) => id !== ScreenId.Exit); diff --git a/src/lib/programs/__tests__/self-driving-deck.test.ts b/src/ui/tui/__tests__/self-driving-deck.test.ts similarity index 95% rename from src/lib/programs/__tests__/self-driving-deck.test.ts rename to src/ui/tui/__tests__/self-driving-deck.test.ts index d4c552a23..7c0f64020 100644 --- a/src/lib/programs/__tests__/self-driving-deck.test.ts +++ b/src/ui/tui/__tests__/self-driving-deck.test.ts @@ -7,7 +7,7 @@ */ import type { ReactNode, ReactElement } from 'react'; -import { getContentBlocks } from '@lib/programs/self-driving/content/index'; +import { getContentBlocks } from '@ui/tui/programs/self-driving/content/index'; /** paneWidth in LearnCard at 80 cols: (min(120, 80) - 2) / 2 - 2 */ const PANE_WIDTH_80COL = 37; diff --git a/src/ui/tui/__tests__/store-invariants.test.ts b/src/ui/tui/__tests__/store-invariants.test.ts index e40535eca..5e19a185e 100644 --- a/src/ui/tui/__tests__/store-invariants.test.ts +++ b/src/ui/tui/__tests__/store-invariants.test.ts @@ -1,5 +1,5 @@ /** - * Behaviour baseline for WizardStore + WizardRouter, taken before a refactor. + * Behaviour baseline for WizardStore and flow resolution, taken before a refactor. * Every expectation here pins what the code does today, exceptions included. */ @@ -8,12 +8,10 @@ import { TaskStatus, Program, type ProgramId, - ScreenId, - Overlay, RunPhase, McpOutcome, - type ScreenName, } from '@ui/tui/store'; +import { ScreenId, Overlay, type ScreenName } from '@ui/tui/router'; import { buildSession, AdditionalFeature, @@ -25,7 +23,6 @@ import { type WizardSession, } from '@lib/wizard-session'; import { EXPANDED_COUNT } from '@ui/tui/constants'; -import { PROGRAM_SEQUENCES } from '@ui/tui/screen-sequences'; import { WizardReadiness } from '@lib/health-checks/readiness'; import { HostResolution } from '@lib/host-resolution'; import { Integration } from '@lib/constants'; @@ -33,6 +30,8 @@ import { FRAMEWORK_REGISTRY } from '@lib/registry'; import { analytics } from '@utils/analytics'; import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; import type { SettingsConflict } from '@lib/claude-settings'; +import { flowEntries, resolveActiveScreen } from '@lib/flow-resolution'; +import { flowFor } from '@lib/programs/flow-for'; vi.mock('../../../utils/analytics.js', () => ({ analytics: { @@ -105,7 +104,7 @@ const aiUser = (approved: boolean): WizardSession['apiUser'] => ({ organization: { is_ai_data_processing_approved: approved } } as never); function createStore(program?: ProgramId): WizardStore { - return new WizardStore(program); + return new WizardStore(flowFor(program ?? Program.PostHogIntegration).flow); } async function flushMicrotasks(): Promise { @@ -145,16 +144,6 @@ const MUTATIONS: MutationCase[] = [ invoke: (s) => s.setCurrentStage('stage'), emits: 1, }, - { - name: 'toggleStatusExpanded', - invoke: (s) => s.toggleStatusExpanded(), - emits: 1, - }, - { - name: 'setStatusExpanded', - invoke: (s) => s.setStatusExpanded(true), - emits: 1, - }, { name: 'completeSetup', invoke: (s) => s.completeSetup(), emits: 1 }, { name: 'grantSharing', invoke: (s) => s.grantSharing(), emits: 1 }, { name: 'declineSharing', invoke: (s) => s.declineSharing(), emits: 1 }, @@ -426,22 +415,21 @@ const MUTATIONS: MutationCase[] = [ }, { name: 'switchProgram', - invoke: (s) => s.switchProgram(Program.Metrics), + invoke: (s) => s.switchProgram(flowFor(Program.Metrics).flow), emits: 1, }, { - name: 'pushOverlay', - invoke: (s) => s.pushOverlay(Overlay.WizardAsk), + name: 'pushInterrupt', + invoke: (s) => s.pushInterrupt(Overlay.WizardAsk), emits: 1, }, { - name: 'popOverlay', - prepare: (s) => s.pushOverlay(Overlay.WizardAsk), - invoke: (s) => s.popOverlay(), + name: 'popInterrupt', + prepare: (s) => s.pushInterrupt(Overlay.WizardAsk), + invoke: (s) => s.popInterrupt(), emits: 1, }, { name: 'pushStatus', invoke: (s) => s.pushStatus('working'), emits: 1 }, - { name: 'toggleTokenHud', invoke: (s) => s.toggleTokenHud(), emits: 1 }, { name: 'addTokenUsage', invoke: (s) => @@ -484,16 +472,6 @@ const MUTATIONS: MutationCase[] = [ emits: 1, }, // Render-only cursor: the learn card drives its own re-render. - { - name: 'setLearnCardBlockIdx', - invoke: (s) => s.setLearnCardBlockIdx(2), - emits: 0, - }, - { - name: 'setLearnCardComplete', - invoke: (s) => s.setLearnCardComplete(), - emits: 1, - }, { name: 'syncTodos', invoke: (s) => s.syncTodos([{ content: 'a', status: 'pending' }]), @@ -577,18 +555,11 @@ describe('store invariants', () => { const store = createStore(); expect( countEmissions(store, () => - store.switchProgram(Program.PostHogIntegration), + store.switchProgram(flowFor(Program.PostHogIntegration).flow), ), ).toBe(0); }); - it('setStatusExpanded to the current value notifies nothing', () => { - const store = createStore(); - expect(countEmissions(store, () => store.setStatusExpanded(false))).toBe( - 0, - ); - }); - it('setCurrentStage with the same stage notifies nothing', () => { const store = createStore(); store.setCurrentStage('stage'); @@ -660,37 +631,20 @@ describe('store invariants', () => { describe('overlays are LIFO', () => { it('unwinds in reverse order back to the program screen', () => { const store = createStore(); - expect(store.router.hasOverlay).toBe(false); - - store.pushOverlay(Overlay.AuthError); - store.pushOverlay(Overlay.SessionTimeout); - expect(store.router.resolve(store.session)).toBe(Overlay.SessionTimeout); - expect(store.router.hasOverlay).toBe(true); - - store.popOverlay(); - expect(store.router.resolve(store.session)).toBe(Overlay.AuthError); - expect(store.router.hasOverlay).toBe(true); - - store.popOverlay(); - expect(store.router.hasOverlay).toBe(false); - expect(store.router.resolve(store.session)).toBe(ScreenId.Intro); - }); - - it('tracks the nav direction across emits and overlay moves', () => { - const store = createStore(); - expect(store.lastNavDirection).toBeNull(); - - store.emitChange(); - expect(store.lastNavDirection).toBe('push'); + expect(store.hasInterrupt).toBe(false); - store.pushOverlay(Overlay.AuthError); - expect(store.lastNavDirection).toBe('push'); + store.pushInterrupt(Overlay.AuthError); + store.pushInterrupt(Overlay.SessionTimeout); + expect(store.currentScreen).toBe(Overlay.SessionTimeout); + expect(store.hasInterrupt).toBe(true); - store.popOverlay(); - expect(store.lastNavDirection).toBe('pop'); + store.popInterrupt(); + expect(store.currentScreen).toBe(Overlay.AuthError); + expect(store.hasInterrupt).toBe(true); - store.emitChange(); - expect(store.lastNavDirection).toBe('push'); + store.popInterrupt(); + expect(store.hasInterrupt).toBe(false); + expect(store.currentScreen).toBe(ScreenId.Intro); }); }); @@ -776,7 +730,13 @@ describe('store invariants', () => { const rand = mulberry32(SEED); const screens: ScreenName[] = []; for (let i = 0; i < SESSION_COUNT; i++) { - screens.push(store.router.resolve(randomSession(rand))); + screens.push( + resolveActiveScreen( + store.flow, + randomSession(rand), + [], + ) as ScreenName, + ); } return screens; } @@ -793,7 +753,7 @@ describe('store invariants', () => { ); it.each(PROGRAM_IDS)('%s sequence ends on the exit screen', (program) => { - const sequence = PROGRAM_SEQUENCES[program]; + const sequence = flowEntries(flowFor(program).flow); expect(sequence[sequence.length - 1].id).toBe(ScreenId.Exit); }); diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index acaf7dc91..ec67a0278 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -3,11 +3,10 @@ import { TaskStatus, Program, type ProgramId, - ScreenId, - Overlay, RunPhase, McpOutcome, } from '@ui/tui/store'; +import { ScreenId, Overlay } from '@ui/tui/router'; import { OutroKind, AdditionalFeature, ScanConsent } from '@lib/wizard-session'; import { EXPANDED_COUNT } from '@ui/tui/constants'; import { @@ -19,6 +18,7 @@ import { HostResolution } from '@lib/host-resolution'; import { Integration } from '@lib/constants'; import { analytics } from '@utils/analytics'; import { getProgramConfig } from '@lib/programs/program-registry'; +import { flowFor } from '@lib/programs/flow-for'; vi.mock('../../../utils/analytics.js', () => ({ analytics: { @@ -46,7 +46,7 @@ vi.mock('../../../lib/health-checks/readiness.js', () => ({ })); function createStore(program?: ProgramId): WizardStore { - return new WizardStore(program); + return new WizardStore(flowFor(program ?? Program.PostHogIntegration).flow); } const wizardCaptureMock = analytics.wizardCapture as Mock; @@ -81,12 +81,12 @@ describe('WizardStore', () => { it('defaults to Wizard flow', () => { const store = createStore(); - expect(store.router.activeProgram).toBe(Program.PostHogIntegration); + expect(store.activeProgram).toBe(Program.PostHogIntegration); }); it('accepts a custom flow', () => { const store = createStore(Program.McpAdd); - expect(store.router.activeProgram).toBe(Program.McpAdd); + expect(store.activeProgram).toBe(Program.McpAdd); }); it('starts with version 0', () => { @@ -99,14 +99,14 @@ describe('WizardStore', () => { describe('switchProgram', () => { it('makes the chosen program the active one', () => { const store = createStore(); - store.switchProgram(Program.Metrics); - expect(store.router.activeProgram).toBe(Program.Metrics); + store.switchProgram(flowFor(Program.Metrics).flow); + expect(store.activeProgram).toBe(Program.Metrics); }); it('routes to the new program instead of finishing the old one', () => { const store = createStore(); - store.switchProgram(Program.Metrics); - expect(store.router.resolve(store.session)).toBe(ScreenId.MetricsIntro); + store.switchProgram(flowFor(Program.Metrics).flow); + expect(store.currentScreen).toBe(ScreenId.MetricsIntro); }); // Every program gates its intro on the same flag, so a stale one skips it. @@ -115,10 +115,10 @@ describe('WizardStore', () => { store.completeSetup(); expect(store.session.setupConfirmed).toBe(true); - store.switchProgram(Program.Metrics); + store.switchProgram(flowFor(Program.Metrics).flow); expect(store.session.setupConfirmed).toBe(false); - expect(store.router.resolve(store.session)).toBe(ScreenId.MetricsIntro); + expect(store.currentScreen).toBe(ScreenId.MetricsIntro); }); // Already resolved for the program we left, so reusing them skips screens. @@ -128,7 +128,7 @@ describe('WizardStore', () => { store.completeSetup(); await expect(before).resolves.toBeUndefined(); - store.switchProgram(Program.Metrics); + store.switchProgram(flowFor(Program.Metrics).flow); const after = store.getGate('intro'); expect(after).not.toBe(before); @@ -142,14 +142,14 @@ describe('WizardStore', () => { const store = createStore(); const parked = store.getGate('intro'); - store.switchProgram(Program.Metrics); + store.switchProgram(flowFor(Program.Metrics).flow); await expect(parked).resolves.toBeUndefined(); }); it('reports screens under the new program', () => { const store = createStore(); - store.switchProgram(Program.Metrics); + store.switchProgram(flowFor(Program.Metrics).flow); expect(store.analyticsProgramId).toBe(Program.Metrics); }); @@ -157,7 +157,7 @@ describe('WizardStore', () => { // switch would otherwise still carry the program the run started as. it('retags the run with the new program', () => { const store = createStore(); - store.switchProgram(Program.Metrics); + store.switchProgram(flowFor(Program.Metrics).flow); expect(analytics.setTag).toHaveBeenCalledWith( 'program_id', Program.Metrics, @@ -166,7 +166,7 @@ describe('WizardStore', () => { it('follows the new program for label and skill', () => { const store = createStore(); - store.switchProgram(Program.Metrics); + store.switchProgram(flowFor(Program.Metrics).flow); expect(store.session.programLabel).toBe(Program.Metrics); expect(store.session.skillId).toBe( getProgramConfig(Program.Metrics).skillId ?? null, @@ -177,9 +177,9 @@ describe('WizardStore', () => { it('leaves the session alone when the program is unchanged', () => { const store = createStore(); store.completeSetup(); - store.switchProgram(Program.PostHogIntegration); + store.switchProgram(flowFor(Program.PostHogIntegration).flow); expect(store.session.setupConfirmed).toBe(true); - expect(store.router.activeProgram).toBe(Program.PostHogIntegration); + expect(store.activeProgram).toBe(Program.PostHogIntegration); }); }); }); @@ -711,53 +711,40 @@ describe('WizardStore', () => { // ── Overlay navigation ─────────────────────────────────────────── describe('overlay navigation', () => { - it('pushOverlay shows the overlay over the current screen', () => { + it('pushInterrupt shows the overlay over the current screen', () => { const store = createStore(); - store.pushOverlay(Overlay.SettingsOverride); + store.pushInterrupt(Overlay.SettingsOverride); expect(store.currentScreen).toBe(Overlay.SettingsOverride); }); - it('popOverlay returns to the underlying screen', () => { + it('popInterrupt returns to the underlying screen', () => { const store = createStore(); - store.pushOverlay(Overlay.SettingsOverride); - store.popOverlay(); + store.pushInterrupt(Overlay.SettingsOverride); + store.popInterrupt(); expect(store.currentScreen).toBe(ScreenId.Intro); }); - it('pushOverlay emits change and increments version', () => { + it('pushInterrupt emits change and increments version', () => { const store = createStore(); const cb = vi.fn(); store.subscribe(cb); - store.pushOverlay(Overlay.SettingsOverride); + store.pushInterrupt(Overlay.SettingsOverride); expect(cb).toHaveBeenCalledTimes(1); expect(store.getVersion()).toBe(1); }); - it('popOverlay emits change and increments version', () => { + it('popInterrupt emits change and increments version', () => { const store = createStore(); - store.pushOverlay(Overlay.SettingsOverride); + store.pushInterrupt(Overlay.SettingsOverride); const cb = vi.fn(); store.subscribe(cb); - store.popOverlay(); + store.popInterrupt(); expect(cb).toHaveBeenCalledTimes(1); }); - - it('pushOverlay sets direction to push', () => { - const store = createStore(); - store.pushOverlay(Overlay.SettingsOverride); - expect(store.lastNavDirection).toBe('push'); - }); - - it('popOverlay sets direction to pop', () => { - const store = createStore(); - store.pushOverlay(Overlay.SettingsOverride); - store.popOverlay(); - expect(store.lastNavDirection).toBe('pop'); - }); }); // ── wizard_ask overlay ─────────────────────────────────────────── @@ -840,8 +827,8 @@ describe('WizardStore', () => { }); }); - describe('tokenUsage / toggleTokenHud (hidden Ctrl+T HUD)', () => { - it('starts at zero usage, and visible by default in dev/test (IS_DEV)', () => { + describe('tokenUsage (hidden Ctrl+T HUD)', () => { + it('starts at zero usage', () => { const store = createStore(); expect(store.tokenUsage).toEqual({ inputTokens: 0, @@ -851,18 +838,6 @@ describe('WizardStore', () => { costUsd: 0, costIsFinal: false, }); - // Defaults to IS_DEV, which is true under vitest (NODE_ENV=test) -- - // see WizardStore's $tokenHudVisible doc comment. - expect(store.tokenHudVisible).toBe(true); - }); - - it('toggleTokenHud flips visibility each call, from whatever it started at', () => { - const store = createStore(); - const initial = store.tokenHudVisible; - store.toggleTokenHud(); - expect(store.tokenHudVisible).toBe(!initial); - store.toggleTokenHud(); - expect(store.tokenHudVisible).toBe(initial); }); it('addTokenUsage accumulates token counts and cost across calls', () => { @@ -963,7 +938,6 @@ describe('WizardStore', () => { const versions: number[] = []; store.subscribe(() => versions.push(store.getSnapshot())); - store.toggleTokenHud(); store.addTokenUsage({ inputTokens: 1, outputTokens: 0, @@ -973,7 +947,7 @@ describe('WizardStore', () => { cacheCreation1h: 0, }); - expect(versions.length).toBe(2); + expect(versions.length).toBe(1); }); }); @@ -1138,21 +1112,6 @@ describe('WizardStore', () => { }); }); - // ── Navigation direction ───────────────────────────────────────── - - describe('lastNavDirection', () => { - it('starts as null', () => { - const store = createStore(); - expect(store.lastNavDirection).toBeNull(); - }); - - it('is set to push on emitChange', () => { - const store = createStore(); - store.emitChange(); - expect(store.lastNavDirection).toBe('push'); - }); - }); - // ── Concurrent / rapid-fire mutations ───────────────────────────── describe('concurrent mutations', () => { @@ -1232,7 +1191,7 @@ describe('WizardStore', () => { }); store.completeSetup(); // -> health-check - store.pushOverlay(Overlay.SettingsOverride); // -> settings-override + store.pushInterrupt(Overlay.SettingsOverride); // -> settings-override store.setCredentials({ // -> settings-override (overlay still on top) accessToken: 'tok', @@ -1240,7 +1199,7 @@ describe('WizardStore', () => { host: HostResolution.fromApiHost('h'), projectId: 1, }); - store.popOverlay(); // -> health-check (readinessResult still null) + store.popInterrupt(); // -> health-check (readinessResult still null) expect(screens).toEqual([ ScreenId.HealthCheck, @@ -1365,9 +1324,9 @@ describe('WizardStore', () => { expect(cb).not.toHaveBeenCalled(); }); - it('popOverlay on empty stack does not crash', () => { + it('popInterrupt on empty stack does not crash', () => { const store = createStore(); - expect(() => store.popOverlay()).not.toThrow(); + expect(() => store.popInterrupt()).not.toThrow(); expect(store.currentScreen).toBe(ScreenId.Intro); }); diff --git a/src/ui/tui/__tests__/task-notice.test.ts b/src/ui/tui/__tests__/task-notice.test.ts index 94be3f76c..aa01e1313 100644 --- a/src/ui/tui/__tests__/task-notice.test.ts +++ b/src/ui/tui/__tests__/task-notice.test.ts @@ -15,7 +15,10 @@ vi.mock('../../../utils/analytics.js', () => ({ sessionProperties: vi.fn(() => ({})), })); -import { WizardStore, Overlay } from '@ui/tui/store'; +import { WizardStore } from '@ui/tui/store'; +import { Overlay } from '@ui/tui/router'; +import { flowFor } from '@lib/programs/flow-for'; +import { Program } from '@lib/programs/program-registry'; const NOTICE: TaskNotice = { title: 'Connect your data sources', @@ -33,14 +36,14 @@ const NOTICE: TaskNotice = { describe('task notice', () => { it('resolves true when kept and false when skipped, closing the overlay', async () => { - const store = new WizardStore(); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); const kept = store.showTaskNotice(NOTICE); - expect(store.router.resolve(store.session)).toBe(Overlay.TaskNotice); + expect(store.currentScreen).toBe(Overlay.TaskNotice); store.resolveTaskNotice(true); await expect(kept).resolves.toBe(true); expect(store.session.taskNotice).toBeNull(); - expect(store.router.resolve(store.session)).not.toBe(Overlay.TaskNotice); + expect(store.currentScreen).not.toBe(Overlay.TaskNotice); const skipped = store.showTaskNotice(NOTICE); store.resolveTaskNotice(false); @@ -48,7 +51,7 @@ describe('task notice', () => { }); it('leaves no notice behind for the next step to inherit', () => { - const store = new WizardStore(); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); expect(store.session.taskNotice).toBeNull(); }); }); diff --git a/src/ui/tui/__tests__/ui-store.test.ts b/src/ui/tui/__tests__/ui-store.test.ts new file mode 100644 index 000000000..662ec50d9 --- /dev/null +++ b/src/ui/tui/__tests__/ui-store.test.ts @@ -0,0 +1,89 @@ +import { WizardStore } from '@ui/tui/store'; +import { UiStore } from '@ui/tui/ui-store'; +import { Overlay } from '@ui/tui/router'; +import { flowFor } from '@lib/programs/flow-for'; +import { Program } from '@lib/programs/program-registry'; + +vi.mock('../../../utils/analytics.js', () => ({ + analytics: { + capture: vi.fn(), + wizardCapture: vi.fn(), + setTag: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + }, + sessionProperties: vi.fn(() => ({})), +})); + +function pair() { + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); + return { store, ui: new UiStore(store) }; +} + +describe('UiStore', () => { + describe('lastNavDirection', () => { + it('starts as null', () => { + expect(pair().ui.lastNavDirection).toBeNull(); + }); + + it('is push after emitChange, push after an interrupt opens, pop after it closes', () => { + const { store, ui } = pair(); + store.emitChange(); + expect(ui.lastNavDirection).toBe('push'); + store.pushInterrupt(Overlay.AuthError); + expect(ui.lastNavDirection).toBe('push'); + store.popInterrupt(); + expect(ui.lastNavDirection).toBe('pop'); + store.emitChange(); + expect(ui.lastNavDirection).toBe('push'); + }); + }); + + describe('status bar', () => { + it('toggles and sets, and a same-value set notifies nothing', () => { + const { ui } = pair(); + const versions: number[] = []; + ui.subscribe(() => versions.push(ui.getSnapshot())); + ui.toggleStatusExpanded(); + expect(ui.statusExpanded).toBe(true); + ui.setStatusExpanded(true); + expect(versions).toHaveLength(1); + ui.setStatusExpanded(false); + expect(ui.statusExpanded).toBe(false); + expect(versions).toHaveLength(2); + }); + }); + + describe('token HUD', () => { + it('is visible by default under vitest (IS_DEV) and toggles each call', () => { + const { ui } = pair(); + expect(ui.tokenHudVisible).toBe(true); + ui.toggleTokenHud(); + expect(ui.tokenHudVisible).toBe(false); + ui.toggleTokenHud(); + expect(ui.tokenHudVisible).toBe(true); + }); + }); + + describe('learn card', () => { + it('tracks the block index silently and completion with a notification', () => { + const { ui } = pair(); + const versions: number[] = []; + ui.subscribe(() => versions.push(ui.getSnapshot())); + ui.setLearnCardBlockIdx(3); + expect(ui.learnCardBlockIdx).toBe(3); + expect(versions).toHaveLength(0); + ui.setLearnCardComplete(); + expect(ui.learnCardComplete).toBe(true); + expect(versions).toHaveLength(1); + }); + }); + + it('re-emits every store commit and mirrors the active screen', () => { + const { store, ui } = pair(); + const versions: number[] = []; + ui.subscribe(() => versions.push(ui.getSnapshot())); + store.completeSetup(); + expect(versions).toHaveLength(1); + expect(ui.activeScreen).toBe(store.currentScreen); + }); +}); diff --git a/src/ui/tui/components/LearnCard.tsx b/src/ui/tui/components/LearnCard.tsx index be6dfc65f..691ef86a5 100644 --- a/src/ui/tui/components/LearnCard.tsx +++ b/src/ui/tui/components/LearnCard.tsx @@ -2,7 +2,7 @@ * LearnCard — Generic render shell for an animated content deck. * * Program-owned. Callers pass the script via `blocks`. The script lives - * under `src/lib/programs//content/`. The shell handles + * under `src/ui/tui/programs//content/`. The shell handles * dimension tracking, status-bar height math, and the `display="none"` * clamp on narrow terminals. */ @@ -17,6 +17,7 @@ import { COLLAPSED_COUNT, EXPANDED_COUNT, } from '@ui/tui/primitives/TabContainer'; +import { useUiStore } from '@ui/tui/hooks/useUiStore'; /** Fixed chrome: ScreenContainer (3) + TabContainer tab bar (2) */ const FIXED_CHROME = 5; @@ -32,11 +33,12 @@ interface LearnCardProps { export const LearnCard = ({ store, blocks, onComplete }: LearnCardProps) => { const [columns, rows] = useStdoutDimensions(); + const ui = useUiStore(); // Dynamic status bar height: messages + border when present const hasStatus = store ? store.statusMessages.length > 0 : false; const statusBarRows = hasStatus - ? (store?.statusExpanded ? EXPANDED_COUNT : COLLAPSED_COUNT) + 1 + ? (ui?.statusExpanded ? EXPANDED_COUNT : COLLAPSED_COUNT) + 1 : 0; const contentHeight = rows - FIXED_CHROME - statusBarRows; @@ -64,8 +66,8 @@ export const LearnCard = ({ store, blocks, onComplete }: LearnCardProps) => { maxHeight={maxHeight} availableWidth={paneWidth} startDelay={2000} - initialBlockIdx={store?.learnCardBlockIdx ?? 0} - onBlockChange={(idx) => store?.setLearnCardBlockIdx(idx)} + initialBlockIdx={ui?.learnCardBlockIdx ?? 0} + onBlockChange={(idx) => ui?.setLearnCardBlockIdx(idx)} onSequenceComplete={onComplete} /> diff --git a/src/ui/tui/components/StatusPeekTrigger.tsx b/src/ui/tui/components/StatusPeekTrigger.tsx index a49e29e8b..60c5e90c4 100644 --- a/src/ui/tui/components/StatusPeekTrigger.tsx +++ b/src/ui/tui/components/StatusPeekTrigger.tsx @@ -8,29 +8,31 @@ import { Text } from 'ink'; import { useEffect } from 'react'; import type { WizardStore } from '@ui/tui/store'; +import { useUiStore } from '@ui/tui/hooks/useUiStore'; let peekedOnce = false; interface StatusPeekTriggerProps { + /** Accepted for the content decks that pass it; presentation reads the UiStore. */ store?: WizardStore; /** How long the status bar stays expanded, in ms. */ duration?: number; } export const StatusPeekTrigger = ({ - store, duration = 10000, }: StatusPeekTriggerProps) => { + const ui = useUiStore(); useEffect(() => { if (peekedOnce) return; peekedOnce = true; - store?.setStatusExpanded(true); + ui?.setStatusExpanded(true); // No cleanup — the store call is safe after unmount and the component // may be evicted before the timer fires (non-persist NodeBlock). setTimeout(() => { - store?.setStatusExpanded(false); + ui?.setStatusExpanded(false); }, duration); - }, [store, duration]); + }, [ui, duration]); return You can view the Wizard's status below.; }; diff --git a/src/ui/headless-ui.ts b/src/ui/tui/console/headless-ui.ts similarity index 91% rename from src/ui/headless-ui.ts rename to src/ui/tui/console/headless-ui.ts index 7029865f1..d31456fc7 100644 --- a/src/ui/headless-ui.ts +++ b/src/ui/tui/console/headless-ui.ts @@ -1,5 +1,5 @@ -import { LoggingUI } from './logging-ui'; -import type { WizardStore } from './tui/store'; +import { LoggingUI } from './logging-ui.js'; +import type { WizardStore } from '@ui/tui/store'; /** * `LoggingUI` plus it feeds run state into a `WizardStore` so the background diff --git a/src/ui/logging-ui.ts b/src/ui/tui/console/logging-ui.ts similarity index 99% rename from src/ui/logging-ui.ts rename to src/ui/tui/console/logging-ui.ts index a0b880d37..d2f2e6a11 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/tui/console/logging-ui.ts @@ -10,7 +10,7 @@ import { type SpinnerHandle, type AuthErrorDetail, type TokenUsageDelta, -} from './wizard-ui'; +} from '@ui/wizard-ui'; import type { SettingsConflict } from '@lib/claude-settings'; import type { ApiUser } from '@lib/api'; import { OAUTH_TIMEOUT_MS } from '@lib/constants'; diff --git a/src/ui/tui/constants.ts b/src/ui/tui/constants.ts index 9c055375c..6eaf2df55 100644 --- a/src/ui/tui/constants.ts +++ b/src/ui/tui/constants.ts @@ -1,9 +1,8 @@ /** - * Status-bar window sizes. How many status lines the bar shows collapsed vs - * expanded. Kept in a dependency-free module so both the renderer - * (TabContainer) and the store (which caps retained history to the window) - * share one definition. + * Status-bar window sizes: how many status lines the bar shows collapsed vs + * expanded. The expanded window equals the store's retention cap. */ +import { MAX_STATUS_MESSAGES } from './store.js'; export const COLLAPSED_COUNT = 2; -export const EXPANDED_COUNT = 10; +export const EXPANDED_COUNT = MAX_STATUS_MESSAGES; diff --git a/src/ui/tui/exit-line.ts b/src/ui/tui/exit-line.ts index 000402a06..ca8cebcf8 100644 --- a/src/ui/tui/exit-line.ts +++ b/src/ui/tui/exit-line.ts @@ -18,6 +18,7 @@ import { OutroKind } from '@lib/wizard-session'; import { isRunFailure, MINT_FAILURE_CONTACT } from '@ui/mint-failure'; import { formatTokenCount, formatCostUsd } from '@lib/token-pricing'; import { getLogFilePath } from '@utils/debug'; +import type { UiStore } from './ui-store.js'; const RESET_ATTRS = '\x1b[0m'; const GREEN = '\x1b[32m'; @@ -26,7 +27,7 @@ const DIM = '\x1b[2m'; /** * Mirrors the hidden Ctrl+T HUD's tally into post-exit scrollback — but only - * when the HUD is actually visible at exit (`store.tokenHudVisible`, which + * when the HUD is actually visible at exit (`ui.tokenHudVisible`, which * defaults on in dev/test and off in production; `useDismissOnAnyKey` * already keeps Ctrl+T from also dismissing the outro screen underneath * it). A production run where the user never toggled it on shouldn't have a @@ -34,8 +35,8 @@ const DIM = '\x1b[2m'; * HUD is hidden, or the run never produced any usage (e.g. non-agent * programs). */ -function tokenCostLine(store: WizardStore): string | null { - if (!store.tokenHudVisible) return null; +function tokenCostLine(store: WizardStore, ui: UiStore): string | null { + if (!ui.tokenHudVisible) return null; const usage = store.tokenUsage; if (totalTokenCount(usage) === 0) return null; @@ -65,10 +66,10 @@ function mcpLoginBlock(store: WizardStore): string | null { ); } -export function getExitLine(store: WizardStore): string { +export function getExitLine(store: WizardStore, ui: UiStore): string { const outro = store.session.outroData; const label = store.session.programLabel ?? 'Wizard'; - const costLine = tokenCostLine(store); + const costLine = tokenCostLine(store, ui); const loginBlock = mcpLoginBlock(store); if (isRunFailure(store.session)) { diff --git a/src/ui/tui/hooks/useUiStore.ts b/src/ui/tui/hooks/useUiStore.ts new file mode 100644 index 000000000..84ef65ea0 --- /dev/null +++ b/src/ui/tui/hooks/useUiStore.ts @@ -0,0 +1,14 @@ +import { createContext, useContext, useSyncExternalStore } from 'react'; +import type { UiStore } from '../ui-store.js'; + +export const UiStoreContext = createContext(null); + +/** The presentation store, subscribed; null outside a ScreenContainer. */ +export function useUiStore(): UiStore | null { + const ui = useContext(UiStoreContext); + useSyncExternalStore( + (cb) => (ui ? ui.subscribe(cb) : () => undefined), + () => (ui ? ui.getSnapshot() : 0), + ); + return ui; +} diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 4e05831a9..132b11e4c 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -1,294 +1 @@ -/** - * InkUI — Ink-backed implementation of WizardUI. - * - * Translates business logic calls into store setter calls. - * No direct session mutation. No imperative screen transitions. - * The router derives the active screen from session state. - */ - -import type { - WizardUI, - SpinnerHandle, - AuthErrorDetail, - TokenUsageDelta, -} from '@ui/wizard-ui'; -import type { WizardStore } from './store.js'; -import type { SettingsConflict } from '@lib/claude-settings'; -import type { WizardReadinessResult } from '@lib/health-checks/readiness'; -import type { ApiUser } from '@lib/api'; -import type { - AskAnswers, - Credentials, - OutroData, - PendingQuestion, - TaskNotice, -} from '@lib/wizard-session'; -import { RunPhase, OutroKind } from '@lib/wizard-session'; - -// Strip ANSI escape codes (chalk formatting) from strings -// eslint-disable-next-line no-control-regex -const ANSI_RE = /\x1b\[[0-9;]*m/g; -function stripAnsi(s: string): string { - return s.replace(ANSI_RE, ''); -} - -export class InkUI implements WizardUI { - readonly interactive = true; - constructor(private store: WizardStore) {} - - intro(message: string): void { - this.store.pushStatus(message); - } - - outro(message: string): void { - this.store.pushStatus(stripAnsi(message)); - - // Outro data is pushed by agent-runner via setOutroData() above. If it - // wasn't (e.g. CI path where outro is called directly with just a - // message), fall back to a minimal success record so the screen still - // renders something useful. - const existing = this.store.session.outroData; - if (!existing) { - this.store.setOutroData({ - kind: OutroKind.Success, - message: stripAnsi(message), - }); - } - - // Signal that the main work is done — router resolves to outro - if (this.store.session.runPhase === RunPhase.Running) { - this.store.setRunPhase(RunPhase.Completed); - } - } - - outroError(data: OutroData): void { - this.store.setOutroData(data); - // Advance router past the run step so the outro screen renders - if (this.store.session.runPhase !== RunPhase.Error) { - this.store.setRunPhase(RunPhase.Error); - } - } - - waitForOutroDismissed(): Promise { - return new Promise((resolve) => { - if (this.store.session.outroDismissed) { - resolve(); - return; - } - const unsub = this.store.subscribe(() => { - if (this.store.session.outroDismissed) { - unsub(); - resolve(); - } - }); - }); - } - - setCredentials(credentials: Credentials): void { - this.store.setCredentials(credentials); - } - - setAccessToken(credentials: Credentials): void { - this.store.setAccessToken(credentials); - } - - setRoleAtOrganization(role: string | null): void { - this.store.setRoleAtOrganization(role); - } - - setApiUser(user: ApiUser | null): void { - this.store.setApiUser(user); - } - - waitForAiOptIn(): Promise { - // Resolved immediately when no gate is registered (requiresAi: false, - // no auth step, or CI). Otherwise parks until _checkGates sees the - // org's approval flip to true — e.g. via [R]etry on the kill screen. - return this.store.getGate('ai-opt-in'); - } - - waitForGate(stepId: string): Promise { - return this.store.getGate(stepId); - } - - getFrameworkContext(key: string): unknown { - return this.store.session.frameworkContext[key]; - } - - setDetectedFramework(label: string): void { - this.store.setDetectedFramework(label); - } - - onEnterScreen(screen: string, fn: () => void): void { - this.store.onEnterScreen( - screen as Parameters[0], - fn, - ); - } - - setLoginUrl(url: string | null): void { - this.store.setLoginUrl(url); - } - - setAuthorizeUrl(url: string | null): void { - this.store.setAuthorizeUrl(url); - } - - showBlockingOutage(result: WizardReadinessResult): Promise { - // In the TUI, the HealthCheckScreen handles outage display. - // This is only called from agent-runner for the CI fallback path. - this.store.setReadinessResult(result); - return Promise.resolve(); - } - - setReadinessWarnings(result: WizardReadinessResult): void { - this.store.setReadinessResult(result); - } - - showPortConflict(processInfo: { - command: string; - pid: string; - port: number; - user: string; - }): Promise { - return this.store.showPortConflict(processInfo); - } - - waitForManualAuthCode(): Promise { - return this.store.waitForManualAuthCode(); - } - - showTaskNotice(notice: TaskNotice): Promise { - return this.store.showTaskNotice(notice); - } - - cancelTaskNotice(): void { - // Same path as pressing Skip: closes the overlay and resolves the pending - // showTaskNotice promise with false. - this.store.resolveTaskNotice(false); - } - - showSettingsOverride( - conflicts: SettingsConflict[], - backupAndFix: () => boolean, - ): Promise { - return this.store.showSettingsOverride(conflicts, backupAndFix); - } - - showAuthError(detail?: AuthErrorDetail): void { - this.store.showAuthError(detail); - } - - showSessionTimeout(): void { - this.store.showSessionTimeout(); - } - - requestQuestion(question: PendingQuestion): Promise { - return this.store.requestQuestion(question); - } - - cancelPendingQuestion(): void { - this.store.cancelPendingQuestion(); - } - - startRun(): void { - this.store.setRunPhase(RunPhase.Running); - } - - cancel(message: string): void { - this.store.pushStatus(message); - } - - log = { - info: (message: string): void => { - this.store.pushStatus(message); - }, - warn: (message: string): void => { - this.store.pushStatus(message); - }, - error: (message: string): void => { - this.store.pushStatus(message); - }, - success: (message: string): void => { - this.store.pushStatus(message); - }, - step: (message: string): void => { - this.store.pushStatus(message); - }, - }; - - note(message: string): void { - this.store.pushStatus(message); - } - - spinner(): SpinnerHandle { - return { - start: (message?: string) => { - if (message) this.store.pushStatus(message); - }, - stop: (message?: string) => { - if (message) this.store.pushStatus(message); - }, - message: (msg?: string) => { - if (msg) this.store.pushStatus(msg); - }, - }; - } - - pushStatus(message: string): void { - this.store.pushStatus(message); - } - - syncTodos( - todos: Array<{ content: string; status: string; activeForm?: string }>, - ): void { - this.store.syncTodos(todos); - } - - setEventPlan(events: Array<{ name: string; description: string }>): void { - this.store.setEventPlan(events); - } - - setDashboardUrl(url: string): void { - this.store.setDashboardUrl(url); - } - - setStage(stage: string): void { - this.store.setCurrentStage(stage); - } - - setNotebookUrl(url: string): void { - this.store.setNotebookUrl(url); - } - - setHandoffText(text: string): void { - this.store.setHandoffText(text); - } - - addTokenUsage(delta: TokenUsageDelta): void { - this.store.addTokenUsage(delta); - } - - setFinalTokenCostUsd(costUsd: number): void { - this.store.setFinalTokenCostUsd(costUsd); - } - - setOutroData(data: OutroData): void { - // Merge in URLs the agent emitted via `[DASHBOARD_URL]` / `[NOTEBOOK_URL]` - // markers. These land on the live store during the run; agent-runner's - // `session` snapshot misses them (setKey forks the reference). The live - // store wins over the `data` payload so a real emission always beats any - // fallback the program's buildOutroData may have computed from the stale - // snapshot (e.g. events-audit defaults dashboardUrl to `${cloudUrl}/dashboard`). - const live = this.store.session; - this.store.setOutroData({ - ...data, - dashboardUrl: live.dashboardUrl ?? data.dashboardUrl ?? undefined, - notebookUrl: live.notebookUrl ?? data.notebookUrl ?? undefined, - }); - } - - setFrameworkContext(key: string, value: unknown): void { - this.store.setFrameworkContext(key, value); - } -} +export { StoreUI as InkUI } from '../store-ui.js'; diff --git a/src/ui/tui/playground/PlaygroundApp.tsx b/src/ui/tui/playground/PlaygroundApp.tsx index 3c06e130e..5d8d07ce8 100644 --- a/src/ui/tui/playground/PlaygroundApp.tsx +++ b/src/ui/tui/playground/PlaygroundApp.tsx @@ -7,6 +7,7 @@ import { ScreenContainer, TabContainer } from '@ui/tui/primitives/index'; import type { WizardStore } from '@ui/tui/store'; +import type { UiStore } from '@ui/tui/ui-store'; import { WelcomeDemo } from './demos/WelcomeDemo.js'; import { LayoutDemo } from './demos/LayoutDemo.js'; import { InputDemo } from './demos/InputDemo.js'; @@ -28,9 +29,10 @@ import { ViewportGuardDemo } from './demos/ViewportGuardDemo.js'; interface PlaygroundAppProps { store: WizardStore; + ui: UiStore; } -export const PlaygroundApp = ({ store }: PlaygroundAppProps) => { +export const PlaygroundApp = ({ store, ui }: PlaygroundAppProps) => { const tabs = [ { id: 'layout', label: 'Layout', component: }, { id: 'input', label: 'Input', component: }, @@ -107,6 +109,7 @@ export const PlaygroundApp = ({ store }: PlaygroundAppProps) => { return ( , run: ( diff --git a/src/ui/tui/playground/demos/AiOptInDemo.tsx b/src/ui/tui/playground/demos/AiOptInDemo.tsx index 97897e219..cb31da364 100644 --- a/src/ui/tui/playground/demos/AiOptInDemo.tsx +++ b/src/ui/tui/playground/demos/AiOptInDemo.tsx @@ -17,6 +17,8 @@ import { Box, Text } from 'ink'; import { WizardStore } from '@ui/tui/store'; import { AiOptInRequiredScreen } from '@ui/tui/screens/AiOptInRequiredScreen'; import { HostResolution } from '@lib/host-resolution'; +import { flowFor } from '@lib/programs/flow-for'; +import { Program } from '@lib/programs/program-registry'; type Variant = 'admin' | 'non-admin'; @@ -26,7 +28,7 @@ interface AiOptInDemoProps { export const AiOptInDemo = ({ variant }: AiOptInDemoProps) => { const [store] = useState(() => { - const s = new WizardStore(); + const s = new WizardStore(flowFor(Program.PostHogIntegration).flow); s.setCredentials({ accessToken: 'demo-fake-token', projectApiKey: 'demo-fake-project-key', diff --git a/src/ui/tui/playground/demos/LearnDeckDemo.tsx b/src/ui/tui/playground/demos/LearnDeckDemo.tsx index a38e3ecf3..0aff94d51 100644 --- a/src/ui/tui/playground/demos/LearnDeckDemo.tsx +++ b/src/ui/tui/playground/demos/LearnDeckDemo.tsx @@ -10,8 +10,8 @@ * Arrow keys are reserved for the playground's tab switcher, so this demo * uses letter keys. * - * Decks are pulled from `PROGRAM_REGISTRY` so every program that ships a - * deck is reviewable here. Migration also gets per-variant entries (one + * Decks are pulled from `PROGRAM_PRESENTATION` so every program that ships + * a deck is reviewable here. Migration also gets per-variant entries (one * per `--product=` choice) so the variant composer in * `migration/content/index.tsx` can be exercised side-by-side with the * generic deck. @@ -29,6 +29,7 @@ import type { ContentBlock, ProgressItem } from '@ui/tui/primitives/index'; import { Colors } from '@ui/tui/styles'; import type { WizardStore } from '@ui/tui/store'; import { PROGRAM_REGISTRY } from '@lib/programs/program-registry'; +import { PROGRAM_PRESENTATION } from '@ui/tui/programs/presentation'; import { AUDIT_AREA_SLIDES } from '@ui/tui/screens/audit/slides/index'; import type { AreaSlide } from '@ui/tui/screens/audit/slides/shared'; @@ -94,7 +95,9 @@ export const LearnDeckDemo = ({ store }: LearnDeckDemoProps) => { // name (e.g. agent-skill's "Running the skill...") render the // real value instead of "unknown". for (const program of PROGRAM_REGISTRY) { - if (!program.getContentBlocks) continue; + const getContentBlocks = + PROGRAM_PRESENTATION[program.id]?.getContentBlocks; + if (!getContentBlocks) continue; const stub = program.skillId ? withSessionOverride(store, { skillId: program.skillId }) : store; @@ -103,7 +106,7 @@ export const LearnDeckDemo = ({ store }: LearnDeckDemoProps) => { label: `${program.id} (${program.command ?? 'default'})${ program.skillId ? ` · skill: ${program.skillId}` : '' }`, - blocks: program.getContentBlocks(stub), + blocks: getContentBlocks(stub), }); } diff --git a/src/ui/tui/playground/demos/RunScreenDemo.tsx b/src/ui/tui/playground/demos/RunScreenDemo.tsx index 27cc7d193..06859703c 100644 --- a/src/ui/tui/playground/demos/RunScreenDemo.tsx +++ b/src/ui/tui/playground/demos/RunScreenDemo.tsx @@ -35,10 +35,11 @@ import type { ProgressItem, TabDefinition } from '@ui/tui/primitives/index'; import { LearnCard } from '@ui/tui/components/LearnCard'; import { TipsCard } from '@ui/tui/components/TipsCard'; import { VisualizerTab } from '@ui/tui/components/PhaseVisuals'; -import { getProgramConfig } from '@lib/programs/program-registry'; -import { getContentBlocks as getSkillContentBlocks } from '@lib/programs/agent-skill/content/index'; +import { PROGRAM_PRESENTATION } from '@ui/tui/programs/presentation'; +import { getContentBlocks as getSkillContentBlocks } from '@ui/tui/programs/agent-skill/content/index'; import { Colors } from '@ui/tui/styles'; import { WIZARD_LOG_FILE } from '@utils/paths'; +import { useUiStore } from '@ui/tui/hooks/useUiStore'; const STAGE_CYCLE: AgentPhase[] = [ AgentPhase.CodebaseScan, @@ -221,20 +222,21 @@ export const RunScreenDemo = ({ store }: RunScreenDemoProps) => { status: t.status, })); + const ui = useUiStore(); const learnBlocks = useMemo(() => { const getBlocks = - getProgramConfig(store.router.activeProgram).getContentBlocks ?? + PROGRAM_PRESENTATION[store.activeProgram]?.getContentBlocks ?? getSkillContentBlocks; return getBlocks(store); }, [store]); - const leftPane = store.learnCardComplete ? ( + const leftPane = ui?.learnCardComplete ? ( ) : ( store.setLearnCardComplete()} + onComplete={() => ui?.setLearnCardComplete()} /> ); diff --git a/src/ui/tui/playground/start-playground.ts b/src/ui/tui/playground/start-playground.ts index 1d59c495e..788903d02 100644 --- a/src/ui/tui/playground/start-playground.ts +++ b/src/ui/tui/playground/start-playground.ts @@ -5,15 +5,18 @@ import { render } from 'ink'; import { createElement } from 'react'; import { WizardStore } from '@ui/tui/store'; +import { UiStore } from '@ui/tui/ui-store'; import { PlaygroundApp } from './PlaygroundApp.js'; import { HostResolution } from '@lib/host-resolution'; import { WizardReadiness } from '@lib/health-checks/readiness'; import { enterDarkTerminal, releaseTerminal } from '../terminal.js'; +import { flowFor } from '@lib/programs/flow-for'; +import { Program } from '@lib/programs/program-registry'; export function startPlayground(version: string): void { enterDarkTerminal(); - const store = new WizardStore(); + const store = new WizardStore(flowFor(Program.PostHogIntegration).flow); store.version = version; // Pre-fill session so the router skips health-check, auth, and setup, @@ -34,7 +37,7 @@ export function startPlayground(version: string): void { }); const { unmount, waitUntilExit } = render( - createElement(PlaygroundApp, { store }), + createElement(PlaygroundApp, { store, ui: new UiStore(store) }), ); void waitUntilExit().then(() => { diff --git a/src/ui/tui/primitives/ScreenContainer.tsx b/src/ui/tui/primitives/ScreenContainer.tsx index afc385c9b..517c88ca9 100644 --- a/src/ui/tui/primitives/ScreenContainer.tsx +++ b/src/ui/tui/primitives/ScreenContainer.tsx @@ -22,11 +22,10 @@ import { KeyboardHintsProvider } from '@ui/tui/hooks/useKeyboardHints'; import { DissolveTransition } from './DissolveTransition.js'; import { KeyboardHintsBar } from './KeyboardHintsBar.js'; import { ScreenErrorBoundary } from './ScreenErrorBoundary.js'; -import { - ViewportTooSmall, - isViewportTooSmall, -} from './ViewportTooSmall.js'; +import { ViewportTooSmall, isViewportTooSmall } from './ViewportTooSmall.js'; import type { WizardStore } from '@ui/tui/store'; +import type { UiStore } from '@ui/tui/ui-store'; +import { UiStoreContext } from '@ui/tui/hooks/useUiStore'; const MIN_WIDTH = 80; export const MAX_WIDTH = 120; @@ -39,15 +38,20 @@ function getContentWidth(terminalColumns: number): number { interface ScreenContainerProps { store: WizardStore; + ui: UiStore; screens: Record; } -export const ScreenContainer = ({ store, screens }: ScreenContainerProps) => { +export const ScreenContainer = ({ + store, + ui, + screens, +}: ScreenContainerProps) => { const [columns, rows] = useStdoutDimensions(); const { stdout } = useStdout(); useSyncExternalStore( - (cb) => store.subscribe(cb), - () => store.getSnapshot(), + (cb) => ui.subscribe(cb), + () => ui.getSnapshot(), ); // Hidden shortcut: Ctrl+T toggles the token/cost HUD. Deliberately not @@ -55,12 +59,12 @@ export const ScreenContainer = ({ store, screens }: ScreenContainerProps) => { // Mounted here (not on any individual screen) so it works everywhere — // ScreenContainer is the one component alive for the whole process. useInput((input, key) => { - if (key.ctrl && input === 't') store.toggleTokenHud(); + if (key.ctrl && input === 't') ui.toggleTokenHud(); }); const terminalWidth = columns; const width = getContentWidth(terminalWidth); - const hudVisible = store.tokenHudVisible; + const hudVisible = ui.tokenHudVisible; // Text width inside TokenCostHud's own paddingX={1} (1 column each side). const hudContentWidth = Math.max(1, width - 2); // 1 row when the "Ctrl+T to hide" hint fits on the cost line's row, 2 when @@ -73,7 +77,7 @@ export const ScreenContainer = ({ store, screens }: ScreenContainerProps) => { : 0; const contentHeight = Math.max(5, rows - 3 - hudRows); const contentAreaWidth = Math.max(10, width - 2); - const direction = store.lastNavDirection === 'pop' ? 'right' : 'left'; + const direction = ui.lastNavDirection === 'pop' ? 'right' : 'left'; const activeScreen = screens[store.currentScreen] ?? null; // Too small to lay out: hide the screens rather than unmounting them. Yoga @@ -88,8 +92,7 @@ export const ScreenContainer = ({ store, screens }: ScreenContainerProps) => { // Only enforced on a real terminal: with stdout piped there are no // dimensions to read (useStdoutDimensions substitutes 80×24) and no window // for anyone to resize, so nagging would be both wrong and unactionable. - const tooSmall = - Boolean(stdout.isTTY) && isViewportTooSmall(columns, rows); + const tooSmall = Boolean(stdout.isTTY) && isViewportTooSmall(columns, rows); const inner = ( { justifyContent="flex-start" > {tooSmall && } - {inner} + + {inner} + ); }; diff --git a/src/ui/tui/primitives/TabContainer.tsx b/src/ui/tui/primitives/TabContainer.tsx index 1b5a0ed69..d8b46947c 100644 --- a/src/ui/tui/primitives/TabContainer.tsx +++ b/src/ui/tui/primitives/TabContainer.tsx @@ -16,6 +16,7 @@ import { } from '@ui/tui/hooks/useKeyBindings'; import type { WizardStore } from '@ui/tui/store'; import { COLLAPSED_COUNT, EXPANDED_COUNT } from '@ui/tui/constants'; +import { useUiStore } from '@ui/tui/hooks/useUiStore'; // Re-exported so existing importers (e.g. LearnCard) keep their path. export { COLLAPSED_COUNT, EXPANDED_COUNT }; @@ -39,13 +40,13 @@ export const TabContainer = ({ tabs, statusMessage, expandableStatus = false, - store, }: TabContainerProps) => { const [activeTab, setActiveTab] = useState(0); // Fallback to local state when no store is provided const [localExpanded, setLocalExpanded] = useState(false); - const statusExpanded = store ? store.statusExpanded : localExpanded; + const ui = useUiStore(); + const statusExpanded = ui ? ui.statusExpanded : localExpanded; const bindings = useMemo(() => { const b: KeyBinding[] = [ @@ -70,8 +71,8 @@ export const TabContainer = ({ action: 'toggle status', priority: 12, handler: () => { - if (store) { - store.toggleStatusExpanded(); + if (ui) { + ui.toggleStatusExpanded(); } else { setLocalExpanded((prev) => !prev); } @@ -79,7 +80,7 @@ export const TabContainer = ({ }); } return b; - }, [tabs.length, expandableStatus, store]); + }, [tabs.length, expandableStatus, ui]); useKeyBindings('tab-container', bindings); diff --git a/src/lib/programs/agent-skill/content/index.tsx b/src/ui/tui/programs/agent-skill/content/index.tsx similarity index 100% rename from src/lib/programs/agent-skill/content/index.tsx rename to src/ui/tui/programs/agent-skill/content/index.tsx diff --git a/src/lib/programs/error-tracking-upload-source-maps/content/index.tsx b/src/ui/tui/programs/error-tracking-upload-source-maps/content/index.tsx similarity index 99% rename from src/lib/programs/error-tracking-upload-source-maps/content/index.tsx rename to src/ui/tui/programs/error-tracking-upload-source-maps/content/index.tsx index bb7d1216a..c9d82b910 100644 --- a/src/lib/programs/error-tracking-upload-source-maps/content/index.tsx +++ b/src/ui/tui/programs/error-tracking-upload-source-maps/content/index.tsx @@ -5,7 +5,7 @@ * It educates the user on what source maps are and why uploading them * matters, built around a before/after stack-trace contrast: a minified * production trace nobody can read, then the same trace resolved back to - * real source. Program-owned; wired onto the program's getContentBlocks. + * real source. Wired onto the program's entry in `PROGRAM_PRESENTATION`. * * Lines stay narrow (~36 cols) because this renders in the left half of a * split pane — see LearnCard's paneWidth math. diff --git a/src/lib/programs/error-tracking/content/index.tsx b/src/ui/tui/programs/error-tracking/content/index.tsx similarity index 84% rename from src/lib/programs/error-tracking/content/index.tsx rename to src/ui/tui/programs/error-tracking/content/index.tsx index 815ad34fc..327776565 100644 --- a/src/lib/programs/error-tracking/content/index.tsx +++ b/src/ui/tui/programs/error-tracking/content/index.tsx @@ -2,7 +2,7 @@ import type { WizardStore } from '@ui/tui/store'; import type { ContentBlock } from '@ui/tui/primitives/content-types'; -import { buildSourceMapsDeck } from '@lib/programs/error-tracking-upload-source-maps/content/index'; +import { buildSourceMapsDeck } from '@ui/tui/programs/error-tracking-upload-source-maps/content/index'; export const getContentBlocks = (store?: WizardStore): ContentBlock[] => buildSourceMapsDeck(store, { diff --git a/src/lib/programs/error-tracking/content/tips.ts b/src/ui/tui/programs/error-tracking/content/tips.ts similarity index 100% rename from src/lib/programs/error-tracking/content/tips.ts rename to src/ui/tui/programs/error-tracking/content/tips.ts diff --git a/src/lib/programs/migration/content/free-tier.tsx b/src/ui/tui/programs/migration/content/free-tier.tsx similarity index 100% rename from src/lib/programs/migration/content/free-tier.tsx rename to src/ui/tui/programs/migration/content/free-tier.tsx diff --git a/src/lib/programs/migration/content/index.tsx b/src/ui/tui/programs/migration/content/index.tsx similarity index 96% rename from src/lib/programs/migration/content/index.tsx rename to src/ui/tui/programs/migration/content/index.tsx index 152e80e8c..37ed35e01 100644 --- a/src/lib/programs/migration/content/index.tsx +++ b/src/ui/tui/programs/migration/content/index.tsx @@ -21,9 +21,9 @@ import { Colors } from '@ui/tui/styles'; import { TextRevealMode } from '@ui/tui/primitives/TextBlock'; import type { ContentBlock } from '@ui/tui/primitives/content-types'; import { StatusPeekTrigger } from '@ui/tui/components/StatusPeekTrigger'; -import { PRODUCT_SUITE_BLOCK } from '@lib/programs/posthog-integration/content/product-suite'; -import { LINE_CHART_BLOCK } from '@lib/programs/posthog-integration/content/line-chart'; -import { FUNNEL_BLOCK } from '@lib/programs/posthog-integration/content/funnel'; +import { PRODUCT_SUITE_BLOCK } from '@ui/tui/programs/posthog-integration/content/product-suite'; +import { LINE_CHART_BLOCK } from '@ui/tui/programs/posthog-integration/content/line-chart'; +import { FUNNEL_BLOCK } from '@ui/tui/programs/posthog-integration/content/funnel'; import { VENDOR_STACK_BLOCK } from './vendor-stack.js'; import { FREE_TIER_BLOCK } from './free-tier.js'; import { PRICING_STRUCTURE_BLOCK } from './pricing-structure.js'; diff --git a/src/lib/programs/migration/content/pricing-structure.tsx b/src/ui/tui/programs/migration/content/pricing-structure.tsx similarity index 100% rename from src/lib/programs/migration/content/pricing-structure.tsx rename to src/ui/tui/programs/migration/content/pricing-structure.tsx diff --git a/src/lib/programs/migration/content/vendor-stack.tsx b/src/ui/tui/programs/migration/content/vendor-stack.tsx similarity index 100% rename from src/lib/programs/migration/content/vendor-stack.tsx rename to src/ui/tui/programs/migration/content/vendor-stack.tsx diff --git a/src/lib/programs/posthog-integration/content/data-flow.tsx b/src/ui/tui/programs/posthog-integration/content/data-flow.tsx similarity index 100% rename from src/lib/programs/posthog-integration/content/data-flow.tsx rename to src/ui/tui/programs/posthog-integration/content/data-flow.tsx diff --git a/src/lib/programs/posthog-integration/content/funnel.tsx b/src/ui/tui/programs/posthog-integration/content/funnel.tsx similarity index 100% rename from src/lib/programs/posthog-integration/content/funnel.tsx rename to src/ui/tui/programs/posthog-integration/content/funnel.tsx diff --git a/src/lib/programs/posthog-integration/content/index.tsx b/src/ui/tui/programs/posthog-integration/content/index.tsx similarity index 100% rename from src/lib/programs/posthog-integration/content/index.tsx rename to src/ui/tui/programs/posthog-integration/content/index.tsx diff --git a/src/lib/programs/posthog-integration/content/line-chart.tsx b/src/ui/tui/programs/posthog-integration/content/line-chart.tsx similarity index 100% rename from src/lib/programs/posthog-integration/content/line-chart.tsx rename to src/ui/tui/programs/posthog-integration/content/line-chart.tsx diff --git a/src/lib/programs/posthog-integration/content/product-suite.tsx b/src/ui/tui/programs/posthog-integration/content/product-suite.tsx similarity index 100% rename from src/lib/programs/posthog-integration/content/product-suite.tsx rename to src/ui/tui/programs/posthog-integration/content/product-suite.tsx diff --git a/src/ui/tui/programs/presentation.ts b/src/ui/tui/programs/presentation.ts new file mode 100644 index 000000000..0bcd2b4a1 --- /dev/null +++ b/src/ui/tui/programs/presentation.ts @@ -0,0 +1,53 @@ +/** + * How each program presents itself while the agent runs: the LearnCard deck + * played in the run screen's left pane, and the tips shown once it finishes. + * + * Presentation lives here rather than on `ProgramConfig` so program + * definitions stay free of Ink. Programs absent from the map fall back to + * the generic skill deck and `DEFAULT_TIPS`. + */ + +import type { ProgramId } from '@lib/programs/program-registry'; +import type { ContentBlock } from '@ui/tui/primitives/index'; +import type { WizardStore } from '@ui/tui/store'; +import type { Tip } from '@ui/tui/components/TipsCard'; +import { getContentBlocks as agentSkillDeck } from './agent-skill/content/index.js'; +import { getContentBlocks as errorTrackingDeck } from './error-tracking/content/index.js'; +import { getTips as errorTrackingTips } from './error-tracking/content/tips.js'; +import { getContentBlocks as sourceMapsDeck } from './error-tracking-upload-source-maps/content/index.js'; +import { getContentBlocks as migrationDeck } from './migration/content/index.js'; +import { getContentBlocks as posthogIntegrationDeck } from './posthog-integration/content/index.js'; +import { getContentBlocks as revenueAnalyticsDeck } from './revenue-analytics/content/index.js'; +import { getContentBlocks as selfDrivingDeck } from './self-driving/content/index.js'; +import { getTips as selfDrivingTips } from './self-driving/content/tips.js'; +import { getContentBlocks as warehouseSourceDeck } from './warehouse-source/content/index.js'; + +export interface ProgramPresentation { + getContentBlocks?: (store?: WizardStore) => ContentBlock[]; + getTips?: (store?: WizardStore) => Tip[]; +} + +export const PROGRAM_PRESENTATION: Partial< + Record +> = { + 'posthog-integration': { getContentBlocks: posthogIntegrationDeck }, + 'revenue-analytics-setup': { getContentBlocks: revenueAnalyticsDeck }, + 'warehouse-source': { getContentBlocks: warehouseSourceDeck }, + 'error-tracking-upload-source-maps': { getContentBlocks: sourceMapsDeck }, + 'error-tracking': { + getContentBlocks: errorTrackingDeck, + getTips: errorTrackingTips, + }, + audit: { getContentBlocks: agentSkillDeck }, + 'web-analytics-doctor': { getContentBlocks: agentSkillDeck }, + migration: { getContentBlocks: migrationDeck }, + 'self-driving': { + getContentBlocks: selfDrivingDeck, + getTips: selfDrivingTips, + }, + 'agent-skill': { getContentBlocks: agentSkillDeck }, + 'mcp-analytics': { getContentBlocks: agentSkillDeck }, + 'replay-vision': { getContentBlocks: agentSkillDeck }, + 'ai-observability': { getContentBlocks: agentSkillDeck }, + metrics: { getContentBlocks: agentSkillDeck }, +}; diff --git a/src/lib/programs/revenue-analytics/content/index.tsx b/src/ui/tui/programs/revenue-analytics/content/index.tsx similarity index 73% rename from src/lib/programs/revenue-analytics/content/index.tsx rename to src/ui/tui/programs/revenue-analytics/content/index.tsx index 1abb4ce84..c15e3548d 100644 --- a/src/lib/programs/revenue-analytics/content/index.tsx +++ b/src/ui/tui/programs/revenue-analytics/content/index.tsx @@ -4,4 +4,4 @@ * revenue narrative grows its own diagrams or talking points. */ -export { getContentBlocks } from '@lib/programs/agent-skill/content/index'; +export { getContentBlocks } from '@ui/tui/programs/agent-skill/content/index'; diff --git a/src/lib/programs/self-driving/content/index.tsx b/src/ui/tui/programs/self-driving/content/index.tsx similarity index 100% rename from src/lib/programs/self-driving/content/index.tsx rename to src/ui/tui/programs/self-driving/content/index.tsx diff --git a/src/lib/programs/self-driving/content/pipeline-diagram.tsx b/src/ui/tui/programs/self-driving/content/pipeline-diagram.tsx similarity index 100% rename from src/lib/programs/self-driving/content/pipeline-diagram.tsx rename to src/ui/tui/programs/self-driving/content/pipeline-diagram.tsx diff --git a/src/lib/programs/self-driving/content/pricing.ts b/src/ui/tui/programs/self-driving/content/pricing.ts similarity index 100% rename from src/lib/programs/self-driving/content/pricing.ts rename to src/ui/tui/programs/self-driving/content/pricing.ts diff --git a/src/lib/programs/self-driving/content/tips.ts b/src/ui/tui/programs/self-driving/content/tips.ts similarity index 93% rename from src/lib/programs/self-driving/content/tips.ts rename to src/ui/tui/programs/self-driving/content/tips.ts index cdb303025..4b7761b3e 100644 --- a/src/lib/programs/self-driving/content/tips.ts +++ b/src/ui/tui/programs/self-driving/content/tips.ts @@ -6,8 +6,8 @@ import { NO_DEFAULT_LIMIT, PRICING_LONG } from './pricing.js'; * loop). The learn deck (`./index.tsx`) carries the teaching; these stay * one to two lines each so the pane never overflows. * - * Product knowledge lives here in the program, not in the generic - * `TipsCard`. Wired onto the program's `getTips`. + * Product knowledge lives here, not in the generic `TipsCard`. Wired + * onto the program's entry in `PROGRAM_PRESENTATION`. */ import type { Tip } from '@ui/tui/components/TipsCard'; diff --git a/src/lib/programs/warehouse-source/content/index.tsx b/src/ui/tui/programs/warehouse-source/content/index.tsx similarity index 73% rename from src/lib/programs/warehouse-source/content/index.tsx rename to src/ui/tui/programs/warehouse-source/content/index.tsx index 4be0eab20..30274e10b 100644 --- a/src/lib/programs/warehouse-source/content/index.tsx +++ b/src/ui/tui/programs/warehouse-source/content/index.tsx @@ -4,4 +4,4 @@ * narrative grows its own diagrams or talking points. */ -export { getContentBlocks } from '@lib/programs/agent-skill/content/index'; +export { getContentBlocks } from '@ui/tui/programs/agent-skill/content/index'; diff --git a/src/ui/tui/router.ts b/src/ui/tui/router.ts index 8c2dc9836..d0c370c68 100644 --- a/src/ui/tui/router.ts +++ b/src/ui/tui/router.ts @@ -1,154 +1,14 @@ /** - * WizardRouter — declarative program pipelines + overlay stack. - * - * Two layers: - * Program cursor — linear sequence of screens, advanced with next() - * Overlay stack — interrupts (outage, auth-expired, etc.) that push/pop - * - * The visible screen is: top of overlay stack if non-empty, otherwise the program cursor. - * - * Adding a program screen = append to a sequence array. - * Adding an overlay = call pushOverlay() from anywhere. - * No switch statements, no hardcoded transitions in business logic. + * Screen name vocabulary for the TUI. Resolution of the active screen lives in + * the store (`WizardStore.currentScreen` over `@lib/flow-resolution`). */ -import { RunPhase, type WizardSession } from '@lib/wizard-session'; -import { isRunFailure } from '@ui/mint-failure'; +import { ScreenId } from './screen-sequences.js'; +import { Interrupt } from '@lib/interrupts'; import { Program, type ProgramId } from '@lib/programs/program-registry'; -import { - PROGRAM_SEQUENCES, - MINT_HANDOFF_SEQUENCE, - ScreenId, - type Screen, - type Sequence, -} from './screen-sequences.js'; -// Re-export so existing imports from './router.js' keep working export { ScreenId, Program }; -export type { Screen, Sequence, ProgramId }; - -// ── ScreenId name taxonomy ────────────────────────────────────────────── - -/** Screens that interrupt programs as overlays */ -export enum Overlay { - SettingsOverride = 'settings-override', - ManagedSettings = 'managed-settings', - PortConflict = 'port-conflict', - ManualAuthCode = 'manual-auth-code', - AuthError = 'auth-error', - SessionTimeout = 'session-timeout', - WizardAsk = 'wizard-ask', - TaskNotice = 'task-notice', -} - -/** Union of all screen names */ -export type ScreenName = ScreenId | Overlay; - -// ── Router ──────────────────────────────────────────────────────────── - -export class WizardRouter { - private sequence: Sequence; - private programId: ProgramId; - private overlays: Overlay[] = []; - - constructor(programId: ProgramId = Program.PostHogIntegration) { - this.setProgram(programId); - } - - /** Point the router at a different program. */ - setProgram(programId: ProgramId): void { - this.programId = programId; - this.sequence = PROGRAM_SEQUENCES[programId]; - this.overlays = []; - } - - /** - * Resolve which screen should be active based on session state. - * Walks the program sequence, skipping hidden entries and completed entries, - * returns the first incomplete screen. - */ - resolve(session: WizardSession): ScreenName { - // A failed agent run interrupts every program until the user leaves the - // handoff screen: exit, or continue through the post-run steps. - const runFailed = isRunFailure(session); - if (runFailed && session.mintHandoff === 'exit') return ScreenId.Exit; - if (runFailed && !session.mintHandoff) return ScreenId.MintFailure; - - if (this.overlays.length > 0) { - return this.overlays[this.overlays.length - 1]; - } - - const sequence = runFailed ? MINT_HANDOFF_SEQUENCE : this.sequence; - for (const entry of sequence) { - if (entry.show && !entry.show(session)) continue; - if (entry.isComplete && entry.isComplete(session)) continue; - // A failed login aborts the run: wizardAbort renders the error outro - // and then waits for its dismissal. But the auth step only completes - // on credentials — which an aborted login never set — so the walk - // would park here forever: auth spinner up, outro unreachable, and - // that wait deadlocked. Route to the outro so the error can be read - // and dismissed. Auth only: the run steps already complete on - // RunPhase.Error, so later aborts reach their program's own outro. - if ( - entry.id === ScreenId.Auth && - session.runPhase === RunPhase.Error && - session.outroData - ) { - return ScreenId.Outro; - } - return entry.id; - } - - // All entries complete — show the last screen (outro) - return sequence[sequence.length - 1].id; - } - - /** The screen that should be rendered right now. */ - get activeScreen(): ScreenName { - // Overlays take priority — resolve() handles this too, - // but activeScreen is called before session is available in some paths - if (this.overlays.length > 0) { - return this.overlays[this.overlays.length - 1]; - } - return this.sequence[0].id; - } - - /** The id of the active program. */ - get activeProgram(): ProgramId { - return this.programId; - } - - /** Whether an overlay is currently active. */ - get hasOverlay(): boolean { - return this.overlays.length > 0; - } - - /** - * Push an overlay that interrupts the current program. - * The program resumes when the overlay is dismissed via popOverlay(). - */ - pushOverlay(overlay: Overlay): void { - this.overlays.push(overlay); - } - - /** - * Dismiss the topmost overlay. The program screen underneath resumes. - */ - popOverlay(): void { - this.overlays.pop(); - } - - /** - * Direction hint for screen transitions. - */ - private _lastDirection: 'push' | 'pop' | null = null; - - get lastNavDirection(): 'push' | 'pop' | null { - return this._lastDirection; - } - - /** @internal — called by store wrapper to track direction */ - _setDirection(dir: 'push' | 'pop' | null): void { - this._lastDirection = dir; - } -} +export type { ProgramId }; +/** Interrupts, under the name the TUI has always used for them. */ +export { Interrupt as Overlay }; +export type ScreenName = ScreenId | Interrupt; diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 93e7dbdd3..8e0eb47b8 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -80,7 +80,7 @@ export function createServices(store: WizardStore): ScreenServices { leaveSpellbook: () => writeWizardSpellbook( store.session, - getProgramConfig(store.router.activeProgram), + getProgramConfig(store.activeProgram), ), mcpInstaller: createMcpInstaller(), mcpSuggestedPromptsServices: createMcpSuggestedPromptsServices(store), diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 590468495..858bbe857 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -1,19 +1,8 @@ /** - * Screen taxonomy + per-program screen sequences. - * - * Owns the ScreenId enum and projects each registered program's steps - * into the router-shaped screen sequence (filtering headless steps and - * appending the exit screen). Pure leaf module — no store, no React. + * Screen taxonomy: the component keys the TUI renders. Flow resolution lives + * in the store (`@lib/flow-resolution`); this module is a pure leaf. */ -import type { WizardSession } from '@lib/wizard-session'; -import { - PROGRAM_REGISTRY, - type ProgramId, -} from '@lib/programs/program-registry'; -import { createProgramSequence } from '@lib/programs/program-step'; -import { withAiOptInGate } from '@lib/programs/ai-opt-in-gate'; - /** Screens that participate in linear programs. */ export enum ScreenId { Intro = 'intro', @@ -53,32 +42,3 @@ export enum ScreenId { McpRemove = 'mcp-remove', AiOptIn = 'ai-opt-in', } - -export interface Screen { - /** ScreenId to show */ - id: ScreenId; - /** If provided, screen is skipped when this returns false. Omit = always show. */ - show?: (session: WizardSession) => boolean; - /** If provided, screen is considered complete when this returns true. */ - isComplete?: (session: WizardSession) => boolean; -} - -/** An ordered list of screens — a program's screen journey. */ -export type Sequence = Screen[]; - -/** Post-run steps a mint-failure handoff continues through; ends on exit. */ -export const MINT_HANDOFF_SEQUENCE: Sequence = [ - { id: ScreenId.Mcp, isComplete: (s) => s.mcpComplete }, - { id: ScreenId.SlackConnect, isComplete: (s) => s.slackStepDismissed }, - { id: ScreenId.KeepSkills, isComplete: (s) => s.skillsComplete }, - { id: ScreenId.Exit }, -]; - -/** All program screen sequences keyed by program id. */ -export const PROGRAM_SEQUENCES: Record = - Object.fromEntries( - PROGRAM_REGISTRY.map((c) => [ - c.id, - createProgramSequence(withAiOptInGate(c)) as Sequence, - ]), - ) as Record; diff --git a/src/ui/tui/screens/McpSuggestedPromptsScreen.tsx b/src/ui/tui/screens/McpSuggestedPromptsScreen.tsx index 05b010920..490a4e8b9 100644 --- a/src/ui/tui/screens/McpSuggestedPromptsScreen.tsx +++ b/src/ui/tui/screens/McpSuggestedPromptsScreen.tsx @@ -156,9 +156,7 @@ export const McpSuggestedPromptsScreen = ({ // all-set screen with the login commands, no surprise OAuth. The tutorial // stays reachable via `wizard mcp tutorial`. const [phase, setPhase] = useState( - store.router.activeProgram === Program.McpTutorial - ? Phase.Choose - : Phase.Goodbye, + store.activeProgram === Program.McpTutorial ? Phase.Choose : Phase.Goodbye, ); // The scout's read of the project, set in the Scouting phase. Drives the // data-aware picker, greeting flavor, and Goodbye samples. Null until the diff --git a/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx b/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx index 3da96b496..9752afa2a 100644 --- a/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx +++ b/src/ui/tui/screens/PostHogIntegrationIntroScreen.tsx @@ -34,6 +34,7 @@ import { introHeadline, introMenuOptions, } from '@ui/tui/posthog-integration-intro'; +import { flowFor } from '@lib/programs/flow-for'; /** * Replaces IntroScreenLayout's DEFAULT_SUBTITLE for this screen only. The @@ -240,7 +241,7 @@ export const PostHogIntegrationIntroScreen = ({ onSelect={(value) => { const id = Array.isArray(value) ? value[0] : value; analytics.wizardCapture('intro menu selected', { value: id, view }); - store.switchProgram(id); + store.switchProgram(flowFor(id).flow); }} /> ); diff --git a/src/ui/tui/screens/RunScreen.tsx b/src/ui/tui/screens/RunScreen.tsx index 251ae5abd..6be6f063e 100644 --- a/src/ui/tui/screens/RunScreen.tsx +++ b/src/ui/tui/screens/RunScreen.tsx @@ -24,10 +24,11 @@ import { VisualizerTab } from '@ui/tui/components/PhaseVisuals'; import { TipsCard } from '@ui/tui/components/TipsCard'; import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; -import { getProgramConfig } from '@lib/programs/program-registry'; -import { getContentBlocks as getSkillContentBlocks } from '@lib/programs/agent-skill/content/index'; +import { PROGRAM_PRESENTATION } from '@ui/tui/programs/presentation'; +import { getContentBlocks as getSkillContentBlocks } from '@ui/tui/programs/agent-skill/content/index'; import { WIZARD_LOG_FILE } from '@utils/paths'; +import { useUiStore } from '@ui/tui/hooks/useUiStore'; interface RunScreenProps { store: WizardStore; @@ -64,28 +65,27 @@ export const RunScreen = ({ store }: RunScreenProps) => { const statuses = store.statusMessages.length > 0 ? store.statusMessages : undefined; - // Each program owns its content deck (program/content/index.tsx) - // and wires it onto its ProgramConfig.getContentBlocks. Fall back to the - // agent-skill deck for runtime-created configs (e.g. `wizard skill `) - // that aren't in the static registry. - const activeProgram = store.router.activeProgram; + // Programs without a deck get the agent-skill one (e.g. `wizard skill `). + const ui = useUiStore(); + const activeProgram = store.activeProgram; const learnBlocks = useMemo(() => { const getBlocks = - getProgramConfig(activeProgram).getContentBlocks ?? getSkillContentBlocks; + PROGRAM_PRESENTATION[activeProgram]?.getContentBlocks ?? + getSkillContentBlocks; return getBlocks(store); }, [store, activeProgram]); // Program-supplied tips for the right pane; undefined falls back to // DEFAULT_TIPS inside TipsCard, so non-self-driving programs are unaffected. - const programTips = getProgramConfig(activeProgram).getTips?.(store); + const programTips = PROGRAM_PRESENTATION[activeProgram]?.getTips?.(store); - const leftPane = store.learnCardComplete ? ( + const leftPane = ui?.learnCardComplete ? ( ) : ( store.setLearnCardComplete()} + onComplete={() => ui?.setLearnCardComplete()} /> ); const progressList = ; diff --git a/src/ui/tui/screens/SelfDrivingIntroScreen.tsx b/src/ui/tui/screens/SelfDrivingIntroScreen.tsx index 94169aa01..2cedbee0b 100644 --- a/src/ui/tui/screens/SelfDrivingIntroScreen.tsx +++ b/src/ui/tui/screens/SelfDrivingIntroScreen.tsx @@ -18,7 +18,7 @@ import { NO_DEFAULT_LIMIT, PRICING_LONG, PRICING_SHORT, -} from '@lib/programs/self-driving/content/pricing.js'; +} from '@ui/tui/programs/self-driving/content/pricing.js'; import type { SelfDrivingDetectError } from '@lib/programs/self-driving/index'; interface SelfDrivingIntroScreenProps { diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index b9cbc4f87..8a08c736c 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -35,8 +35,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { const [columns] = useStdoutDimensions(); const checks = getAuditChecks(store.session); const reportFile = - getProgramConfig(store.router.activeProgram).reportFile ?? - AUDIT_REPORT_FILE; + getProgramConfig(store.activeProgram).reportFile ?? AUDIT_REPORT_FILE; const reportPath = `./${reportFile}`; const pendingChecksList = ; const slides = diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index f7b10a8ff..79435548c 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -10,12 +10,14 @@ import { render } from 'ink'; import { createElement } from 'react'; import { WizardStore, Program, type ProgramId } from './store.js'; import { InkUI } from './ink-ui.js'; +import { UiStore } from './ui-store.js'; import { setUI } from '@ui/index'; import { App } from './App.js'; import { enterDarkTerminal, releaseTerminal } from './terminal.js'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; import { getExitLine } from './exit-line.js'; +import { flowFor } from '@lib/programs/flow-for'; export { releaseTerminal }; @@ -29,14 +31,15 @@ export function startTUI( } { enterDarkTerminal(); - const store = new WizardStore(program); + const store = new WizardStore(flowFor(program).flow); store.version = version; + const ui = new UiStore(store); const inkUI = new InkUI(store); setUI(inkUI); const { unmount: inkUnmount, waitUntilExit } = render( - createElement(App, { store }), + createElement(App, { store, ui }), ); analytics.setTag('program_id', program); @@ -70,7 +73,7 @@ export function startTUI( ); inkUnmount(); releaseTerminal(); - process.stdout.write(getExitLine(store) + '\n'); + process.stdout.write(getExitLine(store, ui) + '\n'); }; process.on('exit', cleanup); diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index f43223d51..737ba0797 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -2,7 +2,7 @@ * WizardStore — Nanostore-backed reactive store for the TUI. * React components subscribe via useSyncExternalStore. * - * The active screen is derived from session state — WizardRouter walks + * The active screen is derived from session state: flow resolution walks * the flow and shows the first step whose `isComplete` is still false. * * Define a step `gate` if your screen needs to await user interactions. @@ -41,28 +41,17 @@ import { getBlockingServiceKeys, type WizardReadinessResult, } from '@lib/health-checks/readiness'; -import { - WizardRouter, - type ScreenName, - ScreenId, - Overlay, - Program, - type ProgramId, -} from './router.js'; +import { Interrupt } from '@lib/interrupts'; +import { resolveActiveScreen } from '@lib/flow-resolution'; +import type { Flow } from '@lib/flow'; +import { Program, type ProgramId } from '@lib/programs/program-registry'; import { analytics, sessionProperties } from '@utils/analytics'; -import type { - StoreInitContext, - ProgramReadyContext, -} from '@lib/programs/program-step'; -import { getProgramConfig } from '@lib/programs/program-registry'; -import { withAiOptInGate } from '@lib/programs/ai-opt-in-gate'; +import type { StoreInitContext, ProgramReadyContext } from '@lib/flow'; import { reportWarehouseSourcesDetected } from '@lib/programs/posthog-integration/detect'; -import { EXPANDED_COUNT } from '@ui/tui/constants'; -import { IS_DEV } from '@lib/constants'; import { computeTokenCostUsd } from '@lib/token-pricing'; -export { TaskStatus, ScreenId, Overlay, Program, RunPhase, McpOutcome }; -export type { ScreenName, OutroData, WizardSession, ProgramId }; +export { TaskStatus, Program, RunPhase, McpOutcome }; +export type { OutroData, WizardSession, ProgramId }; export interface TaskItem { label: string; @@ -120,12 +109,8 @@ interface GateEntry { resolved: boolean; } -/** - * FIFO cap on retained status lines. The status bar is the only consumer and - * renders at most EXPANDED_COUNT lines, so there is no reason to retain more — - * the cap is tied to the window it feeds. - */ -const MAX_STATUS_MESSAGES = EXPANDED_COUNT; +/** FIFO cap on retained status lines; the status bar's expanded window. */ +export const MAX_STATUS_MESSAGES = 10; // Capture blocked skill downloads once per readiness result. function captureHealthCheckBlocked(result: WizardReadinessResult): void { @@ -153,37 +138,31 @@ export class WizardStore { // ── Internal nanostore atoms ───────────────────────────────────── private $session = map(buildSession({})); private $statusMessages = atom([]); - private $statusExpanded = atom(false); private $tasks = atom([]); private $eventPlan = atom([]); private $handoffText = atom(null); - private $learnCardBlockIdx = atom(0); - private $learnCardComplete = atom(false); private $version = atom(0); private $currentStage = atom<{ stage: string; startedAt: number } | null>( null, ); private $tokenUsage = atom(EMPTY_TOKEN_USAGE); - // Defaults on for local/dev/test runs (tsx, `pnpm try`, vitest) so - // contributors see it without needing to know the shortcut; defaults off - // for the published build, where it stays genuinely hidden. Still - // Ctrl+T-toggleable either way. - private $tokenHudVisible = atom(IS_DEV); private _onTasksChanged: (() => void) | null = null; /** Last screen seen — used to detect screen transitions for analytics. */ - private _lastScreen: ScreenName | null = null; + private _lastScreen: string | null = null; /** Hooks run when transitioning onto a screen. */ - private _enterScreenHooks = new Map void)[]>(); + private _enterScreenHooks = new Map void)[]>(); /** Gate promises derived from program step definitions. */ private _gates = new Map(); version = ''; - /** Navigation router — resolves active screen from session state. */ - readonly router: WizardRouter; + /** The flow this store walks. Set by whoever composes the run. */ + private _flow: Flow; + /** Interrupts take over the active screen until dismissed, last on top. */ + private _interrupts: Interrupt[] = []; /** Blocks agent execution until the settings-override overlay is dismissed. */ private _resolveSettingsOverride: (() => void) | null = null; @@ -201,25 +180,14 @@ export class WizardStore { private _resolvePendingQuestion: ((answers: AskAnswers) => void) | null = null; - constructor(program: ProgramId = Program.PostHogIntegration) { - this.router = new WizardRouter(program); - this._initFromProgram(program); + constructor(flow: Flow) { + this._flow = flow; + this._initGates(flow); } - /** - * Scan program steps for gate predicates and create gate promises. - * - * Steps are wrapped with withAiOptInGate so the injected ai-opt-in - * step's gate registers here — the agent runner awaits it (via - * WizardUI.waitForAiOptIn) before any source leaves the machine. - * Same wrapper screen-sequences.ts uses, so the gate and its screen - * can't drift apart. - */ - private _initFromProgram(program: ProgramId): void { - const steps = withAiOptInGate(getProgramConfig(program)); - - // Create gate promises from steps that define them - for (const step of steps) { + /** Create one gate promise per step that declares a `gate` predicate. */ + private _initGates(flow: Flow): void { + for (const step of flow.steps) { if (step.gate) { let resolve!: () => void; const promise = new Promise((r) => { @@ -242,7 +210,7 @@ export class WizardStore { * pre-flight, whose probes belong only to flows that show its screen. */ runInitHooks(): void { - const steps = getProgramConfig(this.router.activeProgram).steps; + const steps = this._flow.steps; const getSession = (): WizardSession => this.session; const ctx: StoreInitContext = { get session() { @@ -264,7 +232,7 @@ export class WizardStore { * need to know which program has which pre-flow work. */ async runReadyHooks(): Promise { - const steps = getProgramConfig(this.router.activeProgram).steps; + const steps = this._flow.steps; const ctx: ProgramReadyContext = { session: this.session, setFrameworkContext: (k, v) => this.setFrameworkContext(k, v), @@ -378,22 +346,6 @@ export class WizardStore { this.emitChange(); } - get statusExpanded(): boolean { - return this.$statusExpanded.get(); - } - - toggleStatusExpanded(): void { - this.$statusExpanded.set(!this.$statusExpanded.get()); - this.emitChange(); - } - - setStatusExpanded(expanded: boolean): void { - if (this.$statusExpanded.get() !== expanded) { - this.$statusExpanded.set(expanded); - this.emitChange(); - } - } - // ── Session setters ───────────────────────────────────────────── // Every setter that affects screen resolution calls emitChange(). // Business logic calls these instead of mutating session directly. @@ -573,9 +525,9 @@ export class WizardStore { const hasReadOnly = conflicts.some((c) => !c.writable); if (hasReadOnly) { - this.pushOverlay(Overlay.ManagedSettings); + this.pushInterrupt(Interrupt.ManagedSettings); } else { - this.pushOverlay(Overlay.SettingsOverride); + this.pushInterrupt(Interrupt.SettingsOverride); } return new Promise((resolve) => { @@ -594,7 +546,7 @@ export class WizardStore { user: string; }): Promise { this.$session.setKey('portConflictProcess', processInfo); - this.pushOverlay(Overlay.PortConflict); + this.pushInterrupt(Interrupt.PortConflict); return new Promise((resolve) => { this._resolvePortConflict = resolve; }); @@ -603,7 +555,7 @@ export class WizardStore { /** Dismiss the port-conflict overlay and retry the OAuth port loop. */ resolvePortConflict(): void { this.$session.setKey('portConflictProcess', null); - this.popOverlay(); + this.popInterrupt(); this._resolvePortConflict?.(); this._resolvePortConflict = null; } @@ -614,7 +566,7 @@ export class WizardStore { */ showTaskNotice(notice: TaskNotice): Promise { this.$session.setKey('taskNotice', notice); - this.pushOverlay(Overlay.TaskNotice); + this.pushInterrupt(Interrupt.TaskNotice); return new Promise((resolve) => { this._resolveTaskNotice = resolve; }); @@ -623,7 +575,7 @@ export class WizardStore { /** Dismiss the notice, keeping (`true`) or skipping (`false`) the step. */ resolveTaskNotice(keep: boolean): void { this.$session.setKey('taskNotice', null); - this.popOverlay(); + this.popInterrupt(); this._resolveTaskNotice?.(keep); this._resolveTaskNotice = null; } @@ -641,12 +593,12 @@ export class WizardStore { /** Open the manual OAuth code-entry overlay over the auth screen. */ showManualAuthCode(): void { - this.pushOverlay(Overlay.ManualAuthCode); + this.pushInterrupt(Interrupt.ManualAuthCode); } /** Dismiss the manual OAuth code overlay without submitting. */ dismissManualAuthCode(): void { - this.popOverlay(); + this.popInterrupt(); } /** @@ -654,7 +606,7 @@ export class WizardStore { * resolve the in-flight OAuth flow so it can exchange the code for a token. */ submitManualAuthCode(code: string): void { - this.popOverlay(); + this.popInterrupt(); this._resolveManualAuthCode?.(code); this._resolveManualAuthCode = null; } @@ -673,7 +625,7 @@ export class WizardStore { ); } this.$session.setKey('pendingQuestion', question); - this.pushOverlay(Overlay.WizardAsk); + this.pushInterrupt(Interrupt.WizardAsk); analytics.wizardCapture('wizard_ask shown', { source: question.source, question_count: question.questions.length, @@ -692,7 +644,7 @@ export class WizardStore { const resolve = this._resolvePendingQuestion; this._resolvePendingQuestion = null; this.$session.setKey('pendingQuestion', null); - this.popOverlay(); + this.popInterrupt(); resolve?.(answers); } @@ -718,7 +670,7 @@ export class WizardStore { if (ok) { this.$session.setKey('settingsOverrideKeys', null); this.$session.setKey('settingsConflicts', null); - this.popOverlay(); + this.popInterrupt(); this._resolveSettingsOverride?.(); this._resolveSettingsOverride = null; this._backupAndFixSettings = null; @@ -729,12 +681,12 @@ export class WizardStore { /** Push the auth-error overlay (no dismiss — user must exit). */ showAuthError(detail?: AuthErrorDetail): void { this.$session.setKey('authErrorDetail', detail ?? null); - this.pushOverlay(Overlay.AuthError); + this.pushInterrupt(Interrupt.AuthError); } /** Push the session-timeout overlay (no dismiss — user must exit). */ showSessionTimeout(): void { - this.pushOverlay(Overlay.SessionTimeout); + this.pushInterrupt(Interrupt.SessionTimeout); } addDiscoveredFeature(feature: DiscoveredFeature): void { @@ -922,39 +874,48 @@ export class WizardStore { this.emitChange(); } - switchProgram(program: ProgramId): void { - if (program === this.router.activeProgram) return; + switchProgram(flow: Flow): void { + if (flow.programId === this._flow.programId) return; // Flush unresolved promises so the wizard can advance for (const gate of this._gates.values()) gate.resolve(); this._gates.clear(); - this.router.setProgram(program); - this._initFromProgram(program); + this._interrupts = []; + this._flow = flow; + this._initGates(flow); // start-tui stamps this once at launch; without it here every event // after the switch still reports under the program the run started as. - analytics.setTag('program_id', program); + analytics.setTag('program_id', flow.programId); - const config = getProgramConfig(program); this.$session.setKey('setupConfirmed', false); - this.$session.setKey('programLabel', config.id); - this.$session.setKey('skillId', config.skillId ?? null); + this.$session.setKey('programLabel', flow.programId); + this.$session.setKey('skillId', flow.skillId); this.emitChange(); } // ── Derived state ─────────────────────────────────────────────── - /** - * The screen that should be rendered right now. - * Derived from session state via the router. - */ - get currentScreen(): ScreenName { - return this.router.resolve(this.session); + get flow(): Flow { + return this._flow; + } + + /** The id of the active program. */ + get activeProgram(): ProgramId { + return this._flow.programId; + } + + /** The screen key that should be rendered right now, derived from state. */ + get currentScreen(): string { + return resolveActiveScreen(this._flow, this.session, this._interrupts); + } + + get hasInterrupt(): boolean { + return this._interrupts.length > 0; } - /** Direction hint for screen transitions. */ - get lastNavDirection(): 'push' | 'pop' | null { - return this.router.lastNavDirection; + get interruptDepth(): number { + return this._interrupts.length; } // ── Change notification ───────────────────────────────────────── @@ -965,28 +926,25 @@ export class WizardStore { /** * Notify React that state has changed. - * The router re-resolves the active screen on next render. + * The active screen re-resolves on next render. * Gate predicates are checked and resolved if ready. */ emitChange(): void { - this.router._setDirection('push'); this.$version.set(this.$version.get() + 1); this._checkGates(); this._detectTransition(); } - // ── Overlay navigation ────────────────────────────────────────── + // ── Interrupts ────────────────────────────────────────────────── - pushOverlay(overlay: Overlay): void { - this.router._setDirection('push'); - this.router.pushOverlay(overlay); + pushInterrupt(interrupt: Interrupt): void { + this._interrupts.push(interrupt); this.$version.set(this.$version.get() + 1); this._detectTransition(); } - popOverlay(): void { - this.router._setDirection('pop'); - this.router.popOverlay(); + popInterrupt(): void { + this._interrupts.pop(); this.$version.set(this.$version.get() + 1); this._detectTransition(); } @@ -997,7 +955,7 @@ export class WizardStore { * Register a callback to run when transitioning onto the given screen. * Fires after every transition that lands on this screen. */ - onEnterScreen(screen: ScreenName, fn: () => void): void { + onEnterScreen(screen: string, fn: () => void): void { const list = this._enterScreenHooks.get(screen) ?? []; list.push(fn); this._enterScreenHooks.set(screen, list); @@ -1008,26 +966,24 @@ export class WizardStore { * claims one, else the running program (also the fallback for overlays and * screens with no owning step). */ - private _programIdForScreen(screen: ScreenName): ProgramId { - const program = this.router.activeProgram; - const step = getProgramConfig(program).steps.find( - (s) => s.screenId === screen, - ); + private _programIdForScreen(screen: string): ProgramId { + const program = this._flow.programId; + const step = this._flow.steps.find((s) => s.screenId === screen); return step?.reportsAsProgramId ?? program; } /** The program the visible screen reports under; screens stamp this on their * own events rather than relying on the run-level `program_id` tag. */ get analyticsProgramId(): ProgramId { - return this._programIdForScreen(this.router.resolve(this.session)); + return this._programIdForScreen(this.currentScreen); } /** * Detect screen transitions, run enter-screen hooks, and fire analytics. - * Called at the end of emitChange/pushOverlay/popOverlay. + * Called at the end of emitChange/pushInterrupt/popInterrupt. */ private _detectTransition(): void { - const next = this.router.resolve(this.session); + const next = this.currentScreen; const prev = this._lastScreen; if (next !== prev) { // Every event carries the active TUI screen, filling the @@ -1069,17 +1025,6 @@ export class WizardStore { return this.$tokenUsage.get(); } - get tokenHudVisible(): boolean { - return this.$tokenHudVisible.get(); - } - - /** Hidden Ctrl+T shortcut — see ScreenContainer. Not registered as a - * keyboard hint, so it never shows in the hints bar. */ - toggleTokenHud(): void { - this.$tokenHudVisible.set(!this.$tokenHudVisible.get()); - this.emitChange(); - } - /** * Accumulate one assistant turn's token usage into the running estimate. * Approximate by design (no dedup for SDK-retried/replayed turns, unlike @@ -1143,23 +1088,6 @@ export class WizardStore { this.emitChange(); } - get learnCardBlockIdx(): number { - return this.$learnCardBlockIdx.get(); - } - - setLearnCardBlockIdx(idx: number): void { - this.$learnCardBlockIdx.set(idx); - } - - get learnCardComplete(): boolean { - return this.$learnCardComplete.get(); - } - - setLearnCardComplete(): void { - this.$learnCardComplete.set(true); - this.emitChange(); - } - syncTodos( todos: Array<{ content: string; status: string; activeForm?: string }>, ): void { diff --git a/src/ui/tui/ui-store.ts b/src/ui/tui/ui-store.ts new file mode 100644 index 000000000..64731ba65 --- /dev/null +++ b/src/ui/tui/ui-store.ts @@ -0,0 +1,99 @@ +/** + * UiStore — presentation state the TUI keeps beside the WizardStore: status + * bar expansion, learn card progress, the token HUD toggle, and the direction + * hint for screen transitions. It watches the store and re-emits every store + * commit, so React subscribes here once for both. + */ + +import { atom } from 'nanostores'; +import { IS_DEV } from '@lib/constants'; +import type { WizardStore } from './store.js'; + +export class UiStore { + private $version = atom(0); + private $statusExpanded = atom(false); + private $learnCardBlockIdx = atom(0); + private $learnCardComplete = atom(false); + // Defaults on for local/dev/test runs so contributors see the HUD without + // knowing the shortcut; off for the published build. Ctrl+T toggles either way. + private $tokenHudVisible = atom(IS_DEV); + private _lastDirection: 'push' | 'pop' | null = null; + private _lastDepth: number; + + constructor(readonly store: WizardStore) { + this._lastDepth = store.interruptDepth; + store.subscribe(() => this._onStoreChange()); + } + + /** A dismissed interrupt animates back; every other commit animates forward. */ + private _onStoreChange(): void { + const depth = this.store.interruptDepth; + this._lastDirection = depth < this._lastDepth ? 'pop' : 'push'; + this._lastDepth = depth; + this._bump(); + } + + private _bump(): void { + this.$version.set(this.$version.get() + 1); + } + + get activeScreen(): string { + return this.store.currentScreen; + } + + /** Direction hint for screen transitions. */ + get lastNavDirection(): 'push' | 'pop' | null { + return this._lastDirection; + } + + get statusExpanded(): boolean { + return this.$statusExpanded.get(); + } + + toggleStatusExpanded(): void { + this.$statusExpanded.set(!this.$statusExpanded.get()); + this._bump(); + } + + setStatusExpanded(expanded: boolean): void { + if (this.$statusExpanded.get() !== expanded) { + this.$statusExpanded.set(expanded); + this._bump(); + } + } + + get tokenHudVisible(): boolean { + return this.$tokenHudVisible.get(); + } + + /** Hidden Ctrl+T shortcut — see ScreenContainer. Never a keyboard hint. */ + toggleTokenHud(): void { + this.$tokenHudVisible.set(!this.$tokenHudVisible.get()); + this._bump(); + } + + get learnCardBlockIdx(): number { + return this.$learnCardBlockIdx.get(); + } + + setLearnCardBlockIdx(idx: number): void { + this.$learnCardBlockIdx.set(idx); + } + + get learnCardComplete(): boolean { + return this.$learnCardComplete.get(); + } + + setLearnCardComplete(): void { + this.$learnCardComplete.set(true); + this._bump(); + } + + subscribe(callback: () => void): () => void { + return this.$version.listen(() => callback()); + } + + getSnapshot(): number { + return this.$version.get(); + } +}