diff --git a/src/lib/workflows/__tests__/workflow-step.test.ts b/src/lib/workflows/__tests__/workflow-step.test.ts index 7c51f64b..5464cae8 100644 --- a/src/lib/workflows/__tests__/workflow-step.test.ts +++ b/src/lib/workflows/__tests__/workflow-step.test.ts @@ -11,7 +11,12 @@ describe('workflowToFlowEntries', () => { ]; const entries = workflowToFlowEntries(workflow); - expect(entries.map((e) => e.screen)).toEqual(['intro', 'run', 'outro']); + expect(entries.map((e) => e.screen)).toEqual([ + 'intro', + 'run', + 'outro', + 'exit', + ]); }); it('falls back isComplete to gate, preferring explicit isComplete', () => { diff --git a/src/lib/workflows/agent-skill/steps.ts b/src/lib/workflows/agent-skill/steps.ts index 128e5457..9aa26da3 100644 --- a/src/lib/workflows/agent-skill/steps.ts +++ b/src/lib/workflows/agent-skill/steps.ts @@ -38,6 +38,6 @@ export const AGENT_SKILL_STEPS: Workflow = [ { id: 'skills', label: 'Skills', - screen: 'skills', + screen: 'keep-skills', }, ]; diff --git a/src/lib/workflows/revenue-analytics/steps.ts b/src/lib/workflows/revenue-analytics/steps.ts index e0e2cb0b..97cb2c89 100644 --- a/src/lib/workflows/revenue-analytics/steps.ts +++ b/src/lib/workflows/revenue-analytics/steps.ts @@ -49,6 +49,6 @@ export const REVENUE_ANALYTICS_WORKFLOW: Workflow = [ { id: 'skills', label: 'Skills', - screen: 'skills', + screen: 'keep-skills', }, ]; diff --git a/src/lib/workflows/workflow-step.ts b/src/lib/workflows/workflow-step.ts index f31ebdda..cc7bd0f6 100644 --- a/src/lib/workflows/workflow-step.ts +++ b/src/lib/workflows/workflow-step.ts @@ -142,7 +142,7 @@ export function workflowToFlowEntries(workflow: Workflow): Array<{ show?: (session: WizardSession) => boolean; isComplete?: (session: WizardSession) => boolean; }> { - return workflow + const entries = workflow .filter((step) => step.screen != null) .map((step) => ({ screen: step.screen!, @@ -152,4 +152,9 @@ export function workflowToFlowEntries(workflow: Workflow): Array<{ // the screen). Only override when the two conditions diverge. isComplete: step.isComplete ?? step.gate, })); + + // Every workflow ends with the exit screen. + entries.push({ screen: 'exit', show: undefined, isComplete: undefined }); + + return entries; } diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index 55580b32..9e262131 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -993,7 +993,7 @@ describe('WizardStore', () => { // Step 4: Dismiss outro store.setOutroDismissed(); - expect(store.currentScreen).toBe('skills'); + expect(store.currentScreen).toBe('keep-skills'); }); it('walks through the agent skill flow correctly', () => { @@ -1023,7 +1023,7 @@ describe('WizardStore', () => { // Step 4: Dismiss outro store.setOutroDismissed(); - expect(store.currentScreen).toBe('skills'); + expect(store.currentScreen).toBe('keep-skills'); }); }); diff --git a/src/ui/tui/flows.ts b/src/ui/tui/flows.ts index a7a3597e..9c89947c 100644 --- a/src/ui/tui/flows.ts +++ b/src/ui/tui/flows.ts @@ -31,6 +31,7 @@ export enum Screen { Mcp = 'mcp', KeepSkills = 'keep-skills', Outro = 'outro', + Exit = 'exit', McpAdd = 'mcp-add', McpRemove = 'mcp-remove', } @@ -90,6 +91,7 @@ export const FLOWS: Record = { isComplete: (s) => s.mcpComplete, }, { screen: Screen.Outro }, + { screen: Screen.Exit }, ], [Flow.McpRemove]: [ @@ -98,5 +100,6 @@ export const FLOWS: Record = { isComplete: (s) => s.mcpComplete, }, { screen: Screen.Outro }, + { screen: Screen.Exit }, ], }; diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 356597d3..67712f3d 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -26,6 +26,7 @@ import { RunScreen } from './screens/RunScreen.js'; import { McpScreen } from './screens/McpScreen.js'; import { KeepSkillsScreen } from './screens/KeepSkillsScreen.js'; import { OutroScreen } from './screens/OutroScreen.js'; +import { ExitScreen } from './screens/ExitScreen.js'; import { AuthErrorScreen } from './screens/AuthErrorScreen.js'; import { createMcpInstaller } from './services/mcp-installer.js'; import type { McpInstaller } from './services/mcp-installer.js'; @@ -62,6 +63,7 @@ export function createScreens( [Screen.Mcp]: , [Screen.KeepSkills]: , [Screen.Outro]: , + [Screen.Exit]: , // Standalone MCP flows [Screen.McpAdd]: ( diff --git a/src/ui/tui/screens/ExitScreen.tsx b/src/ui/tui/screens/ExitScreen.tsx new file mode 100644 index 00000000..dfa4ecd7 --- /dev/null +++ b/src/ui/tui/screens/ExitScreen.tsx @@ -0,0 +1,16 @@ +/** + * ExitScreen — Final step in every workflow. + * + * Renders nothing. Immediately exits the process. + * The cleanup handler in start-tui.ts handles the exit summary line. + */ + +import { useEffect } from 'react'; + +export const ExitScreen = () => { + useEffect(() => { + process.exit(0); + }, []); + + return null; +}; diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index 119b44b3..1cfdede0 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -1,5 +1,9 @@ /** * start-tui.ts — Sets up the Ink TUI renderer and InkUI. + * + * Renders in the terminal's alternate screen buffer so the wizard + * doesn't pollute scrollback history. On exit, the previous terminal + * content is restored and a single exit summary line is printed. */ import { render } from 'ink'; @@ -8,15 +12,31 @@ import { WizardStore, Flow } from './store.js'; import { InkUI } from './ink-ui.js'; import { setUI } from '../index.js'; import { App } from './App.js'; +import { OutroKind } from '../../lib/wizard-session.js'; // ANSI escape sequences const RESET_ATTRS = '\x1b[0m'; const CLEAR_SCREEN = '\x1b[2J'; const CURSOR_HOME = '\x1b[H'; const BG_BLACK = '\x1b[48;2;0;0;0m'; +const ENTER_ALT_SCREEN = '\x1b[?1049h'; +const LEAVE_ALT_SCREEN = '\x1b[?1049l'; +const GREEN = '\x1b[32m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; -/** Set background to true black, clear screen, cursor to top-left. */ -const FORCE_DARK = BG_BLACK + CLEAR_SCREEN + CURSOR_HOME; +function getExitLine(store: WizardStore): string { + const outro = store.session.outroData; + const label = store.session.workflowLabel ?? 'Wizard'; + + if (outro?.kind === OutroKind.Success) { + return `${GREEN}${BOLD}\u2714${RESET_ATTRS} ${ + outro.message ?? `${label} completed successfully.` + }`; + } + + return `${DIM}${label} exited.${RESET_ATTRS}`; +} export function startTUI( version: string, @@ -26,8 +46,10 @@ export function startTUI( store: WizardStore; waitForSetup: () => Promise; } { - // Force dark background regardless of terminal theme - process.stdout.write(FORCE_DARK); + // Enter alternate screen buffer, then set up dark background + process.stdout.write( + ENTER_ALT_SCREEN + BG_BLACK + CLEAR_SCREEN + CURSOR_HOME, + ); const store = new WizardStore(flow); store.version = version; @@ -39,17 +61,20 @@ export function startTUI( // Render the Ink app const { unmount: inkUnmount } = render(createElement(App, { store })); - // Reset terminal on exit + // On exit: unmount Ink, leave alt screen (restores previous content), + // then print exit summary line into the main buffer. + let cleaned = false; const cleanup = () => { - process.stdout.write(RESET_ATTRS + CLEAR_SCREEN + CURSOR_HOME); + if (cleaned) return; + cleaned = true; + inkUnmount(); + process.stdout.write(RESET_ATTRS + LEAVE_ALT_SCREEN); + process.stdout.write(getExitLine(store) + '\n'); }; process.on('exit', cleanup); return { - unmount: () => { - inkUnmount(); - cleanup(); - }, + unmount: cleanup, store, waitForSetup: () => store.getGate('intro'), };