Skip to content

Commit cd3871e

Browse files
authored
feat: use alternate buffer (#407)
1 parent 84bddae commit cd3871e

9 files changed

Lines changed: 72 additions & 16 deletions

File tree

src/lib/workflows/__tests__/workflow-step.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ describe('workflowToFlowEntries', () => {
1111
];
1212

1313
const entries = workflowToFlowEntries(workflow);
14-
expect(entries.map((e) => e.screen)).toEqual(['intro', 'run', 'outro']);
14+
expect(entries.map((e) => e.screen)).toEqual([
15+
'intro',
16+
'run',
17+
'outro',
18+
'exit',
19+
]);
1520
});
1621

1722
it('falls back isComplete to gate, preferring explicit isComplete', () => {

src/lib/workflows/agent-skill/steps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,6 @@ export const AGENT_SKILL_STEPS: Workflow = [
3838
{
3939
id: 'skills',
4040
label: 'Skills',
41-
screen: 'skills',
41+
screen: 'keep-skills',
4242
},
4343
];

src/lib/workflows/revenue-analytics/steps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,6 @@ export const REVENUE_ANALYTICS_WORKFLOW: Workflow = [
4949
{
5050
id: 'skills',
5151
label: 'Skills',
52-
screen: 'skills',
52+
screen: 'keep-skills',
5353
},
5454
];

src/lib/workflows/workflow-step.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function workflowToFlowEntries(workflow: Workflow): Array<{
142142
show?: (session: WizardSession) => boolean;
143143
isComplete?: (session: WizardSession) => boolean;
144144
}> {
145-
return workflow
145+
const entries = workflow
146146
.filter((step) => step.screen != null)
147147
.map((step) => ({
148148
screen: step.screen!,
@@ -152,4 +152,9 @@ export function workflowToFlowEntries(workflow: Workflow): Array<{
152152
// the screen). Only override when the two conditions diverge.
153153
isComplete: step.isComplete ?? step.gate,
154154
}));
155+
156+
// Every workflow ends with the exit screen.
157+
entries.push({ screen: 'exit', show: undefined, isComplete: undefined });
158+
159+
return entries;
155160
}

src/ui/tui/__tests__/store.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -993,7 +993,7 @@ describe('WizardStore', () => {
993993

994994
// Step 4: Dismiss outro
995995
store.setOutroDismissed();
996-
expect(store.currentScreen).toBe('skills');
996+
expect(store.currentScreen).toBe('keep-skills');
997997
});
998998

999999
it('walks through the agent skill flow correctly', () => {
@@ -1023,7 +1023,7 @@ describe('WizardStore', () => {
10231023

10241024
// Step 4: Dismiss outro
10251025
store.setOutroDismissed();
1026-
expect(store.currentScreen).toBe('skills');
1026+
expect(store.currentScreen).toBe('keep-skills');
10271027
});
10281028
});
10291029

src/ui/tui/flows.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export enum Screen {
3131
Mcp = 'mcp',
3232
KeepSkills = 'keep-skills',
3333
Outro = 'outro',
34+
Exit = 'exit',
3435
McpAdd = 'mcp-add',
3536
McpRemove = 'mcp-remove',
3637
}
@@ -90,6 +91,7 @@ export const FLOWS: Record<Flow, FlowEntry[]> = {
9091
isComplete: (s) => s.mcpComplete,
9192
},
9293
{ screen: Screen.Outro },
94+
{ screen: Screen.Exit },
9395
],
9496

9597
[Flow.McpRemove]: [
@@ -98,5 +100,6 @@ export const FLOWS: Record<Flow, FlowEntry[]> = {
98100
isComplete: (s) => s.mcpComplete,
99101
},
100102
{ screen: Screen.Outro },
103+
{ screen: Screen.Exit },
101104
],
102105
};

src/ui/tui/screen-registry.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { RunScreen } from './screens/RunScreen.js';
2626
import { McpScreen } from './screens/McpScreen.js';
2727
import { KeepSkillsScreen } from './screens/KeepSkillsScreen.js';
2828
import { OutroScreen } from './screens/OutroScreen.js';
29+
import { ExitScreen } from './screens/ExitScreen.js';
2930
import { AuthErrorScreen } from './screens/AuthErrorScreen.js';
3031
import { createMcpInstaller } from './services/mcp-installer.js';
3132
import type { McpInstaller } from './services/mcp-installer.js';
@@ -62,6 +63,7 @@ export function createScreens(
6263
[Screen.Mcp]: <McpScreen store={store} installer={services.mcpInstaller} />,
6364
[Screen.KeepSkills]: <KeepSkillsScreen store={store} />,
6465
[Screen.Outro]: <OutroScreen store={store} />,
66+
[Screen.Exit]: <ExitScreen />,
6567

6668
// Standalone MCP flows
6769
[Screen.McpAdd]: (

src/ui/tui/screens/ExitScreen.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* ExitScreen — Final step in every workflow.
3+
*
4+
* Renders nothing. Immediately exits the process.
5+
* The cleanup handler in start-tui.ts handles the exit summary line.
6+
*/
7+
8+
import { useEffect } from 'react';
9+
10+
export const ExitScreen = () => {
11+
useEffect(() => {
12+
process.exit(0);
13+
}, []);
14+
15+
return null;
16+
};

src/ui/tui/start-tui.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
/**
22
* start-tui.ts — Sets up the Ink TUI renderer and InkUI.
3+
*
4+
* Renders in the terminal's alternate screen buffer so the wizard
5+
* doesn't pollute scrollback history. On exit, the previous terminal
6+
* content is restored and a single exit summary line is printed.
37
*/
48

59
import { render } from 'ink';
@@ -8,15 +12,31 @@ import { WizardStore, Flow } from './store.js';
812
import { InkUI } from './ink-ui.js';
913
import { setUI } from '../index.js';
1014
import { App } from './App.js';
15+
import { OutroKind } from '../../lib/wizard-session.js';
1116

1217
// ANSI escape sequences
1318
const RESET_ATTRS = '\x1b[0m';
1419
const CLEAR_SCREEN = '\x1b[2J';
1520
const CURSOR_HOME = '\x1b[H';
1621
const BG_BLACK = '\x1b[48;2;0;0;0m';
22+
const ENTER_ALT_SCREEN = '\x1b[?1049h';
23+
const LEAVE_ALT_SCREEN = '\x1b[?1049l';
24+
const GREEN = '\x1b[32m';
25+
const BOLD = '\x1b[1m';
26+
const DIM = '\x1b[2m';
1727

18-
/** Set background to true black, clear screen, cursor to top-left. */
19-
const FORCE_DARK = BG_BLACK + CLEAR_SCREEN + CURSOR_HOME;
28+
function getExitLine(store: WizardStore): string {
29+
const outro = store.session.outroData;
30+
const label = store.session.workflowLabel ?? 'Wizard';
31+
32+
if (outro?.kind === OutroKind.Success) {
33+
return `${GREEN}${BOLD}\u2714${RESET_ATTRS} ${
34+
outro.message ?? `${label} completed successfully.`
35+
}`;
36+
}
37+
38+
return `${DIM}${label} exited.${RESET_ATTRS}`;
39+
}
2040

2141
export function startTUI(
2242
version: string,
@@ -26,8 +46,10 @@ export function startTUI(
2646
store: WizardStore;
2747
waitForSetup: () => Promise<void>;
2848
} {
29-
// Force dark background regardless of terminal theme
30-
process.stdout.write(FORCE_DARK);
49+
// Enter alternate screen buffer, then set up dark background
50+
process.stdout.write(
51+
ENTER_ALT_SCREEN + BG_BLACK + CLEAR_SCREEN + CURSOR_HOME,
52+
);
3153

3254
const store = new WizardStore(flow);
3355
store.version = version;
@@ -39,17 +61,20 @@ export function startTUI(
3961
// Render the Ink app
4062
const { unmount: inkUnmount } = render(createElement(App, { store }));
4163

42-
// Reset terminal on exit
64+
// On exit: unmount Ink, leave alt screen (restores previous content),
65+
// then print exit summary line into the main buffer.
66+
let cleaned = false;
4367
const cleanup = () => {
44-
process.stdout.write(RESET_ATTRS + CLEAR_SCREEN + CURSOR_HOME);
68+
if (cleaned) return;
69+
cleaned = true;
70+
inkUnmount();
71+
process.stdout.write(RESET_ATTRS + LEAVE_ALT_SCREEN);
72+
process.stdout.write(getExitLine(store) + '\n');
4573
};
4674
process.on('exit', cleanup);
4775

4876
return {
49-
unmount: () => {
50-
inkUnmount();
51-
cleanup();
52-
},
77+
unmount: cleanup,
5378
store,
5479
waitForSetup: () => store.getGate('intro'),
5580
};

0 commit comments

Comments
 (0)