From 0fb7660169768b0e8d5718b9ae991a16b19f9d1e Mon Sep 17 00:00:00 2001 From: Ethan Gui <1060966+ethangui@users.noreply.github.com> Date: Mon, 8 Jun 2026 07:40:53 -0500 Subject: [PATCH] feat: surface a coding-agent handoff prompt on wizard exit Add OutroData.handoffPrompt, set per-program in buildOutroData and printed to the terminal's main buffer on exit (getExitLine). Rides on main's getUI().setOutroData() delivery, so the prompt reaches the outro data without any extra plumbing. Verification content lives in context-mill. Refs #447. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/handoff.test.ts | 38 ++++++++ .../programs/posthog-integration/handoff.ts | 28 ++++++ src/lib/programs/posthog-integration/index.ts | 2 + src/lib/wizard-session.ts | 8 ++ src/ui/tui/__tests__/exit-line.test.ts | 90 +++++++++++++++++++ src/ui/tui/exit-line.ts | 47 ++++++++++ src/ui/tui/start-tui.ts | 21 +---- 7 files changed, 214 insertions(+), 20 deletions(-) create mode 100644 src/lib/programs/posthog-integration/__tests__/handoff.test.ts create mode 100644 src/lib/programs/posthog-integration/handoff.ts create mode 100644 src/ui/tui/__tests__/exit-line.test.ts create mode 100644 src/ui/tui/exit-line.ts 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,