diff --git a/src/lib/programs/posthog-integration/__tests__/handoff.test.ts b/src/lib/programs/posthog-integration/__tests__/handoff.test.ts new file mode 100644 index 00000000..a6a0b865 --- /dev/null +++ b/src/lib/programs/posthog-integration/__tests__/handoff.test.ts @@ -0,0 +1,38 @@ +import { buildCodingAgentPrompt } from '../handoff'; + +describe('buildCodingAgentPrompt', () => { + it('references the given report file', () => { + const prompt = buildCodingAgentPrompt('posthog-setup-report.md'); + expect(prompt).toContain('`posthog-setup-report.md`'); + }); + + it('points the agent at the report checklist, on a single line', () => { + const prompt = buildCodingAgentPrompt('posthog-setup-report.md'); + expect(prompt).toContain('Verify before merging'); + // Single line keeps triple-click selection clean in the terminal. + expect(prompt).not.toContain('\n'); + }); + + it('asks the agent to investigate and get consent before changing anything, without prescribing a workflow', () => { + // Explicit consent for actions with real implications (e.g. source-map + // upload) — but no PR mandate / edit-style rules; the operator governs how + // changes land in their own agent. + const prompt = buildCodingAgentPrompt( + 'posthog-setup-report.md', + ).toLowerCase(); + expect(prompt).toContain('investigate'); // explore first + expect(prompt).toContain('approval'); // explicit consent gate + expect(prompt).not.toMatch(/open a pr|minimal/); // no prescribed workflow + }); + + it('does not reference a separate next-steps file (content lives in the report)', () => { + const prompt = buildCodingAgentPrompt('posthog-setup-report.md'); + expect(prompt).not.toContain('posthog-next-steps.md'); + }); + + it('threads the report file name through rather than hardcoding it', () => { + const prompt = buildCodingAgentPrompt('custom-report.md'); + expect(prompt).toContain('`custom-report.md`'); + expect(prompt).not.toContain('posthog-setup-report.md'); + }); +}); diff --git a/src/lib/programs/posthog-integration/handoff.ts b/src/lib/programs/posthog-integration/handoff.ts new file mode 100644 index 00000000..785d84bf --- /dev/null +++ b/src/lib/programs/posthog-integration/handoff.ts @@ -0,0 +1,28 @@ +/** + * The coding-agent handoff prompt. + * + * The wizard finishes a successful run by emitting `posthog-setup-report.md` — + * which now ends with a "Verify before merging" checklist the agent fills in + * from the context-mill skill (see the basic-integration conclude step). This + * is the one-line prompt the operator pastes into their own coding agent to + * close the gap between "wizard finished" and "PostHog merged": read the + * report, work the checklist, open a PR. + * + * Kept as a pure helper so the exact wording is easy to review and test in + * isolation. It is surfaced through `OutroData.handoffPrompt`, which the wizard + * prints to the terminal's main buffer on exit (where it survives in scrollback + * and stays triple-click-selectable); it is NOT written into any file — the + * verification content itself lives in the report. + * + * The wording asks the agent to investigate each checklist item and get the + * operator's explicit consent before making any changes — so actions with real + * implications (e.g. uploading source maps, editing CI) are surfaced and + * approved, not applied silently. It deliberately stops there: no PR mandate or + * edit-style rules, since the operator pastes this into their own agent and + * governs how changes ultimately land. + * + * Background: https://github.com/PostHog/wizard/issues/447 + */ +export function buildCodingAgentPrompt(reportFile: string): string { + return `Read \`${reportFile}\` and work through its "Verify before merging" checklist: investigate each item, then list the changes you'd make and get my approval before applying any of them.`; +} diff --git a/src/lib/programs/posthog-integration/index.ts b/src/lib/programs/posthog-integration/index.ts index c9049b56..47727c2b 100644 --- a/src/lib/programs/posthog-integration/index.ts +++ b/src/lib/programs/posthog-integration/index.ts @@ -21,6 +21,7 @@ import { openTrackedLink, withUtm } from '@utils/links'; import type { CloudRegion } from '@utils/types'; import { POSTHOG_INTEGRATION_PROGRAM } from './steps.js'; import { getContentBlocks } from './content/index.js'; +import { buildCodingAgentPrompt } from './handoff.js'; const DASHBOARD_DEEP_LINK_KEY = 'dashboardDeepLink'; @@ -264,6 +265,7 @@ Important: Use the detect_package_manager tool (from the wizard-tools MCP server changes, docsUrl: config.metadata.docsUrl, continueUrl, + handoffPrompt: buildCodingAgentPrompt(SETUP_REPORT_FILE), }; }, }; diff --git a/src/lib/wizard-session.ts b/src/lib/wizard-session.ts index 2a222f51..292d34d3 100644 --- a/src/lib/wizard-session.ts +++ b/src/lib/wizard-session.ts @@ -95,6 +95,14 @@ export interface OutroData { dashboardUrl?: string; /** PostHog notebook URL the program uploaded the report to. */ notebookUrl?: string; + /** + * Copy-paste prompt the operator hands to their coding agent to finish the + * job (work the report's checklist). Printed to the terminal's main buffer on + * exit (see getExitLine in start-tui.ts) — the TUI's alternate screen is wiped + * on exit, so the scrollback line is where it survives and can be + * triple-click-selected. Set per-program in buildOutroData. + */ + handoffPrompt?: string; } /** A single question rendered by the WizardAsk overlay. */ diff --git a/src/ui/tui/__tests__/exit-line.test.ts b/src/ui/tui/__tests__/exit-line.test.ts new file mode 100644 index 00000000..3d8cacde --- /dev/null +++ b/src/ui/tui/__tests__/exit-line.test.ts @@ -0,0 +1,90 @@ +import { getExitLine } from '@ui/tui/exit-line'; +import { WizardStore, Program } from '@ui/tui/store'; +import { OutroKind } from '@lib/wizard-session'; + +jest.mock('../../../utils/analytics.js', () => ({ + analytics: { + capture: jest.fn(), + wizardCapture: jest.fn(), + setTag: jest.fn(), + shutdown: jest.fn().mockResolvedValue(undefined), + }, + sessionProperties: jest.fn(() => ({})), +})); + +// Strip ANSI so assertions read against plain text. +// eslint-disable-next-line no-control-regex +const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, ''); + +function storeWithOutro( + data: Parameters[0], +): WizardStore { + const store = new WizardStore(Program.PostHogIntegration); + store.setOutroData(data); + return store; +} + +describe('getExitLine', () => { + it('echoes the handoff prompt on its own line so it survives in scrollback', () => { + const prompt = + 'Read `posthog-setup-report.md` and work through the checklist.'; + const line = stripAnsi( + getExitLine( + storeWithOutro({ + kind: OutroKind.Success, + message: 'Successfully installed PostHog!', + handoffPrompt: prompt, + }), + ), + ); + + expect(line).toContain('Successfully installed PostHog!'); + expect(line).toContain('triple-click to select'); + expect(line).toContain(prompt); + // Prompt sits on its own line (not glued to the label) for clean selection. + expect(line.split('\n').some((l) => l === prompt)).toBe(true); + }); + + it('omits the handoff block when no prompt is set', () => { + const line = stripAnsi( + getExitLine( + storeWithOutro({ + kind: OutroKind.Success, + message: 'Successfully installed PostHog!', + }), + ), + ); + + expect(line).toContain('Successfully installed PostHog!'); + expect(line).not.toContain('coding agent'); + expect(line).not.toContain('\n'); + }); + + it('appends the report suffix when the message does not already mention it', () => { + const line = stripAnsi( + getExitLine( + storeWithOutro({ + kind: OutroKind.Success, + message: 'Done!', + reportFile: 'posthog-setup-report.md', + }), + ), + ); + expect(line).toContain('Check ./posthog-setup-report.md for details.'); + }); + + it('falls back to a default headline when the outro has no message', () => { + const line = stripAnsi( + getExitLine(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' })), + ); + expect(line).toMatch(/exited\.$/); + expect(line).not.toContain('coding agent'); + }); +}); diff --git a/src/ui/tui/exit-line.ts b/src/ui/tui/exit-line.ts new file mode 100644 index 00000000..c6b05ad0 --- /dev/null +++ b/src/ui/tui/exit-line.ts @@ -0,0 +1,47 @@ +/** + * exit-line.ts — builds the text printed to the MAIN terminal buffer after the + * wizard leaves the alternate screen on exit. + * + * The TUI renders in the alternate screen buffer, which is torn down on exit — + * everything the wizard drew (including the outro screen) is wiped. This line, + * printed AFTER releaseTerminal(), is the only output that survives into the + * user's scrollback. So the coding-agent handoff prompt is echoed here, on its + * own plain line (no border, no bullets), which is what a terminal can + * triple-click-select cleanly. + * + * Kept free of `ink`/`@inkjs/ui` imports so it stays a pure, unit-testable + * function (start-tui.ts itself pulls in the whole render tree). + */ + +import type { WizardStore } from './store.js'; +import { OutroKind } from '@lib/wizard-session'; + +const RESET_ATTRS = '\x1b[0m'; +const GREEN = '\x1b[32m'; +const BOLD = '\x1b[1m'; +const DIM = '\x1b[2m'; + +export function getExitLine(store: WizardStore): string { + const outro = store.session.outroData; + const label = store.session.programLabel ?? 'Wizard'; + + if (outro?.kind === OutroKind.Success) { + const message = outro.message ?? `${label} completed successfully.`; + const reportSuffix = + outro.reportFile && !message.includes(outro.reportFile) + ? ` Check ./${outro.reportFile} for details.` + : ''; + const headline = `${GREEN}${BOLD}✔${RESET_ATTRS} ${message}${reportSuffix}`; + + if (outro.handoffPrompt) { + return ( + `${headline}\n\n` + + `${DIM}Hand this to your coding agent to finish up (triple-click to select):${RESET_ATTRS}\n` + + outro.handoffPrompt + ); + } + return headline; + } + + return `${DIM}${label} exited.${RESET_ATTRS}`; +} diff --git a/src/ui/tui/start-tui.ts b/src/ui/tui/start-tui.ts index 871b26a6..9f840737 100644 --- a/src/ui/tui/start-tui.ts +++ b/src/ui/tui/start-tui.ts @@ -12,9 +12,9 @@ import { WizardStore, Program, type ProgramId } from './store.js'; import { InkUI } from './ink-ui.js'; import { setUI } from '@ui/index'; import { App } from './App.js'; -import { OutroKind } from '@lib/wizard-session'; import { analytics } from '@utils/analytics'; import { logToFile } from '@utils/debug'; +import { getExitLine } from './exit-line.js'; // ANSI escape sequences const RESET_ATTRS = '\x1b[0m'; @@ -23,30 +23,11 @@ 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'; export function releaseTerminal(): void { process.stdout.write(RESET_ATTRS + LEAVE_ALT_SCREEN); } -function getExitLine(store: WizardStore): string { - const outro = store.session.outroData; - const label = store.session.programLabel ?? 'Wizard'; - - if (outro?.kind === OutroKind.Success) { - const message = outro.message ?? `${label} completed successfully.`; - const reportSuffix = - outro.reportFile && !message.includes(outro.reportFile) - ? ` Check ./${outro.reportFile} for details.` - : ''; - return `${GREEN}${BOLD}\u2714${RESET_ATTRS} ${message}${reportSuffix}`; - } - - return `${DIM}${label} exited.${RESET_ATTRS}`; -} - export function startTUI( version: string, program: ProgramId = Program.PostHogIntegration,