From c080723c9c19184429f438c70c5b0ee83a736a6b Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 20:39:15 +0200 Subject: [PATCH 01/12] feat(init): show AI-generated feature blurbs in setup summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Here's what we set up" two-column table at the end of sentry init — one row per enabled feature with a project-specific sentence generated by a new feature-blurb-writer agent. The blurb column wraps to terminal width using the same renderTextTable renderer that sentry issue list / project list use. The Features key-value row is suppressed when blurbs are present (the table already covers it). The success preamble "Nice, setup made it through." is removed so the feedback prompt follows the summary directly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/feedback.ts | 1 - src/lib/init/formatters.ts | 14 ++++++++++++-- src/lib/init/types.ts | 1 + src/lib/init/ui/ink-app.tsx | 21 +++++++++++++++++++++ src/lib/init/ui/ink-report.ts | 15 +++++++++++++++ src/lib/init/ui/logging-ui.ts | 15 +++++++++++++++ src/lib/init/ui/types.ts | 2 ++ 7 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/lib/init/feedback.ts b/src/lib/init/feedback.ts index 30cd47be8..5a5792ab8 100644 --- a/src/lib/init/feedback.ts +++ b/src/lib/init/feedback.ts @@ -8,7 +8,6 @@ const FEEDBACK_COMMANDS: Record = { const FEEDBACK_COPY: Record = { success: [ - "Nice, setup made it through.", "Tell us what felt great or rough:", ], cancelled: [ diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index 57fcb9c2b..8b97fbe1b 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -16,7 +16,7 @@ */ import { terminalLink } from "../formatters/colors.js"; -import { featureLabel } from "./clack-utils.js"; +import { featureLabel, sortFeatures } from "./clack-utils.js"; import { EXIT_DEPENDENCY_INSTALL_FAILED, EXIT_PLATFORM_NOT_DETECTED, @@ -41,7 +41,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 && !output.featureBlurbs?.length) { fields.push({ label: "Features", value: output.features.map(featureLabel).join(", "), @@ -62,6 +62,15 @@ function buildSummary(output: WizardOutput): WizardSummary | null { const changedFiles = output.changedFiles ?? []; + const featureBlurbs = sortFeatures( + (output.featureBlurbs ?? []).map((b) => b.feature) + ) + .map((feature) => { + const match = output.featureBlurbs?.find((b) => b.feature === feature); + return match ? { label: featureLabel(feature), blurb: match.blurb } : null; + }) + .filter((b): b is { label: string; blurb: string } => b !== null); + if (fields.length === 0 && changedFiles.length === 0) { return null; } @@ -69,6 +78,7 @@ function buildSummary(output: WizardOutput): WizardSummary | 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..8205954d0 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -1176,6 +1176,27 @@ function SummaryPanel({ {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( ) : null} + {summary.featureBlurbs !== undefined && summary.featureBlurbs.length > 0 ? ( + + + Here's what we set up + + {summary.featureBlurbs.map(({ label, blurb }) => ( + + + + {label} + + + + + {blurb} + + + + ))} + + ) : null} ); } diff --git a/src/lib/init/ui/ink-report.ts b/src/lib/init/ui/ink-report.ts index 4ab7820e2..85cae5ee8 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"; @@ -68,6 +69,20 @@ export function formatSuccessReport( lines.push(formatTreeRowChalk(row)); } } + 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}`); + } + } appendFeedbackHint(lines, feedbackHint); return lines.join("\n"); } diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index a977d6e6f..6482c7aa3 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -18,6 +18,7 @@ * logs deterministic and free of carriage returns. */ +import { renderTextTable } from "../../formatters/text-table.js"; import { renderInlineMarkdown, renderMarkdown, @@ -119,6 +120,20 @@ export class LoggingUI implements WizardUI { this.writeLine(this.stdout, ` ${formatTreeRowPlain(row)}`); } } + 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}`); + } + } } outro(message: string): void { 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 }[]; }; /** From 57b8b6a56555adca31fc54e062d0c26fd2c4cb38 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 20:42:38 +0200 Subject: [PATCH 02/12] =?UTF-8?q?chore(init):=20cleanup=20feature=20blurbs?= =?UTF-8?q?=20=E2=80=94=20Map=20lookup,=20redundant=20prop,=20colon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace O(n²) find-in-map loop in buildSummary with a Map for blurb lookup - Remove redundant marginTop={0} from featureBlurbs row in SummaryPanel - Drop trailing colon from "Here's what we set up" in LoggingUI to match ink-report and ink-app - Update feedback.test.ts to reflect removed success preamble Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/formatters.ts | 11 ++++++----- src/lib/init/ui/ink-app.tsx | 2 +- src/lib/init/ui/logging-ui.ts | 2 +- test/lib/init/feedback.test.ts | 1 - 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index 8b97fbe1b..cffb6cf82 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -62,12 +62,13 @@ function buildSummary(output: WizardOutput): WizardSummary | null { const changedFiles = output.changedFiles ?? []; - const featureBlurbs = sortFeatures( - (output.featureBlurbs ?? []).map((b) => b.feature) - ) + const blurbMap = new Map( + (output.featureBlurbs ?? []).map(({ feature, blurb }) => [feature, blurb]) + ); + const featureBlurbs = sortFeatures([...blurbMap.keys()]) .map((feature) => { - const match = output.featureBlurbs?.find((b) => b.feature === feature); - return match ? { label: featureLabel(feature), blurb: match.blurb } : null; + const blurb = blurbMap.get(feature); + return blurb ? { label: featureLabel(feature), blurb } : null; }) .filter((b): b is { label: string; blurb: string } => b !== null); diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 8205954d0..b80deed6f 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -1182,7 +1182,7 @@ function SummaryPanel({ Here's what we set up {summary.featureBlurbs.map(({ label, blurb }) => ( - + {label} diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index 6482c7aa3..8082fedf3 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -122,7 +122,7 @@ export class LoggingUI implements WizardUI { } if (summary.featureBlurbs && summary.featureBlurbs.length > 0) { this.writeLine(this.stdout, ""); - this.writeLine(this.stdout, " Here's what we set up:"); + this.writeLine(this.stdout, " Here's what we set up"); const tableRows = summary.featureBlurbs.map(({ label, blurb }) => [ label, blurb, 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") From f783d96b1101b1efc0e24d6091d1dcf1ca13c699 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 20:48:29 +0200 Subject: [PATCH 03/12] fix(init): use positional matching for feature blurb labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLM can echo back arbitrary strings in the feature field of the blurbs response. Instead of keying labels off the LLM output, pair blurbs positionally against output.features — the canonical ordered list of selected feature IDs already in the workflow output. Labels are always resolved via featureLabel() on the canonical IDs. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/formatters.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index cffb6cf82..dcc6b74b7 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -62,12 +62,15 @@ function buildSummary(output: WizardOutput): WizardSummary | null { const changedFiles = output.changedFiles ?? []; - const blurbMap = new Map( - (output.featureBlurbs ?? []).map(({ feature, blurb }) => [feature, blurb]) - ); - const featureBlurbs = sortFeatures([...blurbMap.keys()]) + // output.features is the canonical ordered list of selected feature IDs. + // Pair blurbs positionally so labels are always correct regardless of what + // the agent echoes back in the feature field. + const blurbsInOrder = output.featureBlurbs ?? []; + const canonicalFeatures = output.features ?? []; + const featureBlurbs = sortFeatures(canonicalFeatures) .map((feature) => { - const blurb = blurbMap.get(feature); + const pos = canonicalFeatures.indexOf(feature); + const blurb = blurbsInOrder[pos]?.blurb; return blurb ? { label: featureLabel(feature), blurb } : null; }) .filter((b): b is { label: string; blurb: string } => b !== null); From 7811a4328792adf610d73019685ff952ba1d555a Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 21:06:24 +0200 Subject: [PATCH 04/12] fix(init): lint, early-return guard, and test coverage for feature blurbs - Fix Biome import order in logging-ui.ts (renderTextTable after markdown) - Fix Biome format: collapse single-item success array, split long JSX condition - Fix LoggingUI.summary() early-return guard to account for featureBlurbs - Fix buildSummary null guard to include featureBlurbs in the emptiness check - Add test coverage: formatters (blurb population, suppressed Features row, positional label matching, display-order sort), ink-report (blurbs section), logging-ui (blurbs table, no section when absent) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/feedback.ts | 4 +- src/lib/init/formatters.ts | 2 +- src/lib/init/ui/ink-app.tsx | 3 +- src/lib/init/ui/logging-ui.ts | 8 +- test/lib/init/formatters.test.ts | 114 ++++++++++++++++++++++++++++ test/lib/init/ui/ink-report.test.ts | 31 ++++++++ test/lib/init/ui/logging-ui.test.ts | 23 ++++++ 7 files changed, 178 insertions(+), 7 deletions(-) diff --git a/src/lib/init/feedback.ts b/src/lib/init/feedback.ts index 5a5792ab8..5e7cff226 100644 --- a/src/lib/init/feedback.ts +++ b/src/lib/init/feedback.ts @@ -7,9 +7,7 @@ const FEEDBACK_COMMANDS: Record = { }; const FEEDBACK_COPY: Record = { - success: [ - "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 dcc6b74b7..9e1418409 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -75,7 +75,7 @@ function buildSummary(output: WizardOutput): WizardSummary | null { }) .filter((b): b is { label: string; blurb: string } => b !== null); - if (fields.length === 0 && changedFiles.length === 0) { + if (fields.length === 0 && changedFiles.length === 0 && featureBlurbs.length === 0) { return null; } diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index b80deed6f..8555176b6 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -1176,7 +1176,8 @@ function SummaryPanel({ {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( ) : null} - {summary.featureBlurbs !== undefined && summary.featureBlurbs.length > 0 ? ( + {summary.featureBlurbs !== undefined && + summary.featureBlurbs.length > 0 ? ( Here's what we set up diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index 8082fedf3..8af5b7308 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -18,11 +18,11 @@ * logs deterministic and free of carriage returns. */ -import { renderTextTable } from "../../formatters/text-table.js"; 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 { @@ -95,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 diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index 03f860504..1626d9710 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -184,6 +184,120 @@ 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("uses output.features for labels regardless of what the agent echoed in feature field", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring", "sessionReplay"], + // Agent echoed back wrong IDs + featureBlurbs: [ + { feature: "error_monitoring", blurb: "Blurb A." }, + { feature: "session-replay", blurb: "Blurb B." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + // Labels come from output.features positionally, not blurb.feature + expect(summary?.featureBlurbs?.[0]?.label).toBe("Error Monitoring"); + expect(summary?.featureBlurbs?.[1]?.label).toBe("Session Replay"); + }); + + 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-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"); + }); }); From f897e3e90c039d3d4fcbe2db8f3f8e233574f073 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 21:08:52 +0200 Subject: [PATCH 05/12] style(init): split long condition in buildSummary to satisfy Biome Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/formatters.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index 9e1418409..c280e32df 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -75,7 +75,11 @@ function buildSummary(output: WizardOutput): WizardSummary | null { }) .filter((b): b is { label: string; blurb: string } => b !== null); - if (fields.length === 0 && changedFiles.length === 0 && featureBlurbs.length === 0) { + if ( + fields.length === 0 && + changedFiles.length === 0 && + featureBlurbs.length === 0 + ) { return null; } From a6ab2065f2d9d26ba701eee85b158425ec7b826e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 21:41:45 +0200 Subject: [PATCH 06/12] test(init): cover featureBlurbs edge cases and ink-app SummaryPanel - Add snapshot test: SummaryPanel renders featureBlurbs section - Add formatters test: blurbs array shorter than features drops extras - Fix LoggingUI.summary() early-return guard to include featureBlurbs Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/lib/init/formatters.test.ts | 26 ++++++++++++++++++++++ test/lib/init/ui/ink-app.snapshot.test.tsx | 18 +++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index 1626d9710..eec8f13a1 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -271,6 +271,32 @@ describe("formatResult with featureBlurbs", () => { expect(summary?.featureBlurbs?.[1]?.label).toBe("Session Replay"); }); + test("drops entries when blurbs array is shorter than features", () => { + const { ui, calls } = createMockUI(); + formatResult( + { + status: "success", + result: { + platform: "Next.js", + features: ["errorMonitoring", "performanceMonitoring", "sessionReplay"], + // Only 2 blurbs for 3 features — third has no blurb + featureBlurbs: [ + { feature: "errorMonitoring", blurb: "Captures." }, + { feature: "performanceMonitoring", blurb: "Traces." }, + ], + }, + }, + ui + ); + + const summary = summaryCall(calls); + expect(summary?.featureBlurbs).toHaveLength(2); + expect(summary?.featureBlurbs?.map((b) => b.label)).toEqual([ + "Error Monitoring", + "Tracing", + ]); + }); + test("sorts featureBlurbs by canonical display order", () => { const { ui, calls } = createMockUI(); formatResult( diff --git a/test/lib/init/ui/ink-app.snapshot.test.tsx b/test/lib/init/ui/ink-app.snapshot.test.tsx index 3bf529cb5..807f2d2fa 100644 --- a/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -514,6 +514,24 @@ 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(); From 7d41e4318a9e4bcbb1cd1d128cfcc9f0c8cbd95e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 21:43:32 +0200 Subject: [PATCH 07/12] style(test): fix Biome line-length formatting in new test cases Co-Authored-By: Claude Sonnet 4.6 (1M context) --- test/lib/init/formatters.test.ts | 6 +++++- test/lib/init/ui/ink-app.snapshot.test.tsx | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index eec8f13a1..ac2fa29df 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -278,7 +278,11 @@ describe("formatResult with featureBlurbs", () => { status: "success", result: { platform: "Next.js", - features: ["errorMonitoring", "performanceMonitoring", "sessionReplay"], + features: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], // Only 2 blurbs for 3 features — third has no blurb featureBlurbs: [ { feature: "errorMonitoring", blurb: "Captures." }, diff --git a/test/lib/init/ui/ink-app.snapshot.test.tsx b/test/lib/init/ui/ink-app.snapshot.test.tsx index 807f2d2fa..17562d301 100644 --- a/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -519,7 +519,10 @@ describe("Ink App snapshot", () => { store.setSummary({ fields: [{ label: "Platform", value: "javascript.nextjs" }], featureBlurbs: [ - { label: "Error Monitoring", blurb: "Captures every unhandled exception." }, + { + label: "Error Monitoring", + blurb: "Captures every unhandled exception.", + }, { label: "Tracing", blurb: "Traces requests end-to-end." }, ], }); From 5fa2f8855d5af5b87091802241fc8d4e1267977e Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 21:52:30 +0200 Subject: [PATCH 08/12] =?UTF-8?q?fix(init):=20address=20warden=20review=20?= =?UTF-8?q?=E2=80=94=20Map=20lookup,=20stripAnsi,=20test=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert to Map-by-feature-ID lookup (positional matching silently assigns wrong blurbs when agent omits or reorders entries; Map skips the missing feature instead) - Strip ANSI sequences from server-supplied blurbs in buildSummary to prevent terminal injection - Update tests: wrong-ID case now expects undefined (omitted), add omitted-feature case and stripAnsi sanitization case Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/formatters.ts | 21 +++++++++++------- test/lib/init/formatters.test.ts | 37 ++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index c280e32df..8c98a017c 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -16,6 +16,7 @@ */ import { terminalLink } from "../formatters/colors.js"; +import { stripAnsi } from "../formatters/plain-detect.js"; import { featureLabel, sortFeatures } from "./clack-utils.js"; import { EXIT_DEPENDENCY_INSTALL_FAILED, @@ -62,15 +63,19 @@ function buildSummary(output: WizardOutput): WizardSummary | null { const changedFiles = output.changedFiles ?? []; - // output.features is the canonical ordered list of selected feature IDs. - // Pair blurbs positionally so labels are always correct regardless of what - // the agent echoes back in the feature field. - const blurbsInOrder = output.featureBlurbs ?? []; - const canonicalFeatures = output.features ?? []; - const featureBlurbs = sortFeatures(canonicalFeatures) + // Build a Map keyed by the feature ID the agent echoed back. Labels always + // come from featureLabel(canonicalId) so they are correct regardless of what + // the agent put in the feature field. If the agent omits or misspells a + // feature ID the blurb is simply absent for that feature — never misassigned. + const blurbMap = new Map( + (output.featureBlurbs ?? []).map(({ feature, blurb }) => [ + feature, + stripAnsi(blurb), + ]) + ); + const featureBlurbs = sortFeatures(output.features ?? []) .map((feature) => { - const pos = canonicalFeatures.indexOf(feature); - const blurb = blurbsInOrder[pos]?.blurb; + const blurb = blurbMap.get(feature); return blurb ? { label: featureLabel(feature), blurb } : null; }) .filter((b): b is { label: string; blurb: string } => b !== null); diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index ac2fa29df..e66380f7f 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -247,7 +247,7 @@ describe("formatResult with featureBlurbs", () => { expect(summary?.fields.some((f) => f.label === "Features")).toBe(true); }); - test("uses output.features for labels regardless of what the agent echoed in feature field", () => { + test("labels use canonical feature IDs — agent echoing wrong IDs omits the blurb rather than mislabelling", () => { const { ui, calls } = createMockUI(); formatResult( { @@ -255,7 +255,7 @@ describe("formatResult with featureBlurbs", () => { result: { platform: "Next.js", features: ["errorMonitoring", "sessionReplay"], - // Agent echoed back wrong IDs + // Agent echoed back wrong IDs — neither matches a canonical feature featureBlurbs: [ { feature: "error_monitoring", blurb: "Blurb A." }, { feature: "session-replay", blurb: "Blurb B." }, @@ -266,12 +266,11 @@ describe("formatResult with featureBlurbs", () => { ); const summary = summaryCall(calls); - // Labels come from output.features positionally, not blurb.feature - expect(summary?.featureBlurbs?.[0]?.label).toBe("Error Monitoring"); - expect(summary?.featureBlurbs?.[1]?.label).toBe("Session Replay"); + // Wrong IDs → no match → blurbs omitted entirely; safe fallback + expect(summary?.featureBlurbs).toBeUndefined(); }); - test("drops entries when blurbs array is shorter than features", () => { + test("drops blurb for feature the agent omitted — remaining blurbs stay correctly labelled", () => { const { ui, calls } = createMockUI(); formatResult( { @@ -283,10 +282,10 @@ describe("formatResult with featureBlurbs", () => { "performanceMonitoring", "sessionReplay", ], - // Only 2 blurbs for 3 features — third has no blurb + // Agent returned 2 of 3; skipped performanceMonitoring featureBlurbs: [ { feature: "errorMonitoring", blurb: "Captures." }, - { feature: "performanceMonitoring", blurb: "Traces." }, + { feature: "sessionReplay", blurb: "Records." }, ], }, }, @@ -297,10 +296,30 @@ describe("formatResult with featureBlurbs", () => { expect(summary?.featureBlurbs).toHaveLength(2); expect(summary?.featureBlurbs?.map((b) => b.label)).toEqual([ "Error Monitoring", - "Tracing", + "Session Replay", ]); }); + test("stripAnsi sanitizes ANSI sequences in 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("sorts featureBlurbs by canonical display order", () => { const { ui, calls } = createMockUI(); formatResult( From d19c994617f31a1886b4d3f7c785424fd8c64b60 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 22:03:12 +0200 Subject: [PATCH 09/12] =?UTF-8?q?fix(init):=20address=20cursor=20review=20?= =?UTF-8?q?=E2=80=94=20blurb=20ordering=20and=20Features=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move featureBlurbs computation before fields in buildSummary so the Features row check uses the resolved length; when the agent echoes wrong IDs all blurbs drop out and the Features row renders correctly - Move "Here's what we set up" section before "Changed files" in all three renderers (ink-report, logging-ui, ink-app) to match the intended layout: key/value → blurbs → changed files Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/init/formatters.ts | 35 +++++++++++++++++------------------ src/lib/init/ui/ink-app.tsx | 6 +++--- src/lib/init/ui/ink-report.ts | 16 ++++++++-------- src/lib/init/ui/logging-ui.ts | 20 ++++++++++---------- 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index 8c98a017c..ecc1dd8c7 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -34,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) { @@ -42,7 +58,7 @@ function buildSummary(output: WizardOutput): WizardSummary | null { if (output.projectDir) { fields.push({ label: "Directory", value: output.projectDir }); } - if (output.features?.length && !output.featureBlurbs?.length) { + if (output.features?.length && !featureBlurbs.length) { fields.push({ label: "Features", value: output.features.map(featureLabel).join(", "), @@ -63,23 +79,6 @@ function buildSummary(output: WizardOutput): WizardSummary | null { const changedFiles = output.changedFiles ?? []; - // Build a Map keyed by the feature ID the agent echoed back. Labels always - // come from featureLabel(canonicalId) so they are correct regardless of what - // the agent put in the feature field. If the agent omits or misspells a - // feature ID the blurb is simply absent for that feature — never misassigned. - 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); - if ( fields.length === 0 && changedFiles.length === 0 && diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 8555176b6..df32640a8 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -1173,9 +1173,6 @@ function SummaryPanel({ ))} ) : null} - {summary.changedFiles !== undefined && summary.changedFiles.length > 0 ? ( - - ) : null} {summary.featureBlurbs !== undefined && summary.featureBlurbs.length > 0 ? ( @@ -1198,6 +1195,9 @@ function SummaryPanel({ ))} ) : 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 85cae5ee8..53ee6975b 100644 --- a/src/lib/init/ui/ink-report.ts +++ b/src/lib/init/ui/ink-report.ts @@ -61,14 +61,6 @@ export function formatSuccessReport( lines.push(` ${label} ${field.value}`); } } - if (summary?.changedFiles && summary.changedFiles.length > 0) { - lines.push(""); - lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); - const tree = buildFileTree(summary.changedFiles); - for (const row of flattenTree(tree)) { - lines.push(formatTreeRowChalk(row)); - } - } if (summary?.featureBlurbs && summary.featureBlurbs.length > 0) { lines.push(""); lines.push(` ${chalk.hex(REPORT_MUTED).bold("Here's what we set up")}`); @@ -83,6 +75,14 @@ export function formatSuccessReport( lines.push(` ${line}`); } } + if (summary?.changedFiles && summary.changedFiles.length > 0) { + lines.push(""); + lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); + const tree = buildFileTree(summary.changedFiles); + for (const row of flattenTree(tree)) { + lines.push(formatTreeRowChalk(row)); + } + } appendFeedbackHint(lines, feedbackHint); return lines.join("\n"); } diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index 8af5b7308..b2ec786b1 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -114,16 +114,6 @@ export class LoggingUI implements WizardUI { const padded = field.label.padEnd(labelWidth); this.writeLine(this.stdout, ` ${padded} ${field.value}`); } - if (summary.changedFiles && summary.changedFiles.length > 0) { - this.writeLine(this.stdout, ""); - this.writeLine(this.stdout, " Changed files:"); - // Render as a directory tree so collapsed common prefixes match - // what the InkUI panel + post-dispose summary report show. - const tree = buildFileTree(summary.changedFiles); - for (const row of flattenTree(tree)) { - this.writeLine(this.stdout, ` ${formatTreeRowPlain(row)}`); - } - } if (summary.featureBlurbs && summary.featureBlurbs.length > 0) { this.writeLine(this.stdout, ""); this.writeLine(this.stdout, " Here's what we set up"); @@ -138,6 +128,16 @@ export class LoggingUI implements WizardUI { this.writeLine(this.stdout, ` ${line}`); } } + if (summary.changedFiles && summary.changedFiles.length > 0) { + this.writeLine(this.stdout, ""); + this.writeLine(this.stdout, " Changed files:"); + // Render as a directory tree so collapsed common prefixes match + // what the InkUI panel + post-dispose summary report show. + const tree = buildFileTree(summary.changedFiles); + for (const row of flattenTree(tree)) { + this.writeLine(this.stdout, ` ${formatTreeRowPlain(row)}`); + } + } } outro(message: string): void { From fda04819b02635b5ea3a29da120e54e944919be9 Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 22:14:05 +0200 Subject: [PATCH 10/12] fix(formatters): extend stripAnsi to cover all CSI sequences Previous regex only stripped SGR colour codes (\x1b[...m); cursor movement and screen-manipulation sequences (\x1b[2J, \x1b[A, etc.) passed through to the terminal. Extend to match any CSI sequence (\x1b[...LETTER) so server-supplied blurb strings are fully sanitised. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/formatters/plain-detect.ts | 9 +++++---- test/lib/init/formatters.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/lib/formatters/plain-detect.ts b/src/lib/formatters/plain-detect.ts index 676da9bd4..5159f257f 100644 --- a/src/lib/formatters/plain-detect.ts +++ b/src/lib/formatters/plain-detect.ts @@ -75,14 +75,15 @@ export function isPlainOutput(): boolean { /** * Strip ANSI escape sequences from a string. * - * Handles SGR codes (`\x1b[...m`) and OSC 8 terminal hyperlink sequences - * (`\x1b]8;;url\x07text\x1b]8;;\x07`). + * Handles all CSI sequences (`\x1b[...LETTER` — covers SGR colour codes, + * cursor movement, screen-clear, and other control sequences) and OSC 8 + * terminal hyperlink sequences (`\x1b]8;;url\x07text\x1b]8;;\x07`). */ 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: ANSI escape detection requires matching \x1b + .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 hyperlink sequences use \x1b and \x07 .replace(/\x1b\]8;;[^\x07]*\x07/g, "") ); diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index e66380f7f..bf92246f5 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -300,7 +300,7 @@ describe("formatResult with featureBlurbs", () => { ]); }); - test("stripAnsi sanitizes ANSI sequences in server-supplied blurbs", () => { + test("stripAnsi strips SGR colour codes from server-supplied blurbs", () => { const { ui, calls } = createMockUI(); formatResult( { @@ -320,6 +320,30 @@ describe("formatResult with featureBlurbs", () => { 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( From bb582061fe41f3716276d42bf185ea21fc29a2fd Mon Sep 17 00:00:00 2001 From: betegon Date: Tue, 19 May 2026 22:25:48 +0200 Subject: [PATCH 11/12] fix(formatters): use full CSI spec in stripAnsi to cover intermediate bytes Previous regex missed sequences with intermediate bytes (0x20-0x2F) such as \x1b[!p (Soft Terminal Reset) or \x1b[1 q (Set Cursor Style). Use the correct CSI structure: parameter bytes (0x30-0x3F) + optional intermediate bytes (0x20-0x2F) + final byte (0x40-0x7E). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/formatters/plain-detect.ts | 6 +++++- test/lib/init/formatters.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/formatters/plain-detect.ts b/src/lib/formatters/plain-detect.ts index 5159f257f..82d9beae4 100644 --- a/src/lib/formatters/plain-detect.ts +++ b/src/lib/formatters/plain-detect.ts @@ -82,8 +82,12 @@ export function isPlainOutput(): boolean { export function stripAnsi(text: string): string { return ( text + // Full CSI spec: \x1b[ + parameter bytes (0x30-0x3F) + intermediate + // bytes (0x20-0x2F, e.g. space, !, ") + final byte (0x40-0x7E). + // The narrower [0-9;?]*[A-Za-z] missed sequences with intermediate + // bytes like \x1b[!p (Soft Terminal Reset) or \x1b[1 q (cursor style). // biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection requires matching \x1b - .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") + .replace(/\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g, "") // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 hyperlink sequences use \x1b and \x07 .replace(/\x1b\]8;;[^\x07]*\x07/g, "") ); diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index bf92246f5..64239e09b 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -320,6 +320,31 @@ describe("formatResult with featureBlurbs", () => { 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 non-SGR CSI sequences (cursor movement, screen-clear) from blurbs", () => { const { ui, calls } = createMockUI(); formatResult( From 0b2b30d79982432d5d769a9426dc9ac679fe9f55 Mon Sep 17 00:00:00 2001 From: betegon Date: Wed, 20 May 2026 09:16:52 +0200 Subject: [PATCH 12/12] fix(formatters): extend stripAnsi to cover OSC, DCS, and all ESC sequences Previous version only handled CSI and OSC 8 hyperlinks. Adds: - Arbitrary OSC sequences (\x1b]...\x07 / \x1b]\...\x1b\) e.g. window-title changes (\x1b]0;title\x07) - DCS sequences (\x1bP...\x1b\) - All two-char ESC sequences C1 (0x40-0x5F) and Fs (0x60-0x7E) e.g. terminal reset (\x1bc) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/lib/formatters/plain-detect.ts | 27 +++++++++++------- test/lib/init/formatters.test.ts | 45 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/src/lib/formatters/plain-detect.ts b/src/lib/formatters/plain-detect.ts index 82d9beae4..c5a101340 100644 --- a/src/lib/formatters/plain-detect.ts +++ b/src/lib/formatters/plain-detect.ts @@ -73,22 +73,29 @@ export function isPlainOutput(): boolean { } /** - * Strip ANSI escape sequences from a string. + * Strip ANSI/VT escape sequences from a string. * - * Handles all CSI sequences (`\x1b[...LETTER` — covers SGR colour codes, - * cursor movement, screen-clear, and other control sequences) 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 - // Full CSI spec: \x1b[ + parameter bytes (0x30-0x3F) + intermediate - // bytes (0x20-0x2F, e.g. space, !, ") + final byte (0x40-0x7E). - // The narrower [0-9;?]*[A-Za-z] missed sequences with intermediate - // bytes like \x1b[!p (Soft Terminal Reset) or \x1b[1 q (cursor style). + // 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, "") - // biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 hyperlink sequences use \x1b and \x07 - .replace(/\x1b\]8;;[^\x07]*\x07/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/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index 64239e09b..723573332 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -345,6 +345,51 @@ describe("formatResult with featureBlurbs", () => { 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(