Skip to content

Commit 58ac61e

Browse files
betegonclaude
andauthored
feat(init): show AI-generated feature blurbs in setup summary (#982)
## Summary Ends the wizard with a \"Here's what we set up\" two-column table — one row per enabled feature with a sentence specific to the project that was just instrumented. The blurbs come from a new server-side agent that uses the tech-stack description already captured during platform detection (e.g. \"Next.js 14 with Drizzle ORM and Cloudflare D1\") to produce context-aware copy rather than generic marketing text. Depends on getsentry/cli-init-api#156 for the server-side agent. ## Changes - `WizardSummary` and `WizardOutput` gain an optional `featureBlurbs` field - `buildSummary()` populates it from the workflow output and suppresses the plain \"Features\" row when blurbs are present (the table makes it redundant) - Both `ink-report.ts` (post-dispose chalk output) and `logging-ui.ts` render the blurbs as a proper two-column table via `renderTextTable` — same renderer used by `sentry issue list` / `sentry project list`, so wrapping and column fitting are handled automatically - `SummaryPanel` in `ink-app.tsx` renders the live Ink variant with flex layout - Removed \"Nice, setup made it through.\" preamble so the feedback prompt follows the summary directly ## Test Plan - Start `bun run dev` in `cli-init-api`, then run `MASTRA_API_URL=http://localhost:8787 bun run src/bin.ts init` against a real project - Confirm the blurbs table appears after the key/value fields and before changed files - Run with `--no-tui` and confirm the plain-text table renders the same content - If the blurb agent fails, wizard completes normally with no blurbs shown (try/catch in `open-sentry-ui.ts`) --------- Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 362b813 commit 58ac61e

13 files changed

Lines changed: 437 additions & 16 deletions

src/lib/formatters/plain-detect.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,29 @@ export function isPlainOutput(): boolean {
7373
}
7474

7575
/**
76-
* Strip ANSI escape sequences from a string.
76+
* Strip ANSI/VT escape sequences from a string.
7777
*
78-
* Handles SGR codes (`\x1b[...m`) and OSC 8 terminal hyperlink sequences
79-
* (`\x1b]8;;url\x07text\x1b]8;;\x07`).
78+
* Covers the four escape-sequence families that can reach a terminal:
79+
* - CSI (`\x1b[`): SGR colour codes, cursor movement, screen-clear, etc.
80+
* - OSC (`\x1b]`): window-title changes, hyperlinks, etc.
81+
* - DCS (`\x1bP`): device-control strings.
82+
* - Two-character C1 ESC: single-byte sequences like `\x1bc` (terminal reset).
8083
*/
8184
export function stripAnsi(text: string): string {
8285
return (
8386
text
84-
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection requires matching \x1b and \x07
85-
.replace(/\x1b\[[0-9;]*m/g, "")
86-
// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 hyperlink sequences use \x1b and \x07
87-
.replace(/\x1b\]8;;[^\x07]*\x07/g, "")
87+
// CSI: \x1b[ + param bytes (0x30-0x3F) + intermediate bytes (0x20-0x2F) + final byte (0x40-0x7E)
88+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection requires matching \x1b
89+
.replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "")
90+
// OSC: \x1b] ... terminated by BEL (\x07) or ST (\x1b\)
91+
// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC sequences use \x1b and \x07
92+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
93+
// DCS: \x1bP ... terminated by ST (\x1b\)
94+
// biome-ignore lint/suspicious/noControlCharactersInRegex: DCS sequences use \x1b
95+
.replace(/\x1bP[^\x1b]*\x1b\\/g, "")
96+
// Two-character ESC sequences (C1: 0x40-0x5F, Fs: 0x60-0x7E), e.g. \x1bc (terminal reset).
97+
// Applied last so CSI/OSC/DCS introducers (\x1b[, \x1b], \x1bP) are already stripped.
98+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC sequence detection requires matching \x1b
99+
.replace(/\x1b[@-~]/g, "")
88100
);
89101
}

src/lib/init/feedback.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,7 @@ const FEEDBACK_COMMANDS: Record<InitFeedbackOutcome, string> = {
77
};
88

