Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/lib/workflows/__tests__/workflow-step.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/workflows/agent-skill/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,6 @@ export const AGENT_SKILL_STEPS: Workflow = [
{
id: 'skills',
label: 'Skills',
screen: 'skills',
screen: 'keep-skills',
},
];
2 changes: 1 addition & 1 deletion src/lib/workflows/revenue-analytics/steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@ export const REVENUE_ANALYTICS_WORKFLOW: Workflow = [
{
id: 'skills',
label: 'Skills',
screen: 'skills',
screen: 'keep-skills',
},
];
7 changes: 6 additions & 1 deletion src/lib/workflows/workflow-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!,
Expand All @@ -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;
}
4 changes: 2 additions & 2 deletions src/ui/tui/__tests__/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -1023,7 +1023,7 @@ describe('WizardStore', () => {

// Step 4: Dismiss outro
store.setOutroDismissed();
expect(store.currentScreen).toBe('skills');
expect(store.currentScreen).toBe('keep-skills');
});
});

Expand Down
3 changes: 3 additions & 0 deletions src/ui/tui/flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export enum Screen {
Mcp = 'mcp',
KeepSkills = 'keep-skills',
Outro = 'outro',
Exit = 'exit',
McpAdd = 'mcp-add',
McpRemove = 'mcp-remove',
}
Expand Down Expand Up @@ -90,6 +91,7 @@ export const FLOWS: Record<Flow, FlowEntry[]> = {
isComplete: (s) => s.mcpComplete,
},
{ screen: Screen.Outro },
{ screen: Screen.Exit },
],

[Flow.McpRemove]: [
Expand All @@ -98,5 +100,6 @@ export const FLOWS: Record<Flow, FlowEntry[]> = {
isComplete: (s) => s.mcpComplete,
},
{ screen: Screen.Outro },
{ screen: Screen.Exit },
],
};
2 changes: 2 additions & 0 deletions src/ui/tui/screen-registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,6 +63,7 @@ export function createScreens(
[Screen.Mcp]: <McpScreen store={store} installer={services.mcpInstaller} />,
[Screen.KeepSkills]: <KeepSkillsScreen store={store} />,
[Screen.Outro]: <OutroScreen store={store} />,
[Screen.Exit]: <ExitScreen />,

// Standalone MCP flows
[Screen.McpAdd]: (
Expand Down
16 changes: 16 additions & 0 deletions src/ui/tui/screens/ExitScreen.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
45 changes: 35 additions & 10 deletions src/ui/tui/start-tui.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -26,8 +46,10 @@ export function startTUI(
store: WizardStore;
waitForSetup: () => Promise<void>;
} {
// 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;
Expand All @@ -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'),
};
Expand Down
Loading