diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index ac0f43100..ba3885097 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -56,7 +56,6 @@ Path-like arguments (starting with `.`, `/`, or `~`) are always treated as the d | `sourcemaps` | Source map uploads | | `crons` | Cron job monitoring | | `ai-monitoring` | AI/LLM monitoring | -| `user-feedback` | User feedback widget | ## What the Wizard Does diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md index 5f9b1d062..217cee1b1 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -18,7 +18,7 @@ Initialize Sentry in your project (experimental) **Flags:** - `-y, --yes - Accept non-interactive defaults (requires --features outside a TTY)` - `-n, --dry-run - Show what would happen without making changes` -- `--features ... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback` +- `--features ... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring` - `-t, --team - Team slug to create the project under` - `--app - App to initialize in a monorepo (required with --yes when multiple apps are detected)` - `--tui - Use the Ink-based interactive UI (default). Pass --no-tui to fall back to plain log output.` diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6e4ecebd6..5501596fc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -58,8 +58,6 @@ const FEATURE_ALIASES = { crons: "crons", "ai-monitoring": "aiMonitoring", aiMonitoring: "aiMonitoring", - "user-feedback": "userFeedback", - userFeedback: "userFeedback", } as const; const SUPPORTED_FEATURE_NAMES = [ @@ -72,7 +70,6 @@ const SUPPORTED_FEATURE_NAMES = [ "sourcemaps", "crons", "ai-monitoring", - "user-feedback", ] as const; const SUPPORTED_FEATURE_TEXT = SUPPORTED_FEATURE_NAMES.join(", "); @@ -337,7 +334,7 @@ export const initCommand = buildCommand< kind: "parsed", parse: String, brief: - "Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback", + "Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring", variadic: true, optional: true, }, diff --git a/packages/cli/src/lib/init/clack-utils.ts b/packages/cli/src/lib/init/clack-utils.ts index 2f190c436..8ba347fc2 100644 --- a/packages/cli/src/lib/init/clack-utils.ts +++ b/packages/cli/src/lib/init/clack-utils.ts @@ -34,44 +34,62 @@ export function abortIfCancelled(value: T): Exclude { return value as Exclude; } -const FEATURE_INFO: Record = { +const FEATURE_INFO: Record = { errorMonitoring: { label: "Error Monitoring", - hint: "Group exceptions into issues with context", + description: "Automatically capture exceptions and stack traces", }, performanceMonitoring: { label: "Tracing", - hint: "See request paths, spans, and bottlenecks", + description: + "Find bottlenecks, broken requests, and understand application flow end-to-end", }, sessionReplay: { label: "Session Replay", - hint: "Replay sessions linked to errors", + description: "Watch real user sessions to see what went wrong", }, profiling: { label: "Profiling", - hint: "Find CPU-heavy functions in production", + description: + "Pinpoint the functions and lines of code responsible for performance issues", + }, + logs: { + label: "Logging", + description: "See logs in context with errors and performance issues", + }, + metrics: { + label: "Application Metrics", + description: + "Track application performance and usage over time with custom metrics", }, - logs: { label: "Logging", hint: "Search logs beside errors and traces" }, - metrics: { label: "Metrics", hint: "Track custom measurements over time" }, sourceMaps: { label: "Source Maps", - hint: "Turn minified stacks into your source", + description: + "Turn minified production stack traces back into your original source code", }, crons: { label: "Crons", - hint: "Alert on failed or missed scheduled jobs", + description: "Detect failed, missed, or delayed scheduled jobs", }, aiMonitoring: { label: "AI Monitoring", - hint: "Track AI calls, latency, cost, and failures", + description: + "Understand AI calls, latency, token usage, cost, and failures", + }, + mcpObservability: { + label: "MCP Observability", + description: + "Trace MCP tool calls and understand failures across agent workflows", }, userFeedback: { label: "User Feedback", - hint: "Collect user reports with issue context", + description: + "Collect user reports with the error and session context needed to investigate", }, reactFeatures: { label: "React Features", - hint: "Add React-specific context and integrations", + description: + "Capture React-specific errors with component and rendering context", }, }; @@ -79,8 +97,9 @@ export function featureLabel(id: string): string { return FEATURE_INFO[id]?.label ?? id; } -export function featureHint(id: string): string | undefined { - return FEATURE_INFO[id]?.hint; +/** Returns product-oriented supporting copy for a known feature. */ +export function featureDescription(id: string): string | undefined { + return FEATURE_INFO[id]?.description; } const FEATURE_DISPLAY_ORDER = [ @@ -93,11 +112,12 @@ const FEATURE_DISPLAY_ORDER = [ "sourceMaps", "crons", "aiMonitoring", + "mcpObservability", "userFeedback", "reactFeatures", ]; -/** Sort features into canonical display order for the multi-select prompt. */ +/** Sort features into the canonical order used by summaries and final output. */ export function sortFeatures(features: string[]): string[] { return features.slice().sort((a, b) => { const ai = FEATURE_DISPLAY_ORDER.indexOf(a); diff --git a/packages/cli/src/lib/init/interactive.ts b/packages/cli/src/lib/init/interactive.ts index 46d0eceed..acd8ff66b 100644 --- a/packages/cli/src/lib/init/interactive.ts +++ b/packages/cli/src/lib/init/interactive.ts @@ -11,13 +11,11 @@ */ import { setTag } from "@sentry/node-core/light"; -import chalk from "chalk"; import { WizardError } from "../errors.js"; import { abortIfCancelled, - featureHint, + featureDescription, featureLabel, - sortFeatures, } from "./clack-utils.js"; import { REQUIRED_FEATURE } from "./constants.js"; import type { @@ -27,18 +25,67 @@ import type { MultiSelectPayload, SelectPayload, } from "./types.js"; -import type { WizardUI } from "./ui/types.js"; +import type { PromptDetail, WizardUI } from "./ui/types.js"; -function prependRequiredFeature( - features: string[], - hasRequired: boolean -): string[] { - if (!(hasRequired && !features.includes(REQUIRED_FEATURE))) { +function prependRequiredFeature(features: string[]): string[] { + if (features.includes(REQUIRED_FEATURE)) { return features; } return [REQUIRED_FEATURE, ...features]; } +type FeatureReviewAction = "continue" | "back"; + +const DEFAULT_FEATURE_ORDER = [ + REQUIRED_FEATURE, + "logs", + "sessionReplay", + "performanceMonitoring", +] as const; +const DEFAULT_FEATURES = new Set(DEFAULT_FEATURE_ORDER); +const DEFAULT_FEATURE_RANK = new Map( + DEFAULT_FEATURE_ORDER.map((feature, index) => [feature, index]) +); +// Feedback setup needs an in-app placement choice this wizard cannot make yet. +const UNSUPPORTED_INIT_FEATURES = new Set(["userFeedback"]); +const FEATURE_SELECTION_CONTEXT = + "Based on your project, these features are available to set up."; + +function sortFeatureOptions(features: string[]): string[] { + return features.slice().sort((a, b) => { + const rankDifference = + (DEFAULT_FEATURE_RANK.get(a) ?? Number.MAX_SAFE_INTEGER) - + (DEFAULT_FEATURE_RANK.get(b) ?? Number.MAX_SAFE_INTEGER); + if (rankDifference !== 0) { + return rankDifference; + } + return featureLabel(a).localeCompare(featureLabel(b), "en"); + }); +} + +function normalizeFeatureSelection(features: string[]): string[] { + const normalized = new Set(features); + // The server enforces this dependency too; normalizing before review keeps + // the reviewed, resumed, and restored-on-Back selections identical. + if ( + features.includes("aiMonitoring") || + features.includes("mcpObservability") + ) { + normalized.add("performanceMonitoring"); + } + return sortFeatureOptions([...normalized]); +} + +function buildFeatureReviewDetails(features: string[]): PromptDetail[] { + return [ + { text: "We'll add these features:" }, + ...features.map((feature) => ({ + text: `✓ ${featureLabel(feature)}`, + tone: "success" as const, + })), + ]; +} + export async function handleInteractive( payload: InteractivePayload, options: InteractiveContext, @@ -163,13 +210,11 @@ async function handleMultiSelect( options: InteractiveContext, ui: WizardUI ): Promise> { - const available = payload.availableFeatures ?? payload.options ?? []; - - if (available.length === 0) { - return { features: [] }; - } - - const hasRequired = available.includes(REQUIRED_FEATURE); + const available = prependRequiredFeature( + (payload.availableFeatures ?? payload.options ?? []).filter( + (feature) => !UNSUPPORTED_INIT_FEATURES.has(feature) + ) + ); if (options.yes) { ui.log.info( @@ -178,46 +223,50 @@ async function handleMultiSelect( return { features: available }; } - const optional = sortFeatures( - available.filter((f) => f !== REQUIRED_FEATURE) + const sorted = sortFeatureOptions(available); + setTag("wizard.features.offered", available.join(",")); + let initialValues: string[] = sorted.filter((feature) => + DEFAULT_FEATURES.has(feature) ); - if (optional.length === 0) { - if (hasRequired) { - ui.log.info(`${featureLabel(REQUIRED_FEATURE)} is always included.`); - } - return { features: hasRequired ? [REQUIRED_FEATURE] : [] }; - } - - const hints: string[] = []; - // Use clack's vertical bar character so hint lines align with the option lines below - const bar = chalk.gray("\u2502"); - if (hasRequired) { - hints.push( - `${bar} ${chalk.dim(`${featureLabel(REQUIRED_FEATURE)} is always included`)}` - ); - } - hints.push(`${bar} ${chalk.dim("space=toggle, a=all, enter=confirm")}`); + while (true) { + const selected = await ui.multiselect({ + message: payload.prompt, + details: [{ text: FEATURE_SELECTION_CONTEXT }], + options: sorted.map((feature) => { + const description = featureDescription(feature); + return { + value: feature, + label: featureLabel(feature), + ...(description ? { description } : {}), + ...(feature === REQUIRED_FEATURE ? { locked: true } : {}), + }; + }), + initialValues, + required: false, + }); - setTag("wizard.features.offered", available.join(",")); - const selected = await ui.multiselect({ - message: `${payload.prompt}\n${hints.join("\n")}`, - options: optional.map((feature) => { - const hint = featureHint(feature); - return { - value: feature, - label: featureLabel(feature), - ...(hint ? { hint } : {}), - }; - }), - initialValues: optional.filter((f) => f === "performanceMonitoring"), - required: false, - }); + const chosen = abortIfCancelled(selected); + const features = normalizeFeatureSelection(prependRequiredFeature(chosen)); + const review = await ui.select({ + message: "Review your Sentry setup", + details: buildFeatureReviewDetails(features), + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialValue: "continue", + }); - const chosen = abortIfCancelled(selected); - const features = prependRequiredFeature(chosen, hasRequired); - setTag("wizard.features.selected", features.join(",")); - return { features }; + if (abortIfCancelled(review) === "continue") { + setTag("wizard.features.selected", features.join(",")); + return { features }; + } + initialValues = features; + } } async function handleConfirm( diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index 1c3436b66..809df3eb0 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -64,7 +64,7 @@ import { } from "./ink-shortcuts.js"; import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js"; import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; -import type { WizardSummary } from "./types.js"; +import type { PromptDetail, WizardSummary } from "./types.js"; import type { ActivePrompt, FileReadEntry, @@ -288,6 +288,7 @@ function AppBody({ store }: AppProps): React.ReactNode { prompt={snapshot.prompt} spinner={snapshot.spinner} summary={snapshot.summary} + terminalRows={rows} /> ) : ( log.severity === "warn" || log.severity === "error" - ); + const promptLogs = logs.filter( + (log) => log.severity === "warn" || log.severity === "error" + ); + // The shortest supported frame can keep one actionable log above a prompt; + // roomier terminals retain the complete warning/error history. + let visibleLogs = logs; + if (prompt) { + visibleLogs = terminalRows <= 16 ? promptLogs.slice(-1) : promptLogs; + } const hasContent = visibleLogs.length > 0 || spinner.active || @@ -1326,13 +1332,15 @@ function getCenteredSelectLayout( } /** - * Rows unavailable to option lists: workflow chrome reserves the tab/shortcut - * footers, while centered prompts also reserve intro padding, the full banner, - * and the extra controls shown by multiselect prompts. + * Rows unavailable to option lists: workflow chrome reserves the tab and + * shortcut footers, multiselects add their local controls, and centered + * prompts additionally reserve intro padding and the full banner. */ const WORKFLOW_PROMPT_RESERVED_ROWS = 10; +const WORKFLOW_MULTISELECT_RESERVED_ROWS = 12; const CENTERED_SELECT_RESERVED_ROWS = 20; const CENTERED_MULTISELECT_RESERVED_ROWS = 23; +const WORKFLOW_ACTIVITY_RESERVED_ROWS = 7; /** * Returns the half-open option range that keeps the highlighted item visible. @@ -1364,7 +1372,7 @@ export function getOptionWindow( return [start, start + viewportSize]; } -function getPromptOptionLimit({ +function getPromptOptionCapacity({ terminalRows, alignment, kind, @@ -1377,7 +1385,10 @@ function getPromptOptionLimit({ occupiedRows: number; messageRows: number; }): number { - let reservedRows = WORKFLOW_PROMPT_RESERVED_ROWS; + let reservedRows = + kind === "multiselect" + ? WORKFLOW_MULTISELECT_RESERVED_ROWS + : WORKFLOW_PROMPT_RESERVED_ROWS; if (alignment === "center") { reservedRows = kind === "multiselect" @@ -1385,10 +1396,68 @@ function getPromptOptionLimit({ : CENTERED_SELECT_RESERVED_ROWS; } const extraMessageRows = Math.max(0, messageRows - 1); - return Math.max( + return terminalRows - reservedRows - occupiedRows - extraMessageRows; +} + +function getPromptOptionLimit( + args: Parameters[0] +): number { + return Math.max(1, getPromptOptionCapacity(args)); +} + +/** + * Locked rows stay pinned, while the selectable viewport budgets for its + * tallest wrapped description so changing the cursor cannot overflow it. + */ +function getMultiSelectLayout({ + options, + availableRows, + terminalColumns, + alignment, +}: { + options: MultiSelectPromptOptionData[]; + availableRows: number; + terminalColumns: number; + alignment: PromptAlignment; +}): { maxVisibleOptions: number; showDescriptions: boolean } { + const descriptionWidth = Math.max( 1, - terminalRows - reservedRows - occupiedRows - extraMessageRows + getPromptContentWidth(terminalColumns, alignment) - + (alignment === "center" ? 4 : 5) + ); + const optionRows = options.map((option) => { + if (!option.description) { + return 1; + } + const descriptionRows = wrapAnsi(option.description, descriptionWidth, { + hard: true, + trim: false, + wordWrap: true, + }).split("\n").length; + return 1 + descriptionRows; + }); + const lockedRows = optionRows.reduce( + (total, rows, index) => (options[index]?.locked ? total + rows : total), + 0 ); + const selectableRows = optionRows.filter( + (_rows, index) => !options[index]?.locked + ); + const tallestSelectable = Math.max(1, ...selectableRows); + if (lockedRows + tallestSelectable > availableRows) { + const lockedCount = options.filter((option) => option.locked).length; + return { + maxVisibleOptions: Math.max(1, availableRows - lockedCount), + showDescriptions: false, + }; + } + return { + maxVisibleOptions: Math.max( + 1, + Math.floor(Math.max(0, availableRows - lockedRows) / tallestSelectable) + ), + showDescriptions: true, + }; } function getPromptContentWidth( @@ -1402,6 +1471,94 @@ function getPromptContentWidth( return frameWidth >= 80 ? Math.floor((frameWidth - 1) * 0.6) : frameWidth; } +function getPromptTextRows(text: string, availableWidth: number): number { + return wrapAnsi(text, Math.max(1, availableWidth), { + hard: true, + trim: false, + wordWrap: true, + }).split("\n").length; +} + +function getPromptDetailWidth( + terminalColumns: number, + alignment: PromptAlignment +): number { + const leadingWidth = alignment === "start" ? 3 : 0; + return Math.max( + 1, + getPromptContentWidth(terminalColumns, alignment) - leadingWidth + ); +} + +function getPromptDetailsRows( + details: readonly PromptDetail[], + availableWidth: number +): number { + return details.reduce( + (rows, detail) => rows + getPromptTextRows(detail.text, availableWidth), + 0 + ); +} + +function getStructuredSelectLayout({ + alignment, + details, + footer, + occupiedRows, + optionCount, + promptTitle, + terminalColumns, + terminalRows, +}: { + alignment: PromptAlignment; + details: readonly PromptDetail[]; + footer?: PromptDetail; + occupiedRows: number; + optionCount: number; + promptTitle: string; + terminalColumns: number; + terminalRows: number; +}): { + compact: boolean; + detailWidth: number; + maxVisibleDetailRows: number; + maxVisibleOptions: number; +} { + const detailWidth = getPromptDetailWidth(terminalColumns, alignment); + const titleRows = getPromptTextRows(promptTitle, detailWidth); + const detailRows = getPromptDetailsRows(details, detailWidth); + const footerRows = footer ? getPromptTextRows(footer.text, detailWidth) : 0; + const optionRows = Math.max(1, optionCount); + const minimumOptionRows = + details.length > 0 && optionCount <= 2 ? optionRows : 1; + const availableRows = Math.max( + 1, + terminalRows - WORKFLOW_ACTIVITY_RESERVED_ROWS - occupiedRows + ); + const naturalSpacingRows = 2 + (footer ? 1 : 0); + const naturalRows = + titleRows + detailRows + footerRows + optionRows + naturalSpacingRows; + + if (naturalRows <= availableRows) { + return { + compact: false, + detailWidth, + maxVisibleDetailRows: detailRows + footerRows, + maxVisibleOptions: optionRows, + }; + } + + return { + compact: true, + detailWidth, + maxVisibleDetailRows: Math.max( + 0, + availableRows - titleRows - minimumOptionRows + ), + maxVisibleOptions: minimumOptionRows, + }; +} + function getPromptMessageRows({ message, terminalColumns, @@ -1436,6 +1593,368 @@ function getPromptMessageRows({ }).split("\n").length; } +function PromptDetails({ + alignment, + details, +}: { + alignment: PromptAlignment; + details: PromptDetail[]; +}): React.ReactNode { + const layoutProps = + alignment === "center" + ? ({ justifyContent: "center", width: "100%" } as const) + : ({ paddingLeft: 3 } as const); + return details.map((detail, index) => { + const color = detail.tone === "success" ? COLOR_SUCCESS : MUTED; + return ( + + {detail.text} + + ); + }); +} + +function normalizeDetailWindowStart(start: number, itemCount: number): number { + return Math.min(Math.max(0, start), Math.max(0, itemCount - 1)); +} + +function getDetailWindowEnd( + start: number, + itemRows: readonly number[], + rowBudget: number +): number { + let usedRows = 0; + let end = normalizeDetailWindowStart(start, itemRows.length); + while (end < itemRows.length) { + const nextRows = itemRows[end] ?? 1; + if (usedRows + nextRows > rowBudget) { + break; + } + usedRows += nextRows; + end += 1; + } + return end; +} + +function getPreviousDetailWindowStart( + start: number, + itemRows: readonly number[], + rowBudget: number +): number { + let previousStart = normalizeDetailWindowStart(start, itemRows.length); + let usedRows = 0; + while (previousStart > 0) { + const nextRows = itemRows[previousStart - 1] ?? 1; + if (usedRows + nextRows > rowBudget) { + break; + } + usedRows += nextRows; + previousStart -= 1; + } + return previousStart; +} + +function getNextDetailWindowStart( + start: number, + itemRows: readonly number[], + rowBudget: number +): number { + const currentStart = normalizeDetailWindowStart(start, itemRows.length); + const windowEnd = getDetailWindowEnd(currentStart, itemRows, rowBudget); + const nextStart = windowEnd === currentStart ? currentStart + 1 : windowEnd; + return normalizeDetailWindowStart(nextStart, itemRows.length); +} + +/** + * Keeps the first detail pinned and pages the remaining details by their + * wrapped row cost, reserving space for the footer and page indicator. + */ +function usePromptDetailWindow( + promptDetails: PromptDetail[], + maxVisibleRows: number, + detailWidth: number, + footer?: PromptDetail +): { + detailShortcut: ShortcutBinding | null; + isWindowed: boolean; + visibleDetails: PromptDetail[]; +} { + const detailLayout = useMemo(() => { + const header = promptDetails[0]; + const items = header ? promptDetails.slice(1) : promptDetails; + return { + header, + headerRows: header ? getPromptTextRows(header.text, detailWidth) : 0, + itemRows: items.map((detail) => + getPromptTextRows(detail.text, detailWidth) + ), + items, + }; + }, [detailWidth, promptDetails]); + const footerRows = footer ? getPromptTextRows(footer.text, detailWidth) : 0; + const allDetailRows = + detailLayout.headerRows + + detailLayout.itemRows.reduce((total, rows) => total + rows, 0) + + footerRows; + const isWindowed = + allDetailRows > maxVisibleRows && detailLayout.items.length > 0; + const indicatorRows = isWindowed + ? getPromptTextRows( + `${detailLayout.items.length}-${detailLayout.items.length}/${detailLayout.items.length} · pgup/pgdn`, + detailWidth + ) + : 0; + const itemRowBudget = Math.max( + 0, + maxVisibleRows - detailLayout.headerRows - footerRows - indicatorRows + ); + const [windowStart, setWindowStart] = useState(0); + const normalizedStart = normalizeDetailWindowStart( + windowStart, + detailLayout.items.length + ); + const windowEnd = getDetailWindowEnd( + normalizedStart, + detailLayout.itemRows, + itemRowBudget + ); + const visibleDetails = isWindowed + ? [ + ...(detailLayout.header === undefined ? [] : [detailLayout.header]), + ...detailLayout.items.slice(normalizedStart, windowEnd), + { + text: `${normalizedStart + 1}-${windowEnd}/${detailLayout.items.length} · pgup/pgdn`, + }, + ] + : promptDetails; + const detailShortcut = useMemo(() => { + if (!isWindowed) { + return null; + } + return { + key: "pgup/pgdn", + action: "review features", + priority: 41, + showInFooter: false, + match: (_input, key) => key.pageUp || key.pageDown, + run: (_input, key) => { + const moveWindow = key.pageUp + ? getPreviousDetailWindowStart + : getNextDetailWindowStart; + setWindowStart((start) => + moveWindow(start, detailLayout.itemRows, itemRowBudget) + ); + }, + }; + }, [detailLayout, isWindowed, itemRowBudget]); + + return { detailShortcut, isWindowed, visibleDetails }; +} + +function PromptFooter({ + alignment, + compact, + detail, +}: { + alignment: PromptAlignment; + compact: boolean; + detail?: PromptDetail; +}): React.ReactNode { + if (!detail) { + return null; + } + return ( + + + + ); +} + +function SelectPromptHeader({ + alignment, + compact, + detailsAreWindowed, + footer, + isOptionWindowed, + highlighted, + promptTitle, + totalCount, + visiblePromptDetails, +}: { + alignment: PromptAlignment; + compact: boolean; + detailsAreWindowed: boolean; + footer?: PromptDetail; + isOptionWindowed: boolean; + highlighted: number; + promptTitle: string; + totalCount: number; + visiblePromptDetails: PromptDetail[]; +}): React.ReactNode { + const position = isOptionWindowed ? ( + + ({highlighted + 1}/{totalCount}) + + ) : null; + const detailRows = ( + <> + + + + ); + + if (alignment === "center") { + return ( + + + {promptTitle} + {position} + + {detailRows} + + ); + } + + return ( + + + + + {ICONS.diamondOpen} + + + {promptTitle} + {position} + + {detailRows} + + ); +} + +function SelectPromptArea({ + alignment, + occupiedRows, + prompt, +}: { + alignment: PromptAlignment; + occupiedRows: number; + prompt: Extract; +}): React.ReactNode { + const { columns, rows } = useInkFrameSize(); + const hasStructuredCopy = + (prompt.details?.length ?? 0) > 0 || prompt.footer !== undefined; + if (alignment === "start" && hasStructuredCopy) { + const layout = getStructuredSelectLayout({ + alignment, + details: prompt.details ?? [], + footer: prompt.footer, + occupiedRows, + optionCount: prompt.options.length, + promptTitle: prompt.message, + terminalColumns: columns, + terminalRows: rows, + }); + return ( + + ); + } + + const promptMessage = [ + prompt.message, + ...(prompt.details ?? []).map((detail) => detail.text), + ...(prompt.footer ? ["", prompt.footer.text] : []), + ].join("\n"); + const messageRows = getPromptMessageRows({ + message: promptMessage, + terminalColumns: columns, + alignment, + kind: prompt.kind, + totalCount: prompt.options.length, + }); + const optionCapacity = getPromptOptionCapacity({ + terminalRows: rows, + alignment, + kind: prompt.kind, + occupiedRows, + messageRows, + }); + const optionLimit = Math.max(1, optionCapacity); + const minimumVisibleOptions = + (prompt.details?.length ?? 0) > 0 && prompt.options.length <= 2 + ? prompt.options.length + : 1; + const maxVisibleDetailRows = Math.max( + 0, + messageRows - 1 - Math.max(0, minimumVisibleOptions - optionCapacity) + ); + return ( + + ); +} + +function MultiSelectPromptArea({ + alignment, + occupiedRows, + prompt, +}: { + alignment: PromptAlignment; + occupiedRows: number; + prompt: Extract; +}): React.ReactNode { + const { columns, rows } = useInkFrameSize(); + const promptMessage = [ + prompt.message, + ...(prompt.details ?? []).map((detail) => detail.text), + ...((prompt.details?.length ?? 0) > 0 ? [""] : []), + ].join("\n"); + const messageRows = getPromptMessageRows({ + message: promptMessage, + terminalColumns: columns, + alignment, + kind: prompt.kind, + totalCount: prompt.options.length, + }); + const availableRows = getPromptOptionLimit({ + terminalRows: rows, + alignment, + kind: prompt.kind, + occupiedRows, + messageRows, + }); + const multiselectLayout = getMultiSelectLayout({ + options: prompt.options, + availableRows, + terminalColumns: columns, + alignment, + }); + return ( + + ); +} + function PromptArea({ alignment = "start", occupiedRows = 0, @@ -1445,68 +1964,59 @@ function PromptArea({ occupiedRows?: number; prompt: ActivePrompt; }): React.ReactNode { - const { columns, rows } = useInkFrameSize(); if (prompt.kind === "select") { - const messageRows = getPromptMessageRows({ - message: prompt.message, - terminalColumns: columns, - alignment, - kind: prompt.kind, - totalCount: prompt.options.length, - }); return ( - ); } - if (prompt.kind === "confirm") { - return ; - } if (prompt.kind === "multiselect") { - const messageRows = getPromptMessageRows({ - message: prompt.message, - terminalColumns: columns, - alignment, - kind: prompt.kind, - totalCount: prompt.options.length, - }); return ( - ); } + if (prompt.kind === "confirm") { + return ; + } return null; } function SelectPrompt({ alignment, + compact, + detailWidth, + maxVisibleDetailRows, maxVisibleOptions, prompt, }: { alignment: PromptAlignment; + compact: boolean; + detailWidth: number; + maxVisibleDetailRows: number; maxVisibleOptions: number; prompt: Extract; }): React.ReactNode { const isCentered = alignment === "center"; const promptWidth = isCentered ? "100%" : undefined; + const promptTitle = prompt.message; + const promptDetails = prompt.details ?? []; + const { + detailShortcut, + isWindowed: detailsAreWindowed, + visibleDetails: visiblePromptDetails, + } = usePromptDetailWindow( + promptDetails, + maxVisibleDetailRows, + detailWidth, + prompt.footer + ); const { columns } = useInkFrameSize(); const centeredLayout = isCentered ? getCenteredSelectLayout(prompt.options) @@ -1541,10 +2051,11 @@ function SelectPrompt({ setHighlighted((idx) => (idx + 1) % totalCount); }, }, + ...(detailShortcut ? [detailShortcut] : []), { key: "enter", action: "confirm", - priority: 41, + priority: 42, match: (_input, key) => key.return, run: () => { const current = prompt.options[highlighted]; @@ -1556,12 +2067,12 @@ function SelectPrompt({ { key: "esc", action: "cancel", - priority: 42, + priority: 43, match: (input, key) => key.escape || (key.ctrl && input === "c"), run: () => prompt.resolve(null), }, ], - [highlighted, prompt, totalCount] + [detailShortcut, highlighted, prompt, totalCount] ); useInkShortcuts("select-prompt", shortcuts); @@ -1569,31 +2080,20 @@ function SelectPrompt({ - {isCentered ? ( - - {prompt.message} - {isWindowed ? ( - - ({highlighted + 1}/{totalCount}) - - ) : null} - - ) : ( - - - {ICONS.diamondOpen} - - {prompt.message} - {isWindowed ? ( - - ({highlighted + 1}/{totalCount}) - - ) : null} - - )} + ) : ( - - - {ICONS.diamondOpen} - + + + + {ICONS.diamondOpen} + + {prompt.message} ({yLabel}/{nLabel}) @@ -1735,29 +2237,53 @@ function MultiSelectPrompt({ alignment, maxVisibleOptions, prompt, + showDescriptions, }: { alignment: PromptAlignment; maxVisibleOptions: number; prompt: Extract; + showDescriptions: boolean; }): React.ReactNode { const isCentered = alignment === "center"; const promptWidth = isCentered ? "100%" : undefined; + const promptTitle = prompt.message; + const promptDetails = prompt.details ?? []; + const lockedOptions = useMemo( + () => prompt.options.filter((option) => option.locked), + [prompt.options] + ); + const selectableOptions = useMemo( + () => prompt.options.filter((option) => !option.locked), + [prompt.options] + ); const [selected, setSelected] = useState>( - () => new Set(prompt.initialSelected) + () => + new Set([ + ...prompt.initialSelected, + ...prompt.options + .filter((option) => option.locked) + .map((option) => option.value), + ]) ); - const [highlighted, setHighlighted] = useState(0); + const [highlighted, setHighlighted] = useState(() => { + const firstUnselected = selectableOptions.findIndex( + (option) => !prompt.initialSelected.includes(option.value) + ); + return Math.max(0, firstUnselected); + }); const totalCount = prompt.options.length; + const selectableCount = selectableOptions.length; const [windowStart, windowEnd] = getOptionWindow( - totalCount, + selectableCount, highlighted, maxVisibleOptions ); - const visibleOptions = prompt.options.slice(windowStart, windowEnd); - const isWindowed = visibleOptions.length < totalCount; + const visibleOptions = selectableOptions.slice(windowStart, windowEnd); + const isWindowed = visibleOptions.length < selectableCount; const toggleAt = useCallback( (idx: number) => { - const current = prompt.options[idx]; + const current = selectableOptions[idx]; if (!current) { return; } @@ -1771,7 +2297,7 @@ function MultiSelectPrompt({ return next; }); }, - [prompt.options] + [selectableOptions] ); const commit = useCallback(() => { @@ -1792,11 +2318,16 @@ function MultiSelectPrompt({ priority: 40, match: (_input, key) => key.upArrow || key.downArrow, run: (_input, key) => { + if (selectableCount === 0) { + return; + } if (key.upArrow) { - setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + setHighlighted((idx) => + idx === 0 ? selectableCount - 1 : idx - 1 + ); return; } - setHighlighted((idx) => (idx + 1) % totalCount); + setHighlighted((idx) => (idx + 1) % selectableCount); }, }, { @@ -1813,8 +2344,12 @@ function MultiSelectPrompt({ match: (input) => input === "a", run: () => { setSelected((prev) => { - if (prev.size === totalCount) { - return new Set(); + const lockedValues = lockedOptions.map((option) => option.value); + const allSelectableSelected = selectableOptions.every((option) => + prev.has(option.value) + ); + if (allSelectableSelected) { + return new Set(lockedValues); } return new Set(prompt.options.map((option) => option.value)); }); @@ -1822,7 +2357,7 @@ function MultiSelectPrompt({ }, { key: "enter", - action: "confirm", + action: "continue", priority: 43, match: (_input, key) => key.return, run: commit, @@ -1835,12 +2370,20 @@ function MultiSelectPrompt({ run: () => prompt.resolve(null), }, ], - [commit, highlighted, prompt, toggleAt, totalCount] + [ + commit, + highlighted, + lockedOptions, + prompt, + selectableCount, + selectableOptions, + toggleAt, + ] ); useInkShortcuts("multiselect-prompt", shortcuts); - const shortcutText = `space toggle ${ICONS.bullet} a all ${ICONS.bullet} enter confirm ${ICONS.bullet} esc cancel`; + const shortcutText = `↑↓ move ${ICONS.bullet} space toggle ${ICONS.bullet} a all ${ICONS.bullet} enter continue`; const selectedCount = isWindowed - ? `${selected.size}/${totalCount} selected ${ICONS.bullet} ${highlighted + 1}/${totalCount}` + ? `${selected.size}/${totalCount} selected ${ICONS.bullet} ${highlighted + 1}/${selectableCount}` : `${selected.size}/${totalCount}`; return ( @@ -1851,32 +2394,43 @@ function MultiSelectPrompt({ width={promptWidth} > {isCentered ? ( - - {prompt.message} + + + {promptTitle} + + ) : ( - - - - {ICONS.diamondOpen} - - {prompt.message} + + + + + + {ICONS.diamondOpen} + + + {promptTitle} + + {selectedCount} - {selectedCount} + )} - {isCentered ? ( - - {shortcutText} - {selectedCount} - - ) : null} - + 0 ? 1 : 0} + width={promptWidth} + > + {lockedOptions.map((option) => ( + + ))} {visibleOptions.map((option, visibleIndex) => { const idx = windowStart + visibleIndex; const isSelected = selected.has(option.value); @@ -1888,56 +2442,135 @@ function MultiSelectPrompt({ isSelected={isSelected} key={option.value} option={option} + showDescription={showDescriptions} /> ); })} + {isCentered ? ( + + {shortcutText} + {selectedCount} + + ) : ( + + {shortcutText} + + )} ); } +function getLockedOptionHint( + option: MultiSelectPromptOptionData, + availableWidth: number +): string { + if (!option.locked) { + return ""; + } + const lockedHint = " (always included)"; + const optionHint = option.hint ? ` ${option.hint}` : ""; + return stringWidth(`${option.label}${optionHint}${lockedHint}`) <= + availableWidth + ? lockedHint + : ""; +} + +function MultiSelectOptionLabel({ + isCursor, + lockedHint, + option, +}: { + isCursor: boolean; + lockedHint: string; + option: MultiSelectPromptOptionData; +}): React.ReactNode { + const labelColor = option.locked ? MUTED : undefined; + return ( + <> + + {option.label} + + {option.hint ? {option.hint} : null} + {lockedHint} + + ); +} + function MultiSelectPromptOptionRow({ centered, isCursor, isSelected, option, + showDescription, }: { centered: boolean; isCursor: boolean; isSelected: boolean; option: MultiSelectPromptOptionData; + showDescription: boolean; }): React.ReactNode { const marker = isSelected ? ICONS.squareFilled : ICONS.squareOpen; const markerColor = isSelected ? COLOR_SUCCESS : MUTED; + const { columns } = useInkFrameSize(); + const alignment = centered ? "center" : "start"; + const optionContentWidth = + getPromptContentWidth(columns, alignment) - (centered ? 4 : 5); + const lockedHint = getLockedOptionHint(option, optionContentWidth); + const descriptionIndent = 5; + const visibleDescription = showDescription ? option.description : undefined; if (centered) { return ( - - - {isCursor ? `${ICONS.triangleSmallRight} ` : " "} - - {marker} - {option.label} - {option.hint !== undefined && option.hint !== "" ? ( - {option.hint} + + + + {isCursor ? `${ICONS.triangleSmallRight} ` : " "} + + {marker} + + + {visibleDescription ? ( + + {visibleDescription} + ) : null} ); } return ( - - - {isCursor ? ICONS.triangleSmallRight : " "} + + + + + {isCursor ? ICONS.triangleSmallRight : " "} + + + {marker} + - {marker} - {option.label} - {option.hint !== undefined && option.hint !== "" ? ( - {option.hint} + {visibleDescription ? ( + + {visibleDescription} + ) : null} ); diff --git a/packages/cli/src/lib/init/ui/ink-frame.tsx b/packages/cli/src/lib/init/ui/ink-frame.tsx index b23497d33..91e462b22 100644 --- a/packages/cli/src/lib/init/ui/ink-frame.tsx +++ b/packages/cli/src/lib/init/ui/ink-frame.tsx @@ -1,5 +1,6 @@ import { Box, Text, useWindowSize } from "ink"; import { Component, type ReactNode } from "react"; +import stringWidth from "string-width"; import { useShortcutHints } from "./ink-shortcuts.js"; const MIN_FRAME_WIDTH = 80; @@ -111,13 +112,26 @@ export function TabFooter({ export function ShortcutFooter({ color }: { color: string }): React.ReactNode { const hints = useShortcutHints(); + const { columns } = useWindowSize(); + const availableWidth = Math.max(0, getInkFrameWidth(columns) - 2); + let usedWidth = 0; + const visibleHints: typeof hints = []; + for (const hint of hints) { + const gapWidth = visibleHints.length === 0 ? 0 : 2; + const hintWidth = stringWidth(`${hint.key} ${hint.action}`); + if (usedWidth + gapWidth + hintWidth > availableWidth) { + continue; + } + usedWidth += gapWidth + hintWidth; + visibleHints.push(hint); + } return ( - {hints.map((hint, index) => ( + {visibleHints.map((hint, index) => ( {hint.key} diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index 266c8839a..776c767e7 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -77,7 +77,7 @@ import { type WizardSummary, type WizardUI, } from "./types.js"; -import { WizardStore } from "./wizard-store.js"; +import { type ActivePrompt, WizardStore } from "./wizard-store.js"; type CreateInkUIOptions = { initialWelcome?: WelcomeOptions; @@ -378,6 +378,8 @@ export class InkUI implements WizardUI { private tipIndex = 0; private activePromptCancel: (() => void) | undefined; + /** A resolved prompt that may remain visible until its successor is ready. */ + private completedPrompt: ActivePrompt | undefined; private cancelHandler: (() => void) | undefined; /** * Guard so `tearDown()` runs at most once even when called from @@ -542,7 +544,15 @@ export class InkUI implements WizardUI { return { start: (message?: string) => { const clean = stripAnsi(message ?? ""); - this.store.startSpinner(clean); + if ( + this.completedPrompt && + this.store.getSnapshot().prompt === this.completedPrompt + ) { + this.store.replacePromptWithSpinner(clean); + this.completedPrompt = undefined; + } else { + this.store.startSpinner(clean); + } if (clean) { this.store.appendStatus(clean); } @@ -579,6 +589,28 @@ export class InkUI implements WizardUI { return this.promptTelemetry.tracePrompt(kind, () => new Promise(mount)); } + /** + * Defers cleanup so the resumed promise chain can replace the completed prompt. + * The identity check prevents the old cleanup from removing its successor. + */ + private completePrompt( + prompt: ActivePrompt, + value: T, + resolve: (resolvedValue: T) => void + ): void { + this.activePromptCancel = undefined; + this.completedPrompt = prompt; + resolve(value); + setImmediate(() => { + if (this.store.getSnapshot().prompt === prompt) { + this.store.setPrompt(null); + } + if (this.completedPrompt === prompt) { + this.completedPrompt = undefined; + } + }); + } + select(opts: SelectOptions): Promise { return this.waitForPrompt("select", (resolve) => { const initialIndex = @@ -590,14 +622,35 @@ export class InkUI implements WizardUI { ) ) : 0; + let settled = false; this.activePromptCancel = () => { + if (settled) { + return; + } + settled = true; this.store.setPrompt(null); this.activePromptCancel = undefined; resolve(CANCELLED); }; - this.store.setPrompt({ + const prompt: Extract = { kind: "select", message: stripAnsi(opts.message), + ...(opts.details + ? { + details: opts.details.map((detail) => ({ + ...detail, + text: stripAnsi(detail.text), + })), + } + : {}), + ...(opts.footer + ? { + footer: { + ...opts.footer, + text: stripAnsi(opts.footer.text), + }, + } + : {}), options: opts.options.map((option) => ({ value: option.value, label: option.label, @@ -605,15 +658,18 @@ export class InkUI implements WizardUI { })), initialIndex, resolve: (value) => { - this.store.setPrompt(null); - this.activePromptCancel = undefined; - if (value === null) { - resolve(CANCELLED); - } else { - resolve(value as T); + if (settled) { + return; } + settled = true; + this.completePrompt( + prompt, + value === null ? CANCELLED : (value as T), + resolve + ); }, - }); + }; + this.store.setPrompt(prompt); }); } @@ -621,31 +677,49 @@ export class InkUI implements WizardUI { opts: MultiSelectOptions ): Promise { return this.waitForPrompt("multiselect", (resolve) => { + let settled = false; this.activePromptCancel = () => { + if (settled) { + return; + } + settled = true; this.store.setPrompt(null); this.activePromptCancel = undefined; resolve(CANCELLED); }; - this.store.setPrompt({ + const prompt: Extract = { kind: "multiselect", message: stripAnsi(opts.message), + ...(opts.details + ? { + details: opts.details.map((detail) => ({ + ...detail, + text: stripAnsi(detail.text), + })), + } + : {}), options: opts.options.map((option) => ({ value: option.value, label: option.label, ...(option.hint ? { hint: option.hint } : {}), + ...(option.description ? { description: option.description } : {}), + ...(option.locked ? { locked: true } : {}), })), initialSelected: opts.initialValues ?? [], required: opts.required ?? false, resolve: (values) => { - this.store.setPrompt(null); - this.activePromptCancel = undefined; - if (values === null) { - resolve(CANCELLED); - } else { - resolve(values as T[]); + if (settled) { + return; } + settled = true; + this.completePrompt( + prompt, + values === null ? CANCELLED : (values as T[]), + resolve + ); }, - }); + }; + this.store.setPrompt(prompt); }); } diff --git a/packages/cli/src/lib/init/ui/types.ts b/packages/cli/src/lib/init/ui/types.ts index 6e7820011..8e5590a47 100644 --- a/packages/cli/src/lib/init/ui/types.ts +++ b/packages/cli/src/lib/init/ui/types.ts @@ -90,36 +90,73 @@ export type WizardLog = { /** Single option in a `select` / `multiselect` prompt. */ export type SelectOption = { + /** Machine-readable value returned when the user chooses this option. */ value: T; + /** User-facing option label. */ label: string; + /** Optional secondary copy rendered beside the label; omitted by default. */ hint?: string; }; +/** Option in a multiselect prompt, optionally carrying supporting copy or a fixed selection. */ +export type MultiSelectOption = SelectOption & { + /** Product-oriented copy rendered below the label; omitted by default. */ + description?: string; + /** When true, keeps the option selected and skips it during toggles. Defaults to false. */ + locked?: boolean; +}; + +/** Supporting row rendered beneath a prompt title. */ +export type PromptDetail = { + /** Text displayed on its own row. */ + text: string; + /** Semantic color treatment for the row. Defaults to `muted`. */ + tone?: "muted" | "success"; +}; + /** Args for `select`. */ export type SelectOptions = { + /** Prompt title shown above the choices. */ message: string; + /** Supporting rows rendered below the title; omitted by default. */ + details?: PromptDetail[]; + /** Explanatory copy rendered between details and actions; omitted by default. */ + footer?: PromptDetail; + /** Choices presented to the user in display order. */ options: SelectOption[]; + /** Initially highlighted value. Defaults to the first option. */ initialValue?: T; }; /** Args for `multiselect`. */ export type MultiSelectOptions = { + /** Prompt title shown above the choices. */ message: string; - options: SelectOption[]; + /** Supporting rows rendered above a visually separated option list; omitted by default. */ + details?: PromptDetail[]; + /** Choices presented to the user in display order. */ + options: MultiSelectOption[]; + /** Values selected when the prompt mounts. Defaults to an empty list. */ initialValues?: T[]; + /** Whether at least one value is required before continuing. Defaults to false. */ required?: boolean; }; /** Args for `confirm`. */ export type ConfirmOptions = { + /** Confirmation question shown to the user. */ message: string; + /** Initially highlighted answer. Defaults to false. */ initialValue?: boolean; }; /** Args for the richer Ink-only welcome screen. */ export type WelcomeOptions = { + /** Heading shown on the welcome screen. */ title: string; + /** Supporting paragraphs shown below the heading. */ body: string[]; + /** Final call-to-action copy shown above the Continue action. */ punchline: string; }; diff --git a/packages/cli/src/lib/init/ui/wizard-store.ts b/packages/cli/src/lib/init/ui/wizard-store.ts index 5030a02f9..0784dd91f 100644 --- a/packages/cli/src/lib/init/ui/wizard-store.ts +++ b/packages/cli/src/lib/init/ui/wizard-store.ts @@ -22,6 +22,7 @@ import { shortStepLabel, } from "../clack-utils.js"; import type { + PromptDetail, SpinnerExitCode, WelcomeOptions, WizardSummary, @@ -32,14 +33,18 @@ export type LogSeverity = "info" | "warn" | "error" | "success" | "message"; export type LogEntry = { /** Stable id used as React key. Monotonic per store instance. */ id: number; + /** Visual severity used to choose the row glyph and color. */ severity: LogSeverity; + /** User-facing log text. */ text: string; }; export type SpinnerState = { + /** Whether the spinner row is currently mounted. */ active: boolean; /** The spinner frame index. Bumped by the renderer's interval. */ frame: number; + /** Current user-facing operation text. */ message: string; }; @@ -51,7 +56,9 @@ export type SpinnerState = { * `ink-app.tsx`). */ export type FileReadEntry = { + /** Project-relative path read by the wizard. */ path: string; + /** Whether reading is still active or analysis has completed. */ status: "reading" | "analyzed"; }; @@ -78,14 +85,22 @@ export type StepEntry = { id: string; /** Sidebar-friendly short label (already abbreviated). */ label: string; + /** Current lifecycle state for this workflow step. */ status: StepStatus; }; /** Generic option shape passed to mounted prompts. */ export type PromptOption = { + /** Machine-readable value returned when the user chooses this option. */ value: string; + /** User-facing option label. */ label: string; + /** Optional secondary copy rendered beside the label; omitted by default. */ hint?: string; + /** Product-oriented copy rendered below the label; omitted by default. */ + description?: string; + /** When true, keeps the option selected and skips it during toggles. Defaults to false. */ + locked?: boolean; }; /** @@ -98,29 +113,53 @@ export type PromptOption = { */ export type ActivePrompt = | { + /** Select prompt discriminator. */ kind: "select"; + /** Prompt title shown above the choices. */ message: string; + /** Supporting rows rendered below the title; omitted by default. */ + details?: PromptDetail[]; + /** Explanatory copy rendered between details and actions; omitted by default. */ + footer?: PromptDetail; + /** Choices presented to the user in display order. */ options: PromptOption[]; + /** Zero-based option index highlighted when the prompt mounts. */ initialIndex: number; + /** Completes the prompt with a value, or `null` on cancellation. */ resolve: (value: string | null) => void; } | { + /** Multiselect prompt discriminator. */ kind: "multiselect"; + /** Prompt title shown above the choices. */ message: string; + /** Supporting rows rendered above a visually separated option list; omitted by default. */ + details?: PromptDetail[]; + /** Choices presented to the user in display order. */ options: PromptOption[]; + /** Values selected when the prompt mounts. */ initialSelected: string[]; + /** Whether at least one value must remain selected. */ required: boolean; + /** Completes the prompt with values, or `null` on cancellation. */ resolve: (values: string[] | null) => void; } | { + /** Confirmation prompt discriminator. */ kind: "confirm"; + /** Confirmation question shown to the user. */ message: string; + /** Answer highlighted when the prompt mounts. */ initialValue: boolean; + /** Completes the prompt with an answer, or `null` on cancellation. */ resolve: (value: boolean | null) => void; } | { + /** Welcome prompt discriminator. */ kind: "welcome"; + /** Copy used by the richer welcome screen. */ options: WelcomeOptions; + /** Continues the wizard, or receives `null` on cancellation. */ resolve: (value: "continue" | null) => void; }; @@ -287,6 +326,14 @@ export class WizardStore { }); } + /** Replace a completed prompt with progress in one render-state update. */ + replacePromptWithSpinner(message: string): void { + this.update({ + prompt: null, + spinner: { active: true, frame: 0, message }, + }); + } + setSpinnerMessage(message: string): void { if (!this.snapshot.spinner.active) { return; diff --git a/packages/cli/src/lib/init/wizard-runner.ts b/packages/cli/src/lib/init/wizard-runner.ts index 5ef492164..a58ae5505 100644 --- a/packages/cli/src/lib/init/wizard-runner.ts +++ b/packages/cli/src/lib/init/wizard-runner.ts @@ -423,7 +423,13 @@ async function handleSuspendedStep( ); } - spin.start("Processing..."); + // Feature review is complete, so name the next visible phase instead of + // briefly falling back to generic processing while the server advances. + spin.start( + stepId === "select-features" + ? STEP_ACTIVE_LABELS["plan-codemods"] + : "Processing..." + ); spinState.running = true; return { diff --git a/packages/cli/test/commands/init.test.ts b/packages/cli/test/commands/init.test.ts index 272f2c354..dfe699c28 100644 --- a/packages/cli/test/commands/init.test.ts +++ b/packages/cli/test/commands/init.test.ts @@ -223,13 +223,29 @@ describe("init command func", () => { }); await expect(promise).rejects.toThrow(ValidationError); await expect(promise).rejects.toThrow( - "Supported features: errors, tracing, logs, replay, metrics, profiling, sourcemaps, crons, ai-monitoring, user-feedback" + "Supported features: errors, tracing, logs, replay, metrics, profiling, sourcemaps, crons, ai-monitoring" ); expect(runWizardSpy).not.toHaveBeenCalled(); expect(findProjectsSpy).not.toHaveBeenCalled(); expect(warmSpy).not.toHaveBeenCalled(); }); + test.each([ + "user-feedback", + "userFeedback", + ])("rejects %s because init cannot configure User Feedback placement", async (feature) => { + const ctx = makeContext(); + const promise = func.call(ctx, { + ...DEFAULT_FLAGS, + features: [feature], + }); + await expect(promise).rejects.toThrow(ValidationError); + await expect(promise).rejects.toThrow( + `Unknown init feature "${feature}"` + ); + expect(runWizardSpy).not.toHaveBeenCalled(); + }); + test("passes undefined when features not provided", async () => { const ctx = makeContext(); await func.call(ctx, DEFAULT_FLAGS); diff --git a/packages/cli/test/lib/init/clack-utils.test.ts b/packages/cli/test/lib/init/clack-utils.test.ts index 21782c455..81a411009 100644 --- a/packages/cli/test/lib/init/clack-utils.test.ts +++ b/packages/cli/test/lib/init/clack-utils.test.ts @@ -1,5 +1,5 @@ /** - * Tests for clack-utils: WizardCancelledError, abortIfCancelled, featureLabel, featureHint. + * Tests for clack-utils: cancellation helpers and feature display metadata. * * These are pure utility functions that don't require module mocking. */ @@ -7,7 +7,7 @@ import { describe, expect, test } from "vitest"; import { abortIfCancelled, - featureHint, + featureDescription, featureLabel, PROGRESS_ROTATE_INTERVAL_MS, STEP_ACTIVE_LABELS, @@ -61,41 +61,48 @@ describe("featureLabel", () => { }); }); -describe("featureHint", () => { - test("returns hint for known feature", () => { - expect(featureHint("errorMonitoring")).toBe( - "Group exceptions into issues with context" +describe("featureDescription", () => { + test("returns description for known feature", () => { + expect(featureDescription("errorMonitoring")).toBe( + "Automatically capture exceptions and stack traces" ); - expect(featureHint("performanceMonitoring")).toBe( - "See request paths, spans, and bottlenecks" + expect(featureDescription("performanceMonitoring")).toBe( + "Find bottlenecks, broken requests, and understand application flow end-to-end" ); - expect(featureHint("sessionReplay")).toBe( - "Replay sessions linked to errors" + expect(featureDescription("sessionReplay")).toBe( + "Watch real user sessions to see what went wrong" ); - expect(featureHint("profiling")).toBe( - "Find CPU-heavy functions in production" + expect(featureDescription("profiling")).toBe( + "Pinpoint the functions and lines of code responsible for performance issues" ); - expect(featureHint("logs")).toBe("Search logs beside errors and traces"); - expect(featureHint("metrics")).toBe("Track custom measurements over time"); - expect(featureHint("sourceMaps")).toBe( - "Turn minified stacks into your source" + expect(featureDescription("logs")).toBe( + "See logs in context with errors and performance issues" ); - expect(featureHint("crons")).toBe( - "Alert on failed or missed scheduled jobs" + expect(featureDescription("metrics")).toBe( + "Track application performance and usage over time with custom metrics" ); - expect(featureHint("aiMonitoring")).toBe( - "Track AI calls, latency, cost, and failures" + expect(featureDescription("sourceMaps")).toBe( + "Turn minified production stack traces back into your original source code" ); - expect(featureHint("userFeedback")).toBe( - "Collect user reports with issue context" + expect(featureDescription("crons")).toBe( + "Detect failed, missed, or delayed scheduled jobs" ); - expect(featureHint("reactFeatures")).toBe( - "Add React-specific context and integrations" + expect(featureDescription("aiMonitoring")).toBe( + "Understand AI calls, latency, token usage, cost, and failures" + ); + expect(featureDescription("mcpObservability")).toBe( + "Trace MCP tool calls and understand failures across agent workflows" + ); + expect(featureDescription("userFeedback")).toBe( + "Collect user reports with the error and session context needed to investigate" + ); + expect(featureDescription("reactFeatures")).toBe( + "Capture React-specific errors with component and rendering context" ); }); test("returns undefined for unknown feature", () => { - expect(featureHint("unknownFeature")).toBeUndefined(); + expect(featureDescription("unknownFeature")).toBeUndefined(); }); }); @@ -109,6 +116,7 @@ describe("sortFeatures", () => { "sourceMaps", "crons", "aiMonitoring", + "mcpObservability", ]) ).toEqual([ "errorMonitoring", @@ -116,6 +124,7 @@ describe("sortFeatures", () => { "sourceMaps", "crons", "aiMonitoring", + "mcpObservability", "userFeedback", ]); }); diff --git a/packages/cli/test/lib/init/interactive.test.ts b/packages/cli/test/lib/init/interactive.test.ts index 344736ff6..b3fc40f06 100644 --- a/packages/cli/test/lib/init/interactive.test.ts +++ b/packages/cli/test/lib/init/interactive.test.ts @@ -280,6 +280,7 @@ describe("handleMultiSelect", () => { const setTagSpy = vi.spyOn(Sentry, "setTag"); const { ui, respond } = createMockUI(); respond.multiselect(["sessionReplay"]); + respond.select("continue"); await handleInteractive( { @@ -290,6 +291,7 @@ describe("handleMultiSelect", () => { "errorMonitoring", "performanceMonitoring", "sessionReplay", + "userFeedback", ], }, makeOptions(), @@ -317,6 +319,7 @@ describe("handleMultiSelect", () => { "errorMonitoring", "performanceMonitoring", "sessionReplay", + "userFeedback", ], }, makeOptions({ yes: true }), @@ -330,8 +333,10 @@ describe("handleMultiSelect", () => { ]); }); - test("returns empty features when none available", async () => { - const { ui } = createMockUI(); + test("returns error monitoring when no features are provided", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect([]); + respond.select("continue"); const result = await handleInteractive( { type: "interactive", @@ -343,13 +348,39 @@ describe("handleMultiSelect", () => { ui ); - expect(result).toEqual({ features: [] }); + expect(result).toEqual({ features: ["errorMonitoring"] }); + expect(calls.some((call) => call.kind === "multiselect")).toBe(true); + expect(calls.some((call) => call.kind === "select")).toBe(true); + }); + + test("injects error monitoring when the server omits the baseline", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: ["sessionReplay", "performanceMonitoring"], + }, + makeOptions(), + ui + ); + + expect(result).toEqual({ + features: ["errorMonitoring", "sessionReplay"], + }); + const multiselectCall = calls.find((call) => call.kind === "multiselect"); + expect(multiselectCall?.options).toContain("errorMonitoring"); }); test("prepends errorMonitoring when available but not user-selected", async () => { // User selects only sessionReplay, but errorMonitoring is available (required) const { ui, respond } = createMockUI(); respond.multiselect(["sessionReplay"]); + respond.select("continue"); const result = await handleInteractive( { @@ -398,8 +429,10 @@ describe("handleMultiSelect", () => { ); }); - test("returns required feature without calling multiselect when only errorMonitoring available", async () => { - const { ui, calls } = createMockUI(); + test("shows selection and review when only errorMonitoring is available", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect([]); + respond.select("continue"); const result = await handleInteractive( { type: "interactive", @@ -412,12 +445,19 @@ describe("handleMultiSelect", () => { ); expect(result).toEqual({ features: ["errorMonitoring"] }); - expect(calls.some((c) => c.kind === "multiselect")).toBe(false); + const multiselectCall = calls.find((call) => call.kind === "multiselect"); + expect(multiselectCall?.options).toEqual(["errorMonitoring"]); + expect(multiselectCall?.initialValues).toEqual(["errorMonitoring"]); + const reviewCall = calls.find((call) => call.kind === "select"); + expect(reviewCall?.details?.map((detail) => detail.text)).toContain( + "✓ Error Monitoring" + ); }); - test("excludes errorMonitoring from multiselect options (always included)", async () => { + test("shows errorMonitoring as a locked selected option", async () => { const { ui, calls, respond } = createMockUI(); respond.multiselect(["performanceMonitoring"]); + respond.select("continue"); await handleInteractive( { @@ -430,18 +470,30 @@ describe("handleMultiSelect", () => { ui ); - // The options passed to multiselect should NOT include errorMonitoring const multiselectCall = calls.find((c) => c.kind === "multiselect") as | Extract<(typeof calls)[number], { kind: "multiselect" }> | undefined; expect(multiselectCall).toBeDefined(); - expect(multiselectCall?.options).not.toContain("errorMonitoring"); + expect(multiselectCall?.options).toContain("errorMonitoring"); expect(multiselectCall?.options).toContain("performanceMonitoring"); + expect(multiselectCall?.initialValues).toEqual([ + "errorMonitoring", + "performanceMonitoring", + ]); + expect( + multiselectCall?.optionDetails.find( + (option) => option.value === "errorMonitoring" + ) + ).toMatchObject({ + description: "Automatically capture exceptions and stack traces", + locked: true, + }); }); - test("shows available optional features without client-side recommendations", async () => { + test("shows defaults first, sorts optional features, and omits User Feedback", async () => { const { ui, calls, respond } = createMockUI(); respond.multiselect(["sessionReplay"]); + respond.select("continue"); const result = await handleInteractive( { @@ -449,10 +501,16 @@ describe("handleMultiSelect", () => { prompt: "Select features", kind: "multi-select", availableFeatures: [ - "errorMonitoring", - "performanceMonitoring", "sourceMaps", + "profiling", + "performanceMonitoring", + "errorMonitoring", + "metrics", "sessionReplay", + "logs", + "crons", + "aiMonitoring", + "userFeedback", ], }, makeOptions({ yes: false }), @@ -465,11 +523,154 @@ describe("handleMultiSelect", () => { | Extract<(typeof calls)[number], { kind: "multiselect" }> | undefined; expect(multiselectCall?.options).toEqual([ + "errorMonitoring", + "logs", "sessionReplay", "performanceMonitoring", + "aiMonitoring", + "metrics", + "crons", + "profiling", "sourceMaps", ]); - expect(multiselectCall?.initialValues).toEqual(["performanceMonitoring"]); + expect(multiselectCall?.initialValues).toEqual([ + "errorMonitoring", + "logs", + "sessionReplay", + "performanceMonitoring", + ]); + expect(multiselectCall?.details).toEqual([ + { + text: "Based on your project, these features are available to set up.", + }, + ]); + expect(multiselectCall?.options).not.toContain("userFeedback"); + + const reviewCall = calls.find((call) => call.kind === "select"); + expect(reviewCall?.details?.[0]).toEqual({ + text: "We'll add these features:", + }); + expect(reviewCall?.footer).toEqual({ + text: "We'll modify project files for this Sentry setup.", + }); + }); + + test("can go back from review and preserves the explicit selection", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + respond.select("back"); + respond.multiselect(["performanceMonitoring"]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ], + }, + makeOptions(), + ui + ); + + expect(result).toEqual({ + features: ["errorMonitoring", "performanceMonitoring"], + }); + const multiselectCalls = calls.filter( + (call) => call.kind === "multiselect" + ); + expect(multiselectCalls).toHaveLength(2); + expect(multiselectCalls[1]?.initialValues).toEqual([ + "errorMonitoring", + "sessionReplay", + ]); + const reviewCalls = calls.filter((call) => call.kind === "select"); + expect(reviewCalls[0]?.details?.map((detail) => detail.text)).toContain( + "✓ Session Replay" + ); + expect(reviewCalls[0]?.details?.map((detail) => detail.text)).not.toContain( + "✓ Tracing" + ); + expect( + reviewCalls[0]?.details + ?.map((detail) => detail.text) + .filter((line) => line.startsWith("✓ ")) + ).toEqual(["✓ Error Monitoring", "✓ Session Replay"]); + expect(reviewCalls[1]?.details?.map((detail) => detail.text)).toContain( + "✓ Tracing" + ); + expect(reviewCalls[1]?.options).toEqual(["continue", "back"]); + }); + + test.each([ + "aiMonitoring", + "mcpObservability", + ])("review includes tracing when %s enables it implicitly", async (dependencyFeature) => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect([dependencyFeature]); + respond.select("continue"); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + dependencyFeature, + ], + }, + makeOptions(), + ui + ); + + expect(result).toEqual({ + features: ["errorMonitoring", "performanceMonitoring", dependencyFeature], + }); + const reviewCall = calls.find((call) => call.kind === "select"); + const reviewDetails = reviewCall?.details?.map((detail) => detail.text); + expect(reviewDetails).toContain("✓ Error Monitoring"); + expect(reviewDetails).toContain("✓ Tracing"); + expect(reviewDetails).toContain( + `✓ ${dependencyFeature === "aiMonitoring" ? "AI Monitoring" : "MCP Observability"}` + ); + }); + + test("Back restores the normalized AI selection including Tracing", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["aiMonitoring"]); + respond.select("back"); + respond.multiselect(["aiMonitoring", "performanceMonitoring"]); + respond.select("continue"); + + await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "aiMonitoring", + ], + }, + makeOptions(), + ui + ); + + const multiselectCalls = calls.filter( + (call) => call.kind === "multiselect" + ); + expect(multiselectCalls[1]?.initialValues).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "aiMonitoring", + ]); }); }); diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 4dd8bc6e9..67ef9f6ba 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -11,6 +11,7 @@ import { Readable, Writable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; +import chalk from "chalk"; import { render } from "ink"; import { createElement } from "react"; import { describe, expect, test, vi } from "vitest"; @@ -33,7 +34,7 @@ const FILES_HEADER_UNPINNED_RE = /Files analyzed\s+\u2191\s+\d+\/\d+/; const KEYBOARD_HINT_RE = /switch tab/; const SPACE_TOGGLE_HINT_RE = /space\s+toggle/; const A_ALL_HINT_RE = /a\s+all/; -const ENTER_CONFIRM_HINT_RE = /enter\s+confirm/; +const ENTER_CONTINUE_HINT_RE = /enter\s+continue/; const ESC_CANCEL_HINT_RE = /esc\s+cancel/; const COMPLETED_SELECTING_FEATURES_RE = /✔\s+Selecting features/; const ANSI_ESCAPE_PREFIX = "\u001B["; @@ -44,6 +45,8 @@ const ANSI_CSI_RE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; const ANSI_OSC_RE = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g; const LINE_SPLIT_RE = /\r?\n/; const DOWN_ARROW = "\u001B[B"; +const PAGE_DOWN = "\u001B[6~"; +const PAGE_UP = "\u001B[5~"; const RIGHT_ARROW = "\u001B[C"; const FEEDBACK_BANNER_TEXT = '$ sentry cli feedback "what worked or broke"'; @@ -536,41 +539,492 @@ describe("Ink App snapshot", () => { expect(longLogoLine).toBe(shortLogoLine); }); - test("feature multiselect shows available features directly", async () => { + test("feature multiselect shows descriptions and the included baseline", async () => { const store = new WizardStore({ bannerRows: [] }); store.setPrompt({ kind: "multiselect", - message: "Select features", + message: "Select features to enable", + details: [ + { + text: "Based on your project, these features are available to set up.", + }, + ], options: [ - { value: "sessionReplay", label: "Session Replay" }, + { + value: "errorMonitoring", + label: "Error Monitoring", + description: "Automatically capture exceptions and stack traces", + locked: true, + }, + { + value: "logs", + label: "Logging", + description: "See logs in context with errors and performance issues", + }, + { + value: "sessionReplay", + label: "Session Replay", + description: "Watch real user sessions to see what went wrong", + }, { value: "performanceMonitoring", label: "Tracing", - hint: "See request paths, spans, and bottlenecks", + description: + "Find bottlenecks, broken requests, and understand application flow end-to-end", + }, + { + value: "sourceMaps", + label: "Source Maps", + description: + "Turn minified production stack traces back into your original source code", }, - { value: "sourceMaps", label: "Source Maps" }, ], - initialSelected: [], + initialSelected: [ + "errorMonitoring", + "logs", + "sessionReplay", + "performanceMonitoring", + ], required: false, resolve: ignorePromptResolution, }); - const frame = (await renderApp(store, 120)).allOutput(); + const previousColorLevel = chalk.level; + chalk.level = 3; + let frame: string; + try { + frame = (await renderApp(store, 120)).latestFrame(); + } finally { + chalk.level = previousColorLevel; + } const plainFrame = stripAnsi(frame); + expect(frame).toContain("Select features to enable"); + expect(frame).toContain("Based on your project, these features are"); + expect(frame).toContain("available to set up."); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain( + "Automatically capture exceptions and stack traces" + ); expect(frame).toContain("Session Replay"); + expect(frame).toContain("Watch real user sessions to see what went wrong"); expect(frame).toContain("Tracing"); - expect(frame).toContain("See request paths, spans, and bottlenecks"); + expect(frame).toContain("Find bottlenecks, broken requests"); expect(frame).toContain("Source Maps"); - expect(plainFrame).toContain("0/3"); - expect(plainFrame).not.toContain( - "space toggle • a all • enter confirm • esc cancel" + expect(plainFrame).toContain("4/5"); + expect(plainFrame).toContain( + "↑↓ move • space toggle • a all • enter continue" ); expect(plainFrame).toMatch(SPACE_TOGGLE_HINT_RE); expect(plainFrame).toMatch(A_ALL_HINT_RE); - expect(plainFrame).toMatch(ENTER_CONFIRM_HINT_RE); + expect(plainFrame).toMatch(ENTER_CONTINUE_HINT_RE); expect(plainFrame).toMatch(ESC_CANCEL_HINT_RE); + expect(plainFrame).not.toContain("required"); + expect(plainFrame).toContain("Error Monitoring (always included)"); + const errorMonitoringRow = frame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Error Monitoring")); + expect(errorMonitoringRow).toContain( + `${ANSI_ESCAPE_PREFIX}38;2;131;218;144m◼ ` + ); expect(frame).not.toContain("Recommended setup"); expect(frame).not.toContain("Apply recommended setup"); + expect(plainFrame.indexOf("Error Monitoring")).toBeLessThan( + plainFrame.indexOf("Logging") + ); + expect(plainFrame.indexOf("Logging")).toBeLessThan( + plainFrame.indexOf("Session Replay") + ); + expect(plainFrame.indexOf("Session Replay")).toBeLessThan( + plainFrame.indexOf("Tracing") + ); + expect(plainFrame.indexOf("Tracing")).toBeLessThan( + plainFrame.indexOf("Source Maps") + ); + const lines = plainFrame.split(LINE_SPLIT_RE); + const shortcutLine = lines.findIndex((line) => + line.includes("↑↓ move • space toggle • a all • enter continue") + ); + const contextLastLine = lines.findIndex((line) => + line.includes("available to set up.") + ); + const firstFeatureLine = lines.findIndex((line) => + line.includes("Error Monitoring") + ); + const lastFeatureLine = lines.findIndex((line) => + line.includes("Source Maps") + ); + expect(contextLastLine).toBeGreaterThan(0); + expect(firstFeatureLine - contextLastLine).toBeGreaterThan(1); + expect(shortcutLine).toBeGreaterThan(0); + expect(lastFeatureLine).toBeGreaterThan(0); + expect(shortcutLine - lastFeatureLine).toBeGreaterThan(2); + expect(plainFrame.indexOf("Tracing")).toBeLessThan( + plainFrame.indexOf("↑↓ move • space toggle • a all • enter continue") + ); + }); + + test("multiselect hints render and locked options survive toggle all", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + options: [ + { + value: "errors", + label: "Error Monitoring", + hint: "captured by default", + locked: true, + }, + { value: "replay", label: "Session Replay" }, + ], + initialSelected: ["errors", "replay"], + required: false, + resolve, + }); + + const rendered = await renderApp(store, 120, { input: ["a", "\r"] }); + expect(stripAnsi(rendered.allOutput())).toContain("captured by default"); + expect(resolve).toHaveBeenCalledWith(["errors"]); + }); + + test("the multiselect cursor starts on the first unselected option", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + options: [ + { value: "errors", label: "Error Monitoring", locked: true }, + { value: "logs", label: "Logging" }, + { value: "replay", label: "Session Replay" }, + { value: "profiling", label: "Profiling" }, + ], + initialSelected: ["errors", "logs", "replay"], + required: false, + resolve, + }); + + await renderApp(store, 120, { input: [" ", "\r"] }); + expect(resolve).toHaveBeenCalledWith([ + "errors", + "logs", + "replay", + "profiling", + ]); + }); + + test("feature descriptions fit short terminals and keep the baseline pinned", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features to enable", + details: [ + { + text: "Based on your project, these features are available to set up.", + }, + ], + options: [ + { + value: "errors", + label: "Error Monitoring", + description: "Automatically capture exceptions and stack traces", + locked: true, + }, + ...Array.from({ length: 5 }, (_value, index) => ({ + value: `feature-${index + 1}`, + label: `Feature ${index + 1}`, + description: + "A longer explanation that wraps cleanly and still leaves enough room for navigation", + })), + ], + initialSelected: ["errors"], + required: false, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, 60, { + input: [DOWN_ARROW, DOWN_ARROW, DOWN_ARROW], + rows: 16, + }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Feature 4"); + expect(frame).toContain("↑↓ move • space toggle • a all • enter continue"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + }); + + test("feature selection stays usable at 30 columns", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features to enable", + details: [ + { + text: "Based on your project, these features are available to set up.", + }, + ], + options: [ + { + value: "errors", + label: "Error Monitoring", + description: "Automatically capture exceptions and stack traces", + locked: true, + }, + { + value: "logs", + label: "Logging", + description: "See logs in context with errors and performance issues", + }, + { + value: "replay", + label: "Session Replay", + description: "Watch real user sessions to see what went wrong", + }, + { + value: "tracing", + label: "Tracing", + description: + "Find bottlenecks, broken requests, and understand application flow end-to-end", + }, + { + value: "profiling", + label: "Profiling", + description: + "Pinpoint the functions and lines of code responsible for performance issues", + }, + ], + initialSelected: ["errors", "logs", "replay", "tracing"], + required: false, + resolve, + }); + + const rendered = await renderApp(store, 30, { + input: [DOWN_ARROW, DOWN_ARROW, " ", "\r"], + rows: 16, + }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Session Replay"); + expect(frame).toContain("space toggle"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + expect(resolve).toHaveBeenCalledWith(["errors", "logs", "tracing"]); + }); + + test("feature review shows the selected setup and a way back", async () => { + const resolve = vi.fn(); + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + { text: "✓ Error Monitoring", tone: "success" }, + { text: "✓ Session Replay", tone: "success" }, + { text: "✓ Tracing", tone: "success" }, + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve, + }); + + const frame = stripAnsi((await renderApp(store, 120)).latestFrame()); + expect(frame).toContain("Review your Sentry setup"); + expect(frame).toContain("We'll add these features:"); + expect(frame).toContain("Error Monitoring"); + expect(frame).toContain("Tracing"); + expect(frame).toContain("Session Replay"); + expect(frame).toContain("Continue"); + expect(frame).toContain("Back"); + expect(frame).not.toContain("Change features"); + expect(frame).toContain( + "We'll modify project files for this Sentry setup." + ); + expect(frame.indexOf("✓ Tracing")).toBeLessThan( + frame.indexOf("We'll modify project files for this Sentry setup.") + ); + expect( + frame.indexOf("We'll modify project files for this Sentry setup.") + ).toBeLessThan(frame.indexOf("Continue")); + const reviewLines = frame + .split(LINE_SPLIT_RE) + .filter((line) => line.includes("✓ ")); + expect(reviewLines).toHaveLength(3); + expect( + reviewLines.every((line) => (line.match(/✓/g) ?? []).length === 1) + ).toBe(true); + + await renderApp(store, 120, { input: [DOWN_ARROW, "\r"] }); + expect(resolve).toHaveBeenCalledWith("back"); + }); + + test("feature review keeps the workflow content anchored for the next step", async () => { + const reviewStore = new WizardStore({ bannerRows: [] }); + reviewStore.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + { text: "✓ Error Monitoring", tone: "success" }, + { text: "✓ Logging", tone: "success" }, + { text: "✓ Tracing", tone: "success" }, + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + const planStore = new WizardStore({ bannerRows: [] }); + planStore.startSpinner("Planning Sentry changes"); + + const reviewFrame = stripAnsi( + (await renderApp(reviewStore, 120)).latestFrame() + ); + const planFrame = stripAnsi( + (await renderApp(planStore, 120)).latestFrame() + ); + const reviewLine = reviewFrame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Review your Sentry setup")); + const planLine = planFrame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Planning Sentry changes")); + + expect(reviewLine).toBeDefined(); + expect(planLine).toBeDefined(); + expect(reviewLine?.indexOf("Review your Sentry setup")).toBe( + planLine?.indexOf("Planning Sentry changes") + ); + }); + + test.each([ + 120, 60, 30, + ])("review prompts keep their warning and actions visible at %i columns", async (columns) => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog("warn", "Review warning remains visible"); + store.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + ...[ + "AI Monitoring", + "Application Metrics", + "Crons", + "Error Monitoring", + "Logging", + "MCP Observability", + "Profiling", + "Session Replay", + "Source Maps", + "Tracing", + ].map((feature) => ({ + text: `✓ ${feature}`, + tone: "success" as const, + })), + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const rendered = await renderApp(store, columns, { + input: Array.from({ length: 12 }, () => PAGE_DOWN), + rows: 16, + }); + const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); + const normalizedFrame = frame.replace(/\s+/g, " "); + expect(frame).toContain("Review your Sentry setup"); + expect(frame).toContain("Review warning remains"); + expect(frame).toContain("10-10/10 · pgup/pgdn"); + expect(frame).toContain("Tracing"); + expect(normalizedFrame).toContain( + "We'll modify project files for this Sentry setup." + ); + expect(frame).toContain("Continue"); + expect(frame).toContain("Back"); + expect(frame).not.toContain("Change features"); + expect(frame).toContain("Status"); + expect(frame).toContain("Files"); + expect(frame).toContain("↑↓ navigate"); + expect(frame).toContain("enter confirm"); + expect(frame).toContain("Sentry"); + expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); + }); + + test("narrow review paging remains reversible when warnings accumulate", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog("warn", "An older review warning"); + store.appendLog("error", "The latest review warning remains visible"); + store.setPrompt({ + kind: "select", + message: "Review your Sentry setup", + details: [ + { text: "We'll add these features:" }, + ...Array.from({ length: 10 }, (_value, index) => ({ + text: `✓ Feature ${index + 1}`, + tone: "success" as const, + })), + ], + footer: { + text: "We'll modify project files for this Sentry setup.", + }, + options: [ + { value: "continue", label: "Continue" }, + { value: "back", label: "Back" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const firstFrame = stripAnsi( + (await renderApp(store, 30, { rows: 16 })).latestFrame() + ); + expect(firstFrame).not.toContain("An older review warning"); + expect(firstFrame).toContain("latest review warning"); + expect(firstFrame).toContain("✓ Feature 1"); + expect(firstFrame).toContain("1-1/10 · pgup/pgdn"); + + const nextFrame = stripAnsi( + ( + await renderApp(store, 30, { input: [PAGE_DOWN], rows: 16 }) + ).latestFrame() + ); + expect(nextFrame).toContain("✓ Feature 2"); + expect(nextFrame).toContain("2-2/10 · pgup/pgdn"); + + const previousFrame = stripAnsi( + ( + await renderApp(store, 30, { + input: [PAGE_DOWN, PAGE_UP], + rows: 16, + }) + ).latestFrame() + ); + expect(previousFrame).toContain("✓ Feature 1"); + expect(previousFrame).toContain("1-1/10 · pgup/pgdn"); + expect(previousFrame).toContain("Continue"); + expect(previousFrame).toContain("Back"); + expect(previousFrame).toContain("Status"); + expect(previousFrame).toContain("Files"); }); test("workflow prompts hide routine logs but keep warnings and tasks", async () => { @@ -678,7 +1132,8 @@ describe("Ink App snapshot", () => { store.appendLog("warn", "A second warning also remains visible"); store.setPrompt({ kind: "multiselect", - message: "Select features\nReview the monitoring choices", + message: "Select features", + details: [{ text: "Review the monitoring choices" }], options: Array.from({ length: 20 }, (_value, index) => ({ value: `feature-${index + 1}`, label: `Feature ${index + 1}`, @@ -693,8 +1148,8 @@ describe("Ink App snapshot", () => { expect(frame).toContain("0/20 selected • 1/20"); expect(frame).toContain("Review the monitoring choices"); expect(frame).toContain("A second warning also remains visible"); - expect(frame).toContain("Feature 2"); - expect(frame).not.toContain("Feature 3"); + expect(frame).toContain("Feature 1"); + expect(frame).not.toContain("Feature 2"); expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(16); expect(frame).toContain(FEEDBACK_BANNER_TEXT); @@ -721,7 +1176,8 @@ describe("Ink App snapshot", () => { }); store.setPrompt({ kind: "multiselect", - message: "Select features\nReview the monitoring choices", + message: "Select features", + details: [{ text: "Review the monitoring choices" }], options: Array.from({ length: 20 }, (_value, index) => ({ value: `feature-${index + 1}`, label: `Feature ${index + 1}`, @@ -734,8 +1190,8 @@ describe("Ink App snapshot", () => { const rendered = await renderApp(store, 120, { rows: 30 }); const frame = stripFinalLineBreak(stripAnsi(rendered.latestFrame())); expect(frame).toContain("Review the monitoring choices"); - expect(frame).toContain("Feature 6"); - expect(frame).not.toContain("Feature 7"); + expect(frame).toContain("Feature 5"); + expect(frame).not.toContain("Feature 6"); expect(frame.split(LINE_SPLIT_RE).length).toBeLessThanOrEqual(30); expect(frame).toContain(FEEDBACK_BANNER_TEXT); }); diff --git a/packages/cli/test/lib/init/ui/ink-ui-telemetry.test.ts b/packages/cli/test/lib/init/ui/ink-ui-telemetry.test.ts index 98762e163..d90f2d10d 100644 --- a/packages/cli/test/lib/init/ui/ink-ui-telemetry.test.ts +++ b/packages/cli/test/lib/init/ui/ink-ui-telemetry.test.ts @@ -70,6 +70,194 @@ afterEach(() => { }); describe("InkUI prompt telemetry", () => { + test("replaces sequential prompts without an empty frame", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const promptKinds: Array = []; + const unsubscribe = store.subscribe(() => { + promptKinds.push(store.getSnapshot().prompt?.kind ?? null); + }); + + const resultPromise = (async () => { + await ui.multiselect({ + message: "Choose features", + options: [{ label: "Tracing", value: "performanceMonitoring" }], + }); + return ui.select({ + message: "Review your Sentry setup", + options: [ + { label: "Continue", value: "continue" }, + { label: "Back", value: "back" }, + ], + }); + })(); + + const featurePrompt = store.getSnapshot().prompt; + expect(featurePrompt?.kind).toBe("multiselect"); + if (featurePrompt?.kind !== "multiselect") { + throw new Error("Expected a multiselect prompt"); + } + featurePrompt.resolve(["performanceMonitoring"]); + + await vi.waitFor(() => { + expect(store.getSnapshot().prompt?.kind).toBe("select"); + }); + expect(promptKinds).toEqual(["multiselect", "select"]); + + const reviewPrompt = store.getSnapshot().prompt; + if (reviewPrompt?.kind !== "select") { + throw new Error("Expected a select prompt"); + } + reviewPrompt.resolve("continue"); + await expect(resultPromise).resolves.toBe("continue"); + await new Promise((resolve) => setImmediate(resolve)); + expect(promptKinds).toEqual(["multiselect", "select", null]); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + + test("returns from review to feature selection without an empty frame", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const promptKinds: Array = []; + const unsubscribe = store.subscribe(() => { + promptKinds.push(store.getSnapshot().prompt?.kind ?? null); + }); + + const resultPromise = (async () => { + await ui.select({ + message: "Review your Sentry setup", + options: [ + { label: "Continue", value: "continue" }, + { label: "Back", value: "back" }, + ], + }); + return ui.multiselect({ + message: "Choose features", + options: [{ label: "Tracing", value: "performanceMonitoring" }], + }); + })(); + + const reviewPrompt = store.getSnapshot().prompt; + expect(reviewPrompt?.kind).toBe("select"); + if (reviewPrompt?.kind !== "select") { + throw new Error("Expected a select prompt"); + } + reviewPrompt.resolve("back"); + + await vi.waitFor(() => { + expect(store.getSnapshot().prompt?.kind).toBe("multiselect"); + }); + expect(promptKinds).toEqual(["select", "multiselect"]); + + const featurePrompt = store.getSnapshot().prompt; + if (featurePrompt?.kind !== "multiselect") { + throw new Error("Expected a multiselect prompt"); + } + featurePrompt.resolve(["performanceMonitoring"]); + await expect(resultPromise).resolves.toEqual(["performanceMonitoring"]); + await new Promise((resolve) => setImmediate(resolve)); + expect(promptKinds).toEqual(["select", "multiselect", null]); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + + test("replaces the completed review atomically with planning progress", async () => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const states: Array<{ + prompt: string | null; + spinnerActive: boolean; + spinnerMessage: string; + }> = []; + const unsubscribe = store.subscribe(() => { + const snapshot = store.getSnapshot(); + states.push({ + prompt: snapshot.prompt?.kind ?? null, + spinnerActive: snapshot.spinner.active, + spinnerMessage: snapshot.spinner.message, + }); + }); + + const resultPromise = (async () => { + const result = await ui.select({ + message: "Review your Sentry setup", + options: [ + { label: "Continue", value: "continue" }, + { label: "Back", value: "back" }, + ], + }); + ui.spinner().start("Planning code changes..."); + return result; + })(); + + const reviewPrompt = store.getSnapshot().prompt; + if (reviewPrompt?.kind !== "select") { + throw new Error("Expected a select prompt"); + } + reviewPrompt.resolve("continue"); + + await vi.waitFor(() => { + expect(store.getSnapshot().spinner.active).toBe(true); + }); + await expect(resultPromise).resolves.toBe("continue"); + expect(states).not.toContainEqual( + expect.objectContaining({ prompt: null, spinnerActive: false }) + ); + expect(states).not.toContainEqual( + expect.objectContaining({ prompt: "select", spinnerActive: true }) + ); + expect(store.getSnapshot()).toMatchObject({ + prompt: null, + spinner: { + active: true, + message: "Planning code changes...", + }, + }); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + + test.each([ + "select", + "multiselect", + ] as const)("clears a cancelled %s prompt after returning the cancellation", async (kind) => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const { ui, store } = createUi(); + const promptKinds: Array = []; + const unsubscribe = store.subscribe(() => { + promptKinds.push(store.getSnapshot().prompt?.kind ?? null); + }); + + const resultPromise = + kind === "select" + ? ui.select({ + message: "Review your Sentry setup", + options: [{ label: "Continue", value: "continue" }], + }) + : ui.multiselect({ + message: "Choose features", + options: [{ label: "Tracing", value: "performanceMonitoring" }], + }); + const prompt = store.getSnapshot().prompt; + expect(prompt?.kind).toBe(kind); + if (prompt?.kind !== "select" && prompt?.kind !== "multiselect") { + throw new Error(`Expected a ${kind} prompt`); + } + + prompt.resolve(null); + await expect(resultPromise).resolves.toBe(CANCELLED); + await new Promise((resolve) => setImmediate(resolve)); + expect(store.getSnapshot().prompt).toBeNull(); + expect(promptKinds).toEqual([kind, null]); + + unsubscribe(); + await ui[Symbol.asyncDispose](); + }); + test("attributes workflow prompts to the active step", async () => { const metricSpy = vi.spyOn(Sentry.metrics, "distribution"); const startSpanSpy = vi.spyOn(Sentry, "startSpan"); diff --git a/packages/cli/test/lib/init/ui/mock-ui.ts b/packages/cli/test/lib/init/ui/mock-ui.ts index 460bf5553..457f387f5 100644 --- a/packages/cli/test/lib/init/ui/mock-ui.ts +++ b/packages/cli/test/lib/init/ui/mock-ui.ts @@ -41,12 +41,25 @@ export type MockCall = | { kind: "spinner.start"; message?: string } | { kind: "spinner.message"; message?: string } | { kind: "spinner.stop"; message?: string; code?: SpinnerExitCode } - | { kind: "select"; message: string; options: string[] } + | { + kind: "select"; + message: string; + details?: Array<{ text: string; tone?: "muted" | "success" }>; + footer?: { text: string; tone?: "muted" | "success" }; + options: string[]; + } | { kind: "welcome"; options: WelcomeOptions } | { kind: "multiselect"; message: string; + details?: Array<{ text: string; tone?: "muted" | "success" }>; options: string[]; + optionDetails: Array<{ + value: string; + label: string; + description?: string; + locked?: boolean; + }>; initialValues?: string[]; } | { kind: "confirm"; message: string; initialValue?: boolean } @@ -145,6 +158,8 @@ export function createMockUI(options: MockUIOptions = {}): { calls.push({ kind: "select", message: opts.message, + ...(opts.details ? { details: opts.details } : {}), + ...(opts.footer ? { footer: opts.footer } : {}), options: opts.options.map((option) => option.value), }); return Promise.resolve(takeResponse("select")); @@ -153,7 +168,14 @@ export function createMockUI(options: MockUIOptions = {}): { calls.push({ kind: "multiselect", message: opts.message, + ...(opts.details ? { details: opts.details } : {}), options: opts.options.map((option) => option.value), + optionDetails: opts.options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description ? { description: option.description } : {}), + ...(option.locked ? { locked: true } : {}), + })), ...(opts.initialValues ? { initialValues: opts.initialValues } : {}), }); return Promise.resolve(takeResponse("multiselect")); diff --git a/packages/cli/test/lib/init/wizard-runner.test.ts b/packages/cli/test/lib/init/wizard-runner.test.ts index 690848f6c..9726483e9 100644 --- a/packages/cli/test/lib/init/wizard-runner.test.ts +++ b/packages/cli/test/lib/init/wizard-runner.test.ts @@ -645,6 +645,29 @@ describe("runWizard", () => { ); }); + test("moves feature review directly into code-planning progress", async () => { + mockStartResult = { + status: "suspended", + suspended: [["select-features"]], + steps: { + "select-features": { + suspendPayload: { + type: "interactive", + kind: "multi-select", + prompt: "Select features to enable", + availableFeatures: ["errorMonitoring", "performanceMonitoring"], + }, + }, + }, + }; + mockResumeResults = [{ status: "success" }]; + + await runWizard(makeOptions()); + + expect(spinnerMock.start).toHaveBeenCalledWith("Planning code changes..."); + expect(spinnerMock.start).not.toHaveBeenCalledWith("Processing..."); + }); + test("skips verify-changes interactive prompts during dry-run", async () => { resolveInitContextSpy.mockResolvedValue(makeContext({ dryRun: true })); mockStartResult = {