99
const FEEDBACK_COPY: Record<InitFeedbackOutcome, string[]> = {
10-
success: [
11-
"Nice, setup made it through.",
12-
"Tell us what felt great or rough:",
13-
],
10+
success: ["Tell us what felt great or rough:"],
1411
cancelled: [
1512
"Sad to see setup stop. Was something going sideways?",
1613
"Tell us so we can fix it:",

src/lib/init/formatters.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
*/
1717

1818
import { terminalLink } from "../formatters/colors.js";
19-
import { featureLabel } from "./clack-utils.js";
19+
import { stripAnsi } from "../formatters/plain-detect.js";
20+
import { featureLabel, sortFeatures } from "./clack-utils.js";
2021
import {
2122
EXIT_DEPENDENCY_INSTALL_FAILED,
2223
EXIT_PLATFORM_NOT_DETECTED,
@@ -33,6 +34,22 @@ import type { WizardSummary, WizardUI } from "./ui/types.js";
3334
* appear.
3435
*/
3536
function buildSummary(output: WizardOutput): WizardSummary | null {
37+
// Resolve blurbs first so the Features row can check the *resolved* length.
38+
// If the agent returns blurbs with wrong IDs they all drop out here, and
39+
// the Features row falls back to showing correctly.
40+
const blurbMap = new Map(
41+
(output.featureBlurbs ?? []).map(({ feature, blurb }) => [
42+
feature,
43+
stripAnsi(blurb),
44+
])
45+
);
46+
const featureBlurbs = sortFeatures(output.features ?? [])
47+
.map((feature) => {
48+
const blurb = blurbMap.get(feature);
49+
return blurb ? { label: featureLabel(feature), blurb } : null;
50+
})
51+
.filter((b): b is { label: string; blurb: string } => b !== null);
52+
3653
const fields: WizardSummary["fields"] = [];
3754

3855
if (output.platform) {
@@ -41,7 +58,7 @@ function buildSummary(output: WizardOutput): WizardSummary | null {
4158
if (output.projectDir) {
4259
fields.push({ label: "Directory", value: output.projectDir });
4360
}
44-
if (output.features?.length) {
61+
if (output.features?.length && !featureBlurbs.length) {
4562
fields.push({
4663
label: "Features",
4764
value: output.features.map(featureLabel).join(", "),
@@ -62,13 +79,18 @@ function buildSummary(output: WizardOutput): WizardSummary | null {
6279

6380
const changedFiles = output.changedFiles ?? [];
6481

65-
if (fields.length === 0 && changedFiles.length === 0) {
82+
if (
83+
fields.length === 0 &&
84+
changedFiles.length === 0 &&
85+
featureBlurbs.length === 0
86+
) {
6687
return null;
6788
}
6889

6990
return {
7091
fields,
7192
...(changedFiles.length > 0 ? { changedFiles } : {}),
93+
...(featureBlurbs.length > 0 ? { featureBlurbs } : {}),
7294
};
7395
}
7496

src/lib/init/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export type WizardOutput = {
229229
docsUrl?: string;
230230
sentryProjectUrl?: string;
231231
message?: string;
232+
featureBlurbs?: Array<{ feature: string; blurb: string }>;
232233
};
233234

234235
// Interactive payloads

src/lib/init/ui/ink-app.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,28 @@ function SummaryPanel({
11731173
))}
11741174
</Box>
11751175
) : null}
1176+
{summary.featureBlurbs !== undefined &&
1177+
summary.featureBlurbs.length > 0 ? (
1178+
<Box flexDirection="column" flexShrink={0} marginTop={1}>
1179+
<Text bold color={MUTED}>
1180+
Here&apos;s what we set up
1181+
</Text>
1182+
{summary.featureBlurbs.map(({ label, blurb }) => (
1183+
<Box flexDirection="row" flexShrink={0} key={label}>
1184+
<Box flexShrink={0} width={22}>
1185+
<Text bold color={PRIMARY}>
1186+
{label}
1187+
</Text>
1188+
</Box>
1189+
<Box flexShrink={1}>
1190+
<Text color={MUTED} wrap="wrap">
1191+
{blurb}
1192+
</Text>
1193+
</Box>
1194+
</Box>
1195+
))}
1196+
</Box>
1197+
) : null}
11761198
{summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? (
11771199
<ChangedFilesTree files={summary.changedFiles} />
11781200
) : null}

src/lib/init/ui/ink-report.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import chalk from "chalk";
2+
import { renderTextTable } from "../../formatters/text-table.js";
23
import { buildFileTree, flattenTree } from "./file-tree.js";
34
import type { WizardSummary } from "./types.js";
45

@@ -60,6 +61,20 @@ export function formatSuccessReport(
6061
lines.push(` ${label} ${field.value}`);
6162
}
6263
}
64+
if (summary?.featureBlurbs && summary.featureBlurbs.length > 0) {
65+
lines.push("");
66+
lines.push(` ${chalk.hex(REPORT_MUTED).bold("Here's what we set up")}`);
67+
const tableRows = summary.featureBlurbs.map(({ label, blurb }) => [
68+
chalk.bold(label),
69+
chalk.hex(REPORT_MUTED)(blurb),
70+
]);
71+
const table = renderTextTable(["", ""], tableRows, {
72+
shrinkable: [false, true],
73+
});
74+
for (const line of table.trimEnd().split("\n")) {
75+
lines.push(` ${line}`);
76+
}
77+
}
6378
if (summary?.changedFiles && summary.changedFiles.length > 0) {
6479
lines.push("");
6580
lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`);

src/lib/init/ui/logging-ui.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
renderInlineMarkdown,
2323
renderMarkdown,
2424
} from "../../formatters/markdown.js";
25+
import { renderTextTable } from "../../formatters/text-table.js";
2526
import { formatFeedbackHint, type InitFeedbackOutcome } from "../feedback.js";
2627
import { buildFileTree, flattenTree } from "./file-tree.js";
2728
import type {
@@ -94,7 +95,11 @@ export class LoggingUI implements WizardUI {
9495
}
9596

9697
summary(summary: WizardSummary): void {
97-
if (summary.fields.length === 0 && !summary.changedFiles?.length) {
98+
if (
99+
summary.fields.length === 0 &&
100+
!summary.changedFiles?.length &&
101+
!summary.featureBlurbs?.length
102+
) {
98103
return;
99104
}
100105
// Compact two-column key/value listing — one line per field. The
@@ -109,6 +114,20 @@ export class LoggingUI implements WizardUI {
109114
const padded = field.label.padEnd(labelWidth);
110115
this.writeLine(this.stdout, ` ${padded} ${field.value}`);
111116
}
117+
if (summary.featureBlurbs && summary.featureBlurbs.length > 0) {
118+
this.writeLine(this.stdout, "");
119+
this.writeLine(this.stdout, " Here's what we set up");
120+
const tableRows = summary.featureBlurbs.map(({ label, blurb }) => [
121+
label,
122+
blurb,
123+
]);
124+
const table = renderTextTable(["", ""], tableRows, {
125+
shrinkable: [false, true],
126+
});
127+
for (const line of table.trimEnd().split("\n")) {
128+
this.writeLine(this.stdout, ` ${line}`);
129+
}
130+
}
112131
if (summary.changedFiles && summary.changedFiles.length > 0) {
113132
this.writeLine(this.stdout, "");
114133
this.writeLine(this.stdout, " Changed files:");

src/lib/init/ui/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,8 @@ export type WizardSummary = {
143143
fields: { label: string; value: string }[];
144144
/** Optional list of files the wizard added/edited/removed. */
145145
changedFiles?: { action: string; path: string }[];
146+
/** AI-generated per-feature blurbs personalised to the analysed project. */
147+
featureBlurbs?: { label: string; blurb: string }[];
146148
};
147149

148150
/**

test/lib/init/feedback.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ describe("formatFeedbackHint", () => {
55
test("maps init outcomes to copy-paste feedback commands", () => {
66
expect(formatFeedbackHint("success")).toBe(
77
[
8-
"Nice, setup made it through.",
98
"Tell us what felt great or rough:",
109
'$ sentry cli feedback "sentry init worked well"',
1110
].join("\n")

0 commit comments

Comments
 (0)