diff --git a/src/lib/formatters/plain-detect.ts b/src/lib/formatters/plain-detect.ts index 676da9bd4..c5a101340 100644 --- a/src/lib/formatters/plain-detect.ts +++ b/src/lib/formatters/plain-detect.ts @@ -73,17 +73,29 @@ export function isPlainOutput(): boolean { } /** - * Strip ANSI escape sequences from a string. + * Strip ANSI/VT escape sequences from a string. * - * Handles SGR codes (`\x1b[...m`) and OSC 8 terminal hyperlink sequences - * (`\x1b]8;;url\x07text\x1b]8;;\x07`). + * Covers the four escape-sequence families that can reach a terminal: + * - CSI (`\x1b[`): SGR colour codes, cursor movement, screen-clear, etc. + * - OSC (`\x1b]`): window-title changes, hyperlinks, etc. + * - DCS (`\x1bP`): device-control strings. + * - Two-character C1 ESC: single-byte sequences like `\x1bc` (terminal reset). */ export function stripAnsi(text: string): string { return ( text - // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection requires matching \x1b and \x07 - .replace(/\x1b\[[0-9;]*m/g, "") - // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 hyperlink sequences use \x1b and \x07 - .replace(/\x1b\]8;;[^\x07]*\x07/g, "") + // CSI: \x1b[ + param bytes (0x30-0x3F) + intermediate bytes (0x20-0x2F) + final byte (0x40-0x7E) + // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection requires matching \x1b + .replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "") + // OSC: \x1b] ... terminated by BEL (\x07) or ST (\x1b\) + // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC sequences use \x1b and \x07 + .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") + // DCS: \x1bP ... terminated by ST (\x1b\) + // biome-ignore lint/suspicious/noControlCharactersInRegex: DCS sequences use \x1b + .replace(/\x1bP[^\x1b]*\x1b\\/g, "") + // Two-character ESC sequences (C1: 0x40-0x5F, Fs: 0x60-0x7E), e.g. \x1bc (terminal reset). + // Applied last so CSI/OSC/DCS introducers (\x1b[, \x1b], \x1bP) are already stripped. + // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC sequence detection requires matching \x1b + .replace(/\x1b[@-~]/g, "") ); } diff --git a/src/lib/init/feedback.ts b/src/lib/init/feedback.ts index 30cd47be8..5e7cff226 100644 --- a/src/lib/init/feedback.ts +++ b/src/lib/init/feedback.ts @@ -7,10 +7,7 @@ const FEEDBACK_COMMANDS: Record = { }; const FEEDBACK_COPY: Record = { - success: [ - "Nice, setup made it through.", - "Tell us what felt great or rough:", - ], + success: ["Tell us what felt great or rough:"], cancelled: [ "Sad to see setup stop. Was something going sideways?", "Tell us so we can fix it:", diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index 57fcb9c2b..ecc1dd8c7 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -16,7 +16,8 @@ */ import { terminalLink } from "../formatters/colors.js"; -import { featureLabel } from "./clack-utils.js"; +import { stripAnsi } from "../formatters/plain-detect.js"; +import { featureLabel, sortFeatures } from "./clack-utils.js"; import { EXIT_DEPENDENCY_INSTALL_FAILED, EXIT_PLATFORM_NOT_DETECTED, @@ -33,6 +34,22 @@ import type { WizardSummary, WizardUI } from "./ui/types.js"; * appear. */ function buildSummary(output: WizardOutput): WizardSummary | null { + // Resolve blurbs first so the Features row can check the *resolved* length. + // If the agent returns blurbs with wrong IDs they all drop out here, and + // the Features row falls back to showing correctly. + const blurbMap = new Map( + (output.featureBlurbs ?? []).map(({ feature, blurb }) => [ + feature, + stripAnsi(blurb), + ]) + ); + const featureBlurbs = sortFeatures(output.features ?? []) + .map((feature) => { + const blurb = blurbMap.get(feature); + return blurb ? { label: featureLabel(feature), blurb } : null; + }) + .filter((b): b is { label: string; blurb: string } => b !== null); + const fields: WizardSummary["fields"] = []; if (output.platform) { @@ -41,7 +58,7 @@ function buildSummary(output: WizardOutput): WizardSummary | null { if (output.projectDir) { fields.push({ label: "Directory", value: output.projectDir }); } - if (output.features?.length) { + if (output.features?.length && !featureBlurbs.length) { fields.push({ label: "Features", value: output.features.map(featureLabel).join(", "), @@ -62,13 +79,18 @@ function buildSummary(output: WizardOutput): WizardSummary | null { const changedFiles = output.changedFiles ?? []; - if (fields.length === 0 && changedFiles.length === 0) { + if ( + fields.length === 0 && + changedFiles.length === 0 && + featureBlurbs.length === 0 + ) { return null; } return { fields, ...(changedFiles.length > 0 ? { changedFiles } : {}), + ...(featureBlurbs.length > 0 ? { featureBlurbs } : {}), }; } diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index 72aea0656..36a76c1a0 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -229,6 +229,7 @@ export type WizardOutput = { docsUrl?: string; sentryProjectUrl?: string; message?: string; + featureBlurbs?: Array<{ feature: string; blurb: string }>; }; // Interactive payloads diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 338473553..df32640a8 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -1173,6 +1173,28 @@ function SummaryPanel({ ))} ) : null} + {summary.featureBlurbs !== undefined && + summary.featureBlurbs.length > 0 ? ( + + + Here's what we set up + + {summary.featureBlurbs.map(({ label, blurb }) => ( + + + + {label} + + + + + {blurb} + + + + ))} + + ) : null} {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( ) : null} diff --git a/src/lib/init/ui/ink-report.ts b/src/lib/init/ui/ink-report.ts index 4ab7820e2..53ee6975b 100644 --- a/src/lib/init/ui/ink-report.ts +++ b/src/lib/init/ui/ink-report.ts @@ -1,4 +1,5 @@ import chalk from "chalk"; +import { renderTextTable } from "../../formatters/text-table.js"; import { buildFileTree, flattenTree } from "./file-tree.js"; import type { WizardSummary } from "./types.js"; @@ -60,6 +61,20 @@ export function formatSuccessReport( lines.push(` ${label} ${field.value}`); } } + if (summary?.featureBlurbs && summary.featureBlurbs.length > 0) { + lines.push(""); + lines.push(` ${chalk.hex(REPORT_MUTED).bold("Here's what we set up")}`); + const tableRows = summary.featureBlurbs.map(({ label, blurb }) => [ + chalk.bold(label), + chalk.hex(REPORT_MUTED)(blurb), + ]); + const table = renderTextTable(["", ""], tableRows, { + shrinkable: [false, true], + }); + for (const line of table.trimEnd().split("\n")) { + lines.push(` ${line}`); + } + } if (summary?.changedFiles && summary.changedFiles.length > 0) { lines.push(""); lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index a977d6e6f..b2ec786b1 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -22,6 +22,7 @@ import { renderInlineMarkdown, renderMarkdown, } from "../../formatters/markdown.js"; +import { renderTextTable } from "../../formatters/text-table.js"; import { formatFeedbackHint, type InitFeedbackOutcome } from "../feedback.js"; import { buildFileTree, flattenTree } from "./file-tree.js"; import type { @@ -94,7 +95,11 @@ export class LoggingUI implements WizardUI { } summary(summary: WizardSummary): void { - if (summary.fields.length === 0 && !summary.changedFiles?.length) { + if ( + summary.fields.length === 0 && + !summary.changedFiles?.length && + !summary.featureBlurbs?.length + ) { return; } // Compact two-column key/value listing — one line per field. The @@ -109,6 +114,20 @@ export class LoggingUI implements WizardUI { const padded = field.label.padEnd(labelWidth); this.writeLine(this.stdout, ` ${padded} ${field.value}`); } + if (summary.featureBlurbs && summary.featureBlurbs.length > 0) { + this.writeLine(this.stdout, ""); + this.writeLine(this.stdout, " Here's what we set up"); + const tableRows = summary.featureBlurbs.map(({ label, blurb }) => [ + label, + blurb, + ]); + const table = renderTextTable(["", ""], tableRows, { + shrinkable: [false, true], + }); + for (const line of table.trimEnd().split("\n")) { + this.writeLine(this.stdout, ` ${line}`); + } + } if (summary.changedFiles && summary.changedFiles.length > 0) { this.writeLine(this.stdout, ""); this.writeLine(this.stdout, " Changed files:"); diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index 67c376691..6e7820011 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -143,6 +143,8 @@ export type WizardSummary = { fields: { label: string; value: string }[]; /** Optional list of files the wizard added/edited/removed. */ changedFiles?: { action: string; path: string }[]; + /** AI-generated per-feature blurbs personalised to the analysed project. */ + featureBlurbs?: { label: string; blurb: string }[]; }; /** diff --git a/test/lib/init/feedback.test.ts b/test/lib/init/feedback.test.ts index b4eec0ee9..92bf9149e 100644 --- a/test/lib/init/feedback.test.ts +++ b/test/lib/init/feedback.test.ts @@ -5,7 +5,6 @@ describe("formatFeedbackHint", () => { test("maps init outcomes to copy-paste feedback commands", () => { expect(formatFeedbackHint("success")).toBe( [ - "Nice, setup made it through.", "Tell us what felt great or rough:", '$ sentry cli feedback "sentry init worked well"', ].join("\n") diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index 03f860504..723573332 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -184,6 +184,263 @@ describe("formatResult", () => { }); }); +describe("formatResult with featureBlurbs", () => { + test("populates featureBlurbs from output.featureBlurbs paired positionally with output.features", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + projectDir: "/app", + features: ["errorMonitoring", "performanceMonitoring"], + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures exceptions." }, + { feature: "performanceMonitoring", blurb: "Traces requests." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs).toEqual([ + { label: "Error Monitoring", blurb: "Captures exceptions." }, + { label: "Tracing", blurb: "Traces requests." }, + ]); + }); + + test("suppresses the Features row when featureBlurbs are present", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures exceptions." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.fields.some((f) => f.label === "Features")).toBe(false); + }); + + test("shows the Features row when featureBlurbs are absent", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring", "sessionReplay"], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.fields.some((f) => f.label === "Features")).toBe(true); + }); + + test("labels use canonical feature IDs — agent echoing wrong IDs omits the blurb rather than mislabelling", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring", "sessionReplay"], + // Agent echoed back wrong IDs — neither matches a canonical feature + featureBlurbs: [ + { feature: "error_monitoring", blurb: "Blurb A." }, + { feature: "session-replay", blurb: "Blurb B." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + // Wrong IDs → no match → blurbs omitted entirely; safe fallback + expect(summary?.featureBlurbs).toBeUndefined(); + }); + + test("drops blurb for feature the agent omitted — remaining blurbs stay correctly labelled", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + // Agent returned 2 of 3; skipped performanceMonitoring + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures." }, + { feature: "sessionReplay", blurb: "Records." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs).toHaveLength(2); + expect(summary?.featureBlurbs?.map((b) => b.label)).toEqual([ + "Error Monitoring", + "Session Replay", + ]); + }); + + test("stripAnsi strips SGR colour codes from server-supplied blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "\x1b[31mCaptures.\x1b[0m" }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips CSI sequences with intermediate bytes (e.g. soft reset, cursor style)", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1b[!p = Soft Terminal Reset (intermediate byte !) + // \x1b[1 q = Set Cursor Style (intermediate byte space) + { + feature: "errorMonitoring", + blurb: "\x1b[!pCaptures.\x1b[1 q", + }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips arbitrary OSC sequences (e.g. window title change) from blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1b]0;title\x07 = set window title — OSC command, not OSC 8 + { + feature: "errorMonitoring", + blurb: "\x1b]0;injected title\x07Captures.", + }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips single-char C1 ESC sequences (e.g. terminal reset) from blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1bc = RIS (Reset to Initial State) — two-char ESC sequence + { feature: "errorMonitoring", blurb: "\x1bcCaptures." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("stripAnsi strips non-SGR CSI sequences (cursor movement, screen-clear) from blurbs", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring"], + featureBlurbs: [ + // \x1b[2J = clear screen, \x1b[1A = cursor up — non-SGR CSI + { + feature: "errorMonitoring", + blurb: "\x1b[2JCaptures.\x1b[1A", + }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs?.[0]?.blurb).toBe("Captures."); + }); + + test("sorts featureBlurbs by canonical display order", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + // Server returned performanceMonitoring before errorMonitoring + features: ["performanceMonitoring", "errorMonitoring"], + featureBlurbs: [ + { feature: "performanceMonitoring", blurb: "Traces." }, + { feature: "errorMonitoring", blurb: "Captures." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + // errorMonitoring comes before performanceMonitoring in FEATURE_DISPLAY_ORDER + expect(summary?.featureBlurbs?.map((b) => b.label)).toEqual([ + "Error Monitoring", + "Tracing", + ]); + }); +}); + describe("formatError", () => { test("logs the error message", () => { const { ui, calls } = createMockUI(); diff --git a/test/lib/init/ui/ink-app.snapshot.test.tsx b/test/lib/init/ui/ink-app.snapshot.test.tsx index 3bf529cb5..17562d301 100644 --- a/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -514,6 +514,27 @@ describe("Ink App snapshot", () => { expect(frame).not.toMatch(FILES_HEADER_UNPINNED_RE); }); + test("SummaryPanel renders featureBlurbs as Here's what we set up section", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setSummary({ + fields: [{ label: "Platform", value: "javascript.nextjs" }], + featureBlurbs: [ + { + label: "Error Monitoring", + blurb: "Captures every unhandled exception.", + }, + { label: "Tracing", blurb: "Traces requests end-to-end." }, + ], + }); + + const frame = stripAnsi((await renderApp(store, 120)).allOutput()); + expect(frame).toContain("Here's what we set up"); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Captures every unhandled exception."); + expect(frame).toContain("Tracing"); + expect(frame).toContain("Traces requests end-to-end."); + }); + test("Ctrl+C path uses requestCancel via store, never bare process.exit", () => { let cancels = 0; const store = new WizardStore(); diff --git a/test/lib/init/ui/ink-report.test.ts b/test/lib/init/ui/ink-report.test.ts index 679b36760..2060cdd48 100644 --- a/test/lib/init/ui/ink-report.test.ts +++ b/test/lib/init/ui/ink-report.test.ts @@ -49,6 +49,37 @@ describe("formatSuccessReport with summary fields", () => { }); }); +describe("formatSuccessReport with featureBlurbs", () => { + test("renders Here's what we set up heading and blurb content", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { + fields: [], + featureBlurbs: [ + { label: "Error Monitoring", blurb: "Captures exceptions." }, + { label: "Tracing", blurb: "Traces requests end-to-end." }, + ], + }) + ); + expect(output).toContain("Here's what we set up"); + expect(output).toContain("Error Monitoring"); + expect(output).toContain("Captures exceptions."); + expect(output).toContain("Tracing"); + expect(output).toContain("Traces requests end-to-end."); + }); + + test("no blurbs section when featureBlurbs is absent", () => { + const output = stripAnsi(formatSuccessReport("Done!", { fields: [] })); + expect(output).not.toContain("Here's what we set up"); + }); + + test("no blurbs section when featureBlurbs is empty", () => { + const output = stripAnsi( + formatSuccessReport("Done!", { fields: [], featureBlurbs: [] }) + ); + expect(output).not.toContain("Here's what we set up"); + }); +}); + describe("formatSuccessReport with changedFiles", () => { test("shows Changed files heading and file paths", () => { const output = stripAnsi( diff --git a/test/lib/init/ui/logging-ui.test.ts b/test/lib/init/ui/logging-ui.test.ts index 881b9d2bd..c0685ffe6 100644 --- a/test/lib/init/ui/logging-ui.test.ts +++ b/test/lib/init/ui/logging-ui.test.ts @@ -307,4 +307,27 @@ describe("LoggingUI summary", () => { expect(lines.some((l) => l.includes("−") && l.includes("b.ts"))).toBe(true); expect(lines.some((l) => l.includes("~") && l.includes("c.ts"))).toBe(true); }); + + test("featureBlurbs renders Here's what we set up table with label and blurb", () => { + const { ui, stdout } = createUI(); + ui.summary({ + fields: [], + featureBlurbs: [ + { label: "Error Monitoring", blurb: "Captures exceptions." }, + { label: "Tracing", blurb: "Traces requests." }, + ], + }); + const out = stdout(); + expect(out).toContain("Here's what we set up"); + expect(out).toContain("Error Monitoring"); + expect(out).toContain("Captures exceptions."); + expect(out).toContain("Tracing"); + expect(out).toContain("Traces requests."); + }); + + test("no featureBlurbs section when featureBlurbs is absent", () => { + const { ui, stdout } = createUI(); + ui.summary({ fields: [{ label: "Platform", value: "Next.js" }] }); + expect(stdout()).not.toContain("Here's what we set up"); + }); });