diff --git a/docs/src/fragments/commands/init.md b/docs/src/fragments/commands/init.md index 226084c32..e7e80d9d7 100644 --- a/docs/src/fragments/commands/init.md +++ b/docs/src/fragments/commands/init.md @@ -10,8 +10,8 @@ # Interactive setup sentry init -# Non-interactive with auto-yes -sentry init -y +# Non-interactive agent/CI setup +sentry init --yes --features errors,tracing,replay # Dry run to preview changes sentry init --dry-run diff --git a/plugins/sentry-cli/skills/sentry-cli/references/init.md b/plugins/sentry-cli/skills/sentry-cli/references/init.md index e96a83af2..57ed713dc 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/init.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/init.md @@ -16,9 +16,9 @@ Initialize Sentry in your project (experimental) Initialize Sentry in your project (experimental) **Flags:** -- `-y, --yes - Non-interactive mode (accept defaults)` +- `-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,profiling,ai-monitoring,user-feedback` +- `--features ... - Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback` - `-t, --team - Team slug to create the project under` - `--tui - Use the Ink-based interactive UI (default on the Bun binary). Pass --no-tui to fall back to plain log output.` @@ -28,8 +28,8 @@ Initialize Sentry in your project (experimental) # Interactive setup sentry init -# Non-interactive with auto-yes -sentry init -y +# Non-interactive agent/CI setup +sentry init --yes --features errors,tracing,replay # Dry run to preview changes sentry init --dry-run diff --git a/src/commands/init.ts b/src/commands/init.ts index 6c2c91111..5b1bb0152 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -37,9 +37,45 @@ import { const log = logger.withTag("init"); const FEATURE_DELIMITER = /[,+ ]+/; +const NON_INTERACTIVE_USAGE_HINT = + "sentry init --yes --features errors,tracing,replay [target] [directory]"; const USAGE_HINT = "sentry init / [directory]"; +const FEATURE_ALIASES = { + errors: "errorMonitoring", + errorMonitoring: "errorMonitoring", + tracing: "performanceMonitoring", + performanceMonitoring: "performanceMonitoring", + logs: "logs", + replay: "sessionReplay", + sessionReplay: "sessionReplay", + metrics: "metrics", + profiling: "profiling", + sourcemaps: "sourceMaps", + sourceMaps: "sourceMaps", + crons: "crons", + "ai-monitoring": "aiMonitoring", + aiMonitoring: "aiMonitoring", + "user-feedback": "userFeedback", + userFeedback: "userFeedback", +} as const; + +const SUPPORTED_FEATURE_NAMES = [ + "errors", + "tracing", + "logs", + "replay", + "metrics", + "profiling", + "sourcemaps", + "crons", + "ai-monitoring", + "user-feedback", +] as const; + +const SUPPORTED_FEATURE_TEXT = SUPPORTED_FEATURE_NAMES.join(", "); + type InitFlags = { readonly yes: boolean; readonly "dry-run": boolean; @@ -109,6 +145,61 @@ function classifyArgs( return { target: second, directory: first }; } +function parseFeatures( + features: readonly string[] | undefined +): string[] | undefined { + const requested = features + ?.flatMap((feature) => feature.split(FEATURE_DELIMITER)) + .map((feature) => feature.trim()) + .filter(Boolean); + + if (!requested || requested.length === 0) { + return; + } + + return requested.map(normalizeFeature); +} + +function normalizeFeature(feature: string): string { + const normalized = FEATURE_ALIASES[feature as keyof typeof FEATURE_ALIASES]; + if (!normalized) { + throw new ValidationError( + `Unknown init feature "${feature}". Supported features: ${SUPPORTED_FEATURE_TEXT}`, + "features" + ); + } + return normalized; +} + +function isNonInteractiveContext(context: unknown): boolean { + const { stdin, stdout } = context as { + stdin?: { isTTY?: boolean }; + stdout?: { isTTY?: boolean }; + }; + return stdin?.isTTY !== true || stdout?.isTTY !== true; +} + +function validateNonInteractiveInit( + context: unknown, + flags: InitFlags, + features: readonly string[] | undefined +): void { + if (!isNonInteractiveContext(context)) { + return; + } + + if (flags.yes && features && features.length > 0) { + return; + } + + throw new ContextError("Yes flag and features", NON_INTERACTIVE_USAGE_HINT, [ + "Agent/CI mode cannot ask interactive setup questions.", + "Pass --yes to accept non-interactive prompts.", + `Pass --features with one or more supported features: ${SUPPORTED_FEATURE_TEXT}.`, + "Run sentry init from an interactive terminal to use the wizard UI.", + ]); +} + /** * Resolve the parsed org/project target into explicit org and project values. * @@ -220,13 +311,17 @@ export const initCommand = buildCommand< ], }, flags: { - yes: { ...YES_FLAG, brief: "Non-interactive mode (accept defaults)" }, + yes: { + ...YES_FLAG, + brief: + "Accept non-interactive defaults (requires --features outside a TTY)", + }, "dry-run": DRY_RUN_FLAG, features: { kind: "parsed", parse: String, brief: - "Features to enable: errors,tracing,logs,replay,profiling,ai-monitoring,user-feedback", + "Features to enable: errors,tracing,logs,replay,metrics,profiling,sourcemaps,crons,ai-monitoring,user-feedback", variadic: true, optional: true, }, @@ -264,11 +359,13 @@ export const initCommand = buildCommand< // 2. Resolve directory const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd; - // 3. Parse features - const featuresList = flags.features - ?.flatMap((f) => f.split(FEATURE_DELIMITER)) - .map((f) => f.trim()) - .filter(Boolean); + // 3. Parse and validate features before any network or wizard work. + const featuresList = parseFeatures(flags.features); + + // Non-TTY callers (CI/agents) must provide every interactive choice + // needed to start setup. Fail before project lookup, org prefetch, or + // UI creation so the error is deterministic and actionable. + validateNonInteractiveInit(this, flags, featuresList); // 4. Resolve target → org + project // Validation of user-provided slugs happens inside resolveTarget. diff --git a/src/lib/init/clack-utils.ts b/src/lib/init/clack-utils.ts index dbcaa2bac..521897cfa 100644 --- a/src/lib/init/clack-utils.ts +++ b/src/lib/init/clack-utils.ts @@ -37,41 +37,41 @@ export function abortIfCancelled(value: T): Exclude { const FEATURE_INFO: Record = { errorMonitoring: { label: "Error Monitoring", - hint: "Error and crash reporting", + hint: "Group exceptions into issues with context", }, performanceMonitoring: { - label: "Performance Monitoring (Tracing)", - hint: "Transaction and span tracing", + label: "Tracing", + hint: "See request paths, spans, and bottlenecks", }, sessionReplay: { label: "Session Replay", - hint: "Visual replay of user sessions", + hint: "Replay sessions linked to errors", }, profiling: { label: "Profiling", - hint: "Code-level performance insights", + hint: "Find CPU-heavy functions in production", }, - logs: { label: "Logging", hint: "Structured log ingestion" }, - metrics: { label: "Metrics", hint: "Track business 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: "See original source code in production errors", + hint: "Turn minified stacks into your source", }, crons: { label: "Crons", - hint: "Monitor scheduled and recurring jobs", + hint: "Alert on failed or missed scheduled jobs", }, aiMonitoring: { label: "AI Monitoring", - hint: "Track AI model calls, latency, and failures", + hint: "Track AI calls, latency, cost, and failures", }, userFeedback: { label: "User Feedback", - hint: "Collect in-app user feedback and reports", + hint: "Collect user reports with issue context", }, reactFeatures: { label: "React Features", - hint: "Redux, component tracking, source maps, and integrations", + hint: "Add React-specific context and integrations", }, }; diff --git a/src/lib/init/feedback.ts b/src/lib/init/feedback.ts new file mode 100644 index 000000000..30cd47be8 --- /dev/null +++ b/src/lib/init/feedback.ts @@ -0,0 +1,23 @@ +export type InitFeedbackOutcome = "success" | "cancelled" | "failed"; + +const FEEDBACK_COMMANDS: Record = { + success: '$ sentry cli feedback "sentry init worked well"', + cancelled: '$ sentry cli feedback "sentry init was cancelled"', + failed: '$ sentry cli feedback "sentry init failed"', +}; + +const FEEDBACK_COPY: Record = { + success: [ + "Nice, setup made it through.", + "Tell us what felt great or rough:", + ], + cancelled: [ + "Sad to see setup stop. Was something going sideways?", + "Tell us so we can fix it:", + ], + failed: ["Setup hit a wall.", "Tell us what happened so we can fix it:"], +}; + +export function formatFeedbackHint(outcome: InitFeedbackOutcome): string { + return [...FEEDBACK_COPY[outcome], FEEDBACK_COMMANDS[outcome]].join("\n"); +} diff --git a/src/lib/init/formatters.ts b/src/lib/init/formatters.ts index 43cfeec20..57fcb9c2b 100644 --- a/src/lib/init/formatters.ts +++ b/src/lib/init/formatters.ts @@ -87,11 +87,9 @@ export function formatResult(result: WorkflowRunResult, ui: WizardUI): void { } ui.log.info("Please review the changes above before committing."); - ui.log.info( - "You're one of the first to try the new setup wizard! Run `sentry cli feedback` to let us know how it went." - ); ui.outro("Sentry SDK installed successfully!"); + ui.feedback("success"); } export function formatError(result: WorkflowRunResult, ui: WizardUI): void { @@ -125,4 +123,5 @@ export function formatError(result: WorkflowRunResult, ui: WizardUI): void { } ui.cancel("Setup failed"); + ui.feedback("failed"); } diff --git a/src/lib/init/interactive.ts b/src/lib/init/interactive.ts index e3bdcdf2c..82d9c4c6f 100644 --- a/src/lib/init/interactive.ts +++ b/src/lib/init/interactive.ts @@ -27,6 +27,16 @@ import type { } from "./types.js"; import type { WizardUI } from "./ui/types.js"; +function prependRequiredFeature( + features: string[], + hasRequired: boolean +): string[] { + if (!(hasRequired && !features.includes(REQUIRED_FEATURE))) { + return features; + } + return [REQUIRED_FEATURE, ...features]; +} + export async function handleInteractive( payload: InteractivePayload, options: InteractiveContext, @@ -133,16 +143,11 @@ async function handleMultiSelect( ...(hint ? { hint } : {}), }; }), - initialValues: optional.filter((f) => f === "performanceMonitoring"), required: false, }); const chosen = abortIfCancelled(selected); - if (hasRequired && !chosen.includes(REQUIRED_FEATURE)) { - chosen.unshift(REQUIRED_FEATURE); - } - - return { features: chosen }; + return { features: prependRequiredFeature(chosen, hasRequired) }; } async function handleConfirm( diff --git a/src/lib/init/preflight.ts b/src/lib/init/preflight.ts index 261bcd8db..5c282ff6b 100644 --- a/src/lib/init/preflight.ts +++ b/src/lib/init/preflight.ts @@ -72,6 +72,7 @@ async function withPreflightHandling( } catch (error) { if (error instanceof WizardCancelledError) { ui.cancel("Setup cancelled."); + ui.feedback("cancelled"); process.exitCode = 0; return null; } @@ -79,6 +80,7 @@ async function withPreflightHandling( const message = error instanceof Error ? error.message : String(error); ui.log.error(message); ui.cancel("Setup failed."); + ui.feedback("failed"); throw error instanceof WizardError ? error : new WizardError(message); } } diff --git a/src/lib/init/readiness.ts b/src/lib/init/readiness.ts index bc208822d..8d1393882 100644 --- a/src/lib/init/readiness.ts +++ b/src/lib/init/readiness.ts @@ -35,6 +35,7 @@ export async function checkReadiness(ui: WizardUI): Promise { ui.log.info("Run `sentry auth login` to authenticate."); ui.log.info("Check your network connection and try again."); ui.cancel("Setup failed"); + ui.feedback("failed"); throw new WizardError("Pre-flight checks failed"); } @@ -43,11 +44,12 @@ export async function checkReadiness(ui: WizardUI): Promise { ui.log.error("No authentication token found."); ui.log.info("Run `sentry auth login` to authenticate, then try again."); ui.cancel("Setup failed"); + ui.feedback("failed"); throw new WizardError("Not authenticated"); } if (apiOk) { - spin.stop("Prerequisites OK"); + spin.stop(""); } else { spin.stop("Warning", 2); ui.log.warn( diff --git a/src/lib/init/ui/factory.ts b/src/lib/init/ui/factory.ts index ff363d390..192c3bad4 100644 --- a/src/lib/init/ui/factory.ts +++ b/src/lib/init/ui/factory.ts @@ -34,7 +34,7 @@ */ import { LoggingUI } from "./logging-ui.js"; -import type { WizardUI } from "./types.js"; +import type { WelcomeOptions, WizardUI } from "./types.js"; /** * Inputs that affect UI selection. Mirrors the relevant subset of @@ -43,6 +43,12 @@ import type { WizardUI } from "./types.js"; export type UIFactoryOptions = { /** True when `--yes` (or `--dry-run`, which implies non-interactive) is set. */ yes: boolean; + /** + * Optional first Ink prompt to seed before `ink.render()` is called. + * This keeps the first painted frame from flashing the normal wizard + * shell before the welcome screen is ready. + */ + initialWelcome?: WelcomeOptions; /** * True when the user explicitly opted out of the new TUI via * `--no-tui`. Forces `LoggingUI`. @@ -118,7 +124,7 @@ export async function getUIAsync(opts: UIFactoryOptions): Promise { } try { const { createInkUI } = await import("./ink-ui.js"); - return await createInkUI(); + return await createInkUI({ initialWelcome: opts.initialWelcome }); } catch { // Fall through to LoggingUI so a missing/broken Ink install // doesn't take down the wizard. This branch should be diff --git a/src/lib/init/ui/ink-app.tsx b/src/lib/init/ui/ink-app.tsx index 3087625ac..a775e9da4 100644 --- a/src/lib/init/ui/ink-app.tsx +++ b/src/lib/init/ui/ink-app.tsx @@ -15,19 +15,19 @@ * │ [PromptArea] │ │ ▶ Install deps │ │ * │ │ │ ◻ Apply codemods │ │ * │ │ ╰────────────────────────╯ │ - * │ ────────────────────────────────────────────────────── │ - * │ ◆ Reading package.json │ * │ ● Status Files │ - * │ ←→ switch tab s toggle status │ + * │ ←→ switch tab │ + * │ Sentry For feedback run: ... │ * └─────────────────────────────────────────────────────────┘ * * Tab 1 (Status): Banner + logs + spinner + prompts + summary * Tab 2 (Files): Scrollable file read tree */ -import { Box, Text, useInput, useStdout } from "ink"; +import { Box, Text } from "ink"; import Spinner from "ink-spinner"; import { + useCallback, useEffect, useMemo, useRef, @@ -40,6 +40,20 @@ import { type FileTreeRow, flattenTree, } from "./file-tree.js"; +import { + type FrameTab, + getInkFrameMargin, + getInkFrameWidth, + InitRenderBoundary, + ShortcutFooter, + TabFooter, + useInkFrameSize, +} from "./ink-frame.js"; +import { + type ShortcutBinding, + ShortcutHintProvider, + useInkShortcuts, +} 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"; @@ -68,21 +82,13 @@ const COLOR_WARN = "#FDB81B"; const COLOR_ERROR = "#fe4144"; const COLOR_SUCCESS = "#83da90"; -const MIN_WIDTH = 80; -const MAX_WIDTH = 120; - -/** Number of collapsed status-bar lines visible. */ -const STATUS_COLLAPSED_COUNT = 2; -/** Number of expanded status-bar lines visible. */ -const STATUS_EXPANDED_COUNT = 10; - -const ICON_BY_SEVERITY: Record = +const ICON_BY_SEVERITY: Record = { info: { glyph: "●", color: COLOR_INFO }, warn: { glyph: "▲", color: COLOR_WARN }, error: { glyph: "✖", color: COLOR_ERROR }, success: { glyph: "✔", color: COLOR_SUCCESS }, - message: { glyph: " ", color: "white" }, + message: { glyph: " " }, }; const ICONS = { @@ -97,6 +103,56 @@ const ICONS = { bullet: "\u2022", } as const; +const DEFAULT_WELCOME_OPTIONS = { + title: "Sentry Init", + body: [ + "We'll use AI to inspect this project and configure Sentry.", + "You'll choose the setup before local files change.", + ], + punchline: "Continue to let Sentry use AI for setup.", +}; +const FEEDBACK_BANNER_TEXT = + 'For feedback run: sentry cli feedback "what worked or broke"'; +const FEEDBACK_BANNER_FG = "#FFFFFF"; + +function getIntroTopPadding(rows: number): number { + return Math.min(6, Math.max(1, Math.floor(rows * 0.18))); +} + +function truncateForBanner(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + if (maxLength <= 3) { + return text.slice(0, maxLength); + } + return `${text.slice(0, maxLength - 3)}...`; +} + +function formatBannerBrand(cliVersion: string | null): string { + return cliVersion ? `Sentry v${cliVersion}` : "Sentry"; +} + +function formatFeedbackBanner( + width: number, + cliVersion: string | null +): string { + const brand = formatBannerBrand(cliVersion); + const left = ` ${brand}`; + if (width <= left.length) { + return left.slice(0, Math.max(0, width)); + } + + const maxRight = Math.max(0, width - left.length - 1); + const clippedRight = truncateForBanner(FEEDBACK_BANNER_TEXT, maxRight); + if (clippedRight.length === 0) { + return left.padEnd(width, " "); + } + + const spacerWidth = Math.max(1, width - left.length - clippedRight.length); + return `${left}${" ".repeat(spacerWidth)}${clippedRight}`; +} + // ────────────────────────────── App entry ───────────────────────────── export type AppProps = { @@ -104,43 +160,27 @@ export type AppProps = { }; export function App({ store }: AppProps): React.ReactNode { + return ( + + + + ); +} + +function AppBody({ store }: AppProps): React.ReactNode { const snapshot = useSyncExternalStore( store.subscribe, store.getSnapshot, store.getSnapshot ); - const { columns, rows } = useTerminalSize(); + const { columns, rows } = useInkFrameSize(); const [activeTab, setActiveTab] = useState(0); - const width = getContentWidth(columns); - const contentHeight = Math.max(5, rows - 3); + const width = getInkFrameWidth(columns); + const contentHeight = Math.max(5, rows - 4); const isWide = width >= 80; - useInput((input, key) => { - if (key.ctrl && input === "c" && !snapshot.prompt) { - snapshot.requestCancel?.(); - return; - } - if (key.leftArrow && !snapshot.prompt) { - setActiveTab((prev) => Math.max(0, prev - 1)); - return; - } - if (key.rightArrow && !snapshot.prompt) { - setActiveTab((prev) => Math.min(1, prev + 1)); - return; - } - if (input === "s" && !snapshot.prompt) { - store.toggleStatusExpanded(); - } - }); - - const statusMessages = snapshot.statusMessages; - const visibleCount = snapshot.statusExpanded - ? STATUS_EXPANDED_COUNT - : STATUS_COLLAPSED_COUNT; - const visibleMessages = statusMessages.slice(-visibleCount); - - const tabs = useMemo( + const tabs = useMemo( () => [ { id: "status", label: "Status" }, { id: "files", label: "Files" }, @@ -148,38 +188,72 @@ export function App({ store }: AppProps): React.ReactNode { [] ); - const hints: KeyHint[] = useMemo(() => { - const h: KeyHint[] = [{ label: "\u2190\u2192", action: "switch tab" }]; - if (statusMessages.length > STATUS_COLLAPSED_COUNT) { - h.push({ label: "s", action: "toggle status" }); - } - if (activeTab === 1 && snapshot.filesRead.length > 0) { - h.push({ label: "\u2191\u2193", action: "scroll" }); - } - if (snapshot.prompt) { - if (snapshot.prompt.kind === "confirm") { - h.push({ label: "y/n", action: "answer" }); - } else { - h.push({ label: "\u2191\u2193", action: "navigate" }); - h.push({ label: "enter", action: "confirm" }); - h.push({ label: "esc", action: "cancel" }); - } - } - return h; - }, [ - statusMessages.length, - snapshot.prompt, - activeTab, - snapshot.filesRead.length, - ]); + const appShortcuts = useMemo(() => { + const bindings: ShortcutBinding[] = [ + { + key: "ctrl+c", + action: "cancel", + priority: 0, + showInFooter: false, + match: (input, key) => key.ctrl && input === "c", + run: () => snapshot.requestCancel?.(), + }, + { + key: "\u2190\u2192", + action: "switch tab", + priority: 10, + match: (_input, key) => key.leftArrow || key.rightArrow, + run: (_input, key) => { + if (key.leftArrow) { + setActiveTab((prev) => Math.max(0, prev - 1)); + } + if (key.rightArrow) { + setActiveTab((prev) => Math.min(tabs.length - 1, prev + 1)); + } + }, + }, + ]; + return bindings; + }, [snapshot.requestCancel, tabs.length]); + useInkShortcuts("init-app", appShortcuts, { + isActive: snapshot.layout === "workflow" && snapshot.prompt === null, + }); - const marginLeft = Math.max(0, Math.floor((columns - width) / 2)); + if (snapshot.layout === "intro" || snapshot.prompt?.kind === "welcome") { + const inner = ( + + + + + + + ); + return ( + {inner} + ); + } const inner = ( @@ -194,7 +268,6 @@ export function App({ store }: AppProps): React.ReactNode { {activeTab === 0 ? ( ) : null} - {visibleMessages.length > 0 ? ( - - ) : null} - - - + + + ); - return inner; -} - -// ────────────────────────────── Layout helpers ──────────────────────── - -function getContentWidth(terminalColumns: number): number { - if (terminalColumns < MIN_WIDTH) { - return terminalColumns; - } - return Math.min(MAX_WIDTH, terminalColumns); -} - -function useTerminalSize(): { columns: number; rows: number } { - const { stdout } = useStdout(); - const [size, setSize] = useState(() => ({ - columns: stdout?.columns ?? 80, - rows: stdout?.rows ?? 24, - })); - useEffect(() => { - if (!stdout) { - return; - } - const onResize = () => { - setSize({ - columns: stdout.columns ?? 80, - rows: stdout.rows ?? 24, - }); - }; - stdout.on("resize", onResize); - return () => { - stdout.off("resize", onResize); - }; - }, [stdout]); - return size; -} - -// ──────────────────────────── Status Bar ────────────────────────────── - -function StatusBar({ messages }: { messages: string[] }): React.ReactNode { return ( - - {messages.map((msg, i, arr) => { - const isCurrent = i === arr.length - 1; - // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression - const msgColor = isCurrent ? MUTED : MUTED_DIM; - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: positional status messages - - {isCurrent ? ICONS.diamond : ICONS.separator} {msg} - - ); - })} - - ); -} - -// ──────────────────────────── Tab Bar ───────────────────────────────── - -function TabBar({ - tabs, - activeTab, -}: { - tabs: { id: string; label: string }[]; - activeTab: number; -}): React.ReactNode { - return ( - - {tabs.map((tab, i) => { - const isActive = i === activeTab; - // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression - const tabColor = isActive ? ACCENT : MUTED_DIM; - return ( - - - {isActive ? ICONS.bullet : " "} {tab.label} - - - ); - })} - - ); -} - -// ────────────────────────── Keyboard Hints ──────────────────────────── - -type KeyHint = { label: string; action: string }; - -function KeyboardHintsBar({ hints }: { hints: KeyHint[] }): React.ReactNode { - return ( - - {hints.map((hint, i) => ( - - - {hint.label} - - {hint.action} - - ))} - + {inner} ); } @@ -382,34 +348,31 @@ function Sidebar({ // ─────────────────────────── Activity Pane ──────────────────────────── function ActivityPane({ - bannerRows, logs, spinner, prompt, summary, }: { - bannerRows: { content: string; color: string }[]; logs: LogEntry[]; spinner: SpinnerState; prompt: ActivePrompt | null; summary: WizardSummary | null; }): React.ReactNode { + const visibleLogs = + prompt === null + ? logs + : logs.filter( + (log) => log.severity === "warn" || log.severity === "error" + ); const hasContent = - logs.length > 0 || spinner.active || prompt !== null || summary !== null; + visibleLogs.length > 0 || + spinner.active || + prompt !== null || + summary !== null; return ( - {bannerRows.length > 0 ? ( - - {bannerRows.map((row, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: positional banner rows - - {row.content} - - ))} - - ) : null} - {!hasContent && bannerRows.length === 0 ? ( + {hasContent ? null : ( @@ -418,10 +381,10 @@ function ActivityPane({ Initializing wizard... - ) : null} - {logs.length > 0 ? ( + )} + {visibleLogs.length > 0 ? ( - {logs.map((log) => ( + {visibleLogs.map((log) => ( ))} @@ -495,6 +458,271 @@ function OverlayPanel({ // ──────────────────────────── Components ────────────────────────────── +type ChoiceRow = { + value: T; + label: string; + hint?: string; +}; + +function useChoiceNavigation({ + choices, + onChoose, + onCancel, + scope, +}: { + choices: ChoiceRow[]; + onChoose: (value: T) => void; + onCancel: () => void; + scope: string; +}): number { + const [highlighted, setHighlighted] = useState(0); + const totalCount = choices.length; + + const shortcuts = useMemo( + () => [ + { + key: "\u2191\u2193", + action: "navigate", + priority: 40, + match: (_input, key) => key.upArrow || key.downArrow, + run: (_input, key) => { + if (key.upArrow) { + setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + return; + } + setHighlighted((idx) => (idx + 1) % totalCount); + }, + }, + { + key: "enter", + action: "select", + priority: 41, + match: (_input, key) => key.return, + run: () => { + const current = choices[highlighted]; + if (current) { + onChoose(current.value); + } + }, + }, + { + key: "esc", + action: "cancel", + priority: 42, + match: (input, key) => key.escape || (key.ctrl && input === "c"), + run: onCancel, + }, + ], + [choices, highlighted, onCancel, onChoose, totalCount] + ); + useInkShortcuts(scope, shortcuts); + + return highlighted; +} + +function ActionList({ + centered = false, + choices, + highlighted, +}: { + centered?: boolean; + choices: ChoiceRow[]; + highlighted: number; +}): React.ReactNode { + const listWidth = centered ? "100%" : undefined; + return ( + + {choices.map((choice, index) => { + const isCursor = index === highlighted; + // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression + const labelColor = isCursor ? undefined : MUTED; + if (centered) { + return ( + + + {isCursor ? `${ICONS.triangleSmallRight} ` : " "} + + + {choice.label} + + {choice.hint ? ( + {choice.hint} + ) : null} + + ); + } + return ( + + + + {isCursor ? ICONS.triangleSmallRight : " "} + + + + {choice.label} + + {choice.hint ? {choice.hint} : null} + + ); + })} + + ); +} + +function FeedbackBanner({ + cliVersion, + width, +}: { + cliVersion: string | null; + width: number; +}): React.ReactNode { + return ( + + + {formatFeedbackBanner(width, cliVersion)} + + + ); +} + +// ─────────────────────────── Intro Screen ──────────────────────────── + +function IntroScreen({ + bannerRows, + logs, + prompt, + spinner, + width, +}: { + bannerRows: { content: string; color: string }[]; + logs: LogEntry[]; + prompt: ActivePrompt | null; + spinner: SpinnerState; + width: number; +}): React.ReactNode { + const welcomePrompt = prompt?.kind === "welcome" ? prompt : null; + const options = welcomePrompt?.options ?? DEFAULT_WELCOME_OPTIONS; + const bodyWidth = Math.min(width, 84); + + return ( + + + {bannerRows.map((row, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional banner rows + + {row.content} + + ))} + + {welcomePrompt ? ( + + {options.body.map((line) => ( + {line} + ))} + + ) : null} + {welcomePrompt ? ( + <> + + {options.punchline} + + + + ) : null} + {welcomePrompt ? null : ( + + )} + + ); +} + +function WelcomeActions({ + prompt, +}: { + prompt: Extract; +}): React.ReactNode { + const choices = useMemo[]>( + () => [ + { value: "continue", label: "Continue" }, + { value: "cancel", label: "Cancel" }, + ], + [] + ); + const onChoose = useCallback( + (value: "continue" | "cancel") => { + if (value === "cancel") { + prompt.resolve(null); + return; + } + prompt.resolve("continue"); + }, + [prompt] + ); + const highlighted = useChoiceNavigation({ + choices, + onChoose, + onCancel: () => prompt.resolve(null), + scope: "welcome-screen", + }); + + return ( + + + + ); +} + +function IntroPreflightContent({ + logs, + prompt, + spinner, +}: { + logs: LogEntry[]; + prompt: ActivePrompt | null; + spinner: SpinnerState; +}): React.ReactNode { + const visibleLogs = prompt ? [] : logs.slice(-5); + const hasContent = + visibleLogs.length > 0 || spinner.active || prompt !== null; + + if (!hasContent) { + return null; + } + + const promptContent = prompt ? ( + + + + ) : null; + + return ( + + {visibleLogs.length > 0 ? ( + + {visibleLogs.map((log) => ( + + ))} + + ) : null} + {spinner.active ? ( + + + + ) : null} + {promptContent} + + ); +} + function LogLine({ entry }: { entry: LogEntry }): React.ReactNode { const { glyph, color } = ICON_BY_SEVERITY[entry.severity]; return ( @@ -653,14 +881,13 @@ function ProgressRow({ entry }: { entry: StepEntry }): React.ReactNode { function progressStyle(entry: StepEntry): { glyph: string; glyphColor: string; - labelColor: string; + labelColor?: string; dimLabel: boolean; } { if (entry.status === "in_progress") { return { glyph: ICONS.triangleRight, glyphColor: PRIMARY, - labelColor: "white", dimLabel: false, }; } @@ -739,53 +966,73 @@ function FilesPanel({ } }, [totalRows, viewport, pinnedToBottom]); - useInput( - (_input, key) => { - if (!canScroll) { - return; - } - if (key.upArrow) { - setPinnedToBottom(false); - setOffset((current) => Math.min(maxOffset, current + 1)); - return; - } - if (key.downArrow) { - setOffset((current) => { - const next = Math.max(0, current - 1); - if (next === 0) { - setPinnedToBottom(true); + const fileShortcuts = useMemo(() => { + if (!canScroll) { + return []; + } + return [ + { + key: "\u2191\u2193", + action: "scroll", + priority: 30, + match: (_input, key) => key.upArrow || key.downArrow, + run: (_input, key) => { + if (key.upArrow) { + setPinnedToBottom(false); + setOffset((current) => Math.min(maxOffset, current + 1)); + return; } - return next; - }); - return; - } - if (key.pageUp) { - setPinnedToBottom(false); - setOffset((current) => Math.min(maxOffset, current + viewport)); - return; - } - if (key.pageDown) { - setOffset((current) => { - const next = Math.max(0, current - viewport); - if (next === 0) { - setPinnedToBottom(true); + setOffset((current) => { + const next = Math.max(0, current - 1); + if (next === 0) { + setPinnedToBottom(true); + } + return next; + }); + }, + }, + { + key: "page", + action: "scroll", + priority: 31, + showInFooter: false, + match: (_input, key) => key.pageUp || key.pageDown, + run: (_input, key) => { + if (key.pageUp) { + setPinnedToBottom(false); + setOffset((current) => Math.min(maxOffset, current + viewport)); + return; } - return next; - }); - return; - } - if (key.home) { - setPinnedToBottom(false); - setOffset(maxOffset); - return; - } - if (key.end) { - setPinnedToBottom(true); - setOffset(0); - } - }, - { isActive: !hasActivePrompt } - ); + setOffset((current) => { + const next = Math.max(0, current - viewport); + if (next === 0) { + setPinnedToBottom(true); + } + return next; + }); + }, + }, + { + key: "home/end", + action: "jump", + priority: 32, + showInFooter: false, + match: (_input, key) => key.home || key.end, + run: (_input, key) => { + if (key.home) { + setPinnedToBottom(false); + setOffset(maxOffset); + return; + } + setPinnedToBottom(true); + setOffset(0); + }, + }, + ]; + }, [canScroll, maxOffset, viewport]); + useInkShortcuts("files-panel", fileShortcuts, { + isActive: !hasActivePrompt && canScroll, + }); if (filesRead.length === 0) { return null; @@ -882,10 +1129,10 @@ function ReadTreeLine({ row }: { row: FileTreeRow }): React.ReactNode { function readStatusStyle(status: FileTreeRow["status"]): { glyph: string; glyphColor: string; - labelColor: string; + labelColor?: string; } { if (status === "reading") { - return { glyph: "\u25D0", glyphColor: PRIMARY, labelColor: "white" }; + return { glyph: "\u25D0", glyphColor: PRIMARY }; } return { glyph: "\u2713", glyphColor: COLOR_SUCCESS, labelColor: MUTED }; } @@ -979,74 +1226,117 @@ function changedFileStyle(action: string): { glyph: string; color: string } { // ─────────────────────────────── Prompts ────────────────────────────── -function PromptArea({ prompt }: { prompt: ActivePrompt }): React.ReactNode { +type PromptAlignment = "start" | "center"; +type SelectPromptOptionData = Extract< + ActivePrompt, + { kind: "select" } +>["options"][number]; +type MultiSelectPromptOptionData = Extract< + ActivePrompt, + { kind: "multiselect" } +>["options"][number]; + +function PromptArea({ + alignment = "start", + prompt, +}: { + alignment?: PromptAlignment; + prompt: ActivePrompt; +}): React.ReactNode { if (prompt.kind === "select") { - return ; + return ; } if (prompt.kind === "confirm") { - return ; + return ; + } + if (prompt.kind === "multiselect") { + return ; } - return ; + return null; } function SelectPrompt({ + alignment, prompt, }: { + alignment: PromptAlignment; prompt: Extract; }): React.ReactNode { + const isCentered = alignment === "center"; + const promptWidth = isCentered ? "100%" : undefined; const totalCount = prompt.options.length; const [highlighted, setHighlighted] = useState(() => Math.min(Math.max(prompt.initialIndex, 0), Math.max(0, totalCount - 1)) ); - useInput((input, key) => { - if (key.upArrow) { - setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); - return; - } - if (key.downArrow) { - setHighlighted((idx) => (idx + 1) % totalCount); - return; - } - if (key.escape || (key.ctrl && input === "c")) { - prompt.resolve(null); - return; - } - if (key.return) { - const current = prompt.options[highlighted]; - if (current) { - prompt.resolve(current.value); - } - } - }); + const shortcuts = useMemo( + () => [ + { + key: "\u2191\u2193", + action: "navigate", + priority: 40, + match: (_input, key) => key.upArrow || key.downArrow, + run: (_input, key) => { + if (key.upArrow) { + setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + return; + } + setHighlighted((idx) => (idx + 1) % totalCount); + }, + }, + { + key: "enter", + action: "confirm", + priority: 41, + match: (_input, key) => key.return, + run: () => { + const current = prompt.options[highlighted]; + if (current) { + prompt.resolve(current.value); + } + }, + }, + { + key: "esc", + action: "cancel", + priority: 42, + match: (input, key) => key.escape || (key.ctrl && input === "c"), + run: () => prompt.resolve(null), + }, + ], + [highlighted, prompt, totalCount] + ); + useInkShortcuts("select-prompt", shortcuts); return ( - - - - {ICONS.diamondOpen} - - {prompt.message} - - + + {isCentered ? ( + + {prompt.message} + + ) : ( + + + {ICONS.diamondOpen} + + {prompt.message} + + )} + {prompt.options.map((option, idx) => { const isCursor = idx === highlighted; - // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression - const labelColor = isCursor ? "white" : MUTED; return ( - - - - {isCursor ? ICONS.triangleSmallRight : " "} - - - - {option.label} - - {option.hint !== undefined && option.hint !== "" ? ( - {option.hint} - ) : null} - + ); })} @@ -1054,75 +1344,153 @@ function SelectPrompt({ ); } +function SelectPromptOptionRow({ + centered, + isCursor, + option, +}: { + centered: boolean; + isCursor: boolean; + option: SelectPromptOptionData; +}): React.ReactNode { + const labelColor = isCursor ? undefined : MUTED; + if (centered) { + return ( + + + {isCursor ? `${ICONS.triangleSmallRight} ` : " "} + + + {option.label} + + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); + } + return ( + + + {isCursor ? ICONS.triangleSmallRight : " "} + + + {option.label} + + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); +} + function ConfirmPrompt({ + alignment, prompt, }: { + alignment: PromptAlignment; prompt: Extract; }): React.ReactNode { - useInput((input, key) => { - if (input === "y" || input === "Y") { - prompt.resolve(true); - return; - } - if (input === "n" || input === "N") { - prompt.resolve(false); - return; - } - if (key.return) { - prompt.resolve(prompt.initialValue); - return; - } - if (key.escape || (key.ctrl && input === "c")) { - prompt.resolve(null); - } - }); + const isCentered = alignment === "center"; + const promptWidth = isCentered ? "100%" : undefined; + const shortcuts = useMemo( + () => [ + { + key: "y/n", + action: "answer", + priority: 40, + match: (input) => + input === "y" || input === "Y" || input === "n" || input === "N", + run: (input) => prompt.resolve(input === "y" || input === "Y"), + }, + { + key: "enter", + action: "default", + priority: 41, + showInFooter: false, + match: (_input, key) => key.return, + run: () => prompt.resolve(prompt.initialValue), + }, + { + key: "esc", + action: "cancel", + priority: 42, + showInFooter: false, + match: (input, key) => key.escape || (key.ctrl && input === "c"), + run: () => prompt.resolve(null), + }, + ], + [prompt] + ); + useInkShortcuts("confirm-prompt", shortcuts); const yLabel = prompt.initialValue ? "Y" : "y"; const nLabel = prompt.initialValue ? "n" : "N"; return ( - - - - {ICONS.diamondOpen} - - {prompt.message} - - ({yLabel}/{nLabel}) - - + + {isCentered ? ( + + {prompt.message} + + ({yLabel}/{nLabel}) + + + ) : ( + + + {ICONS.diamondOpen} + + {prompt.message} + + ({yLabel}/{nLabel}) + + + )} ); } function MultiSelectPrompt({ + alignment, prompt, }: { + alignment: PromptAlignment; prompt: Extract; }): React.ReactNode { + const isCentered = alignment === "center"; + const promptWidth = isCentered ? "100%" : undefined; const [selected, setSelected] = useState>( () => new Set(prompt.initialSelected) ); const [highlighted, setHighlighted] = useState(0); const totalCount = prompt.options.length; - const toggleAt = (idx: number) => { - const current = prompt.options[idx]; - if (!current) { - return; - } - setSelected((prev) => { - const next = new Set(prev); - if (next.has(current.value)) { - next.delete(current.value); - } else { - next.add(current.value); + const toggleAt = useCallback( + (idx: number) => { + const current = prompt.options[idx]; + if (!current) { + return; } - return next; - }); - }; + setSelected((prev) => { + const next = new Set(prev); + if (next.has(current.value)) { + next.delete(current.value); + } else { + next.add(current.value); + } + return next; + }); + }, + [prompt.options] + ); - const commit = () => { + const commit = useCallback(() => { if (prompt.required && selected.size === 0) { return; } @@ -1130,80 +1498,154 @@ function MultiSelectPrompt({ .map((option) => option.value) .filter((value) => selected.has(value)); prompt.resolve(ordered); - }; + }, [prompt, selected]); - useInput((input, key) => { - if (key.upArrow) { - setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); - return; - } - if (key.downArrow) { - setHighlighted((idx) => (idx + 1) % totalCount); - return; - } - if (key.escape || (key.ctrl && input === "c")) { - prompt.resolve(null); - return; - } - if (input === " ") { - toggleAt(highlighted); - return; - } - if (input === "a") { - setSelected((prev) => { - if (prev.size === totalCount) { - return new Set(); - } - return new Set(prompt.options.map((o) => o.value)); - }); - return; - } - if (key.return) { - commit(); - } - }); + const shortcuts = useMemo( + () => [ + { + key: "\u2191\u2193", + action: "navigate", + priority: 40, + match: (_input, key) => key.upArrow || key.downArrow, + run: (_input, key) => { + if (key.upArrow) { + setHighlighted((idx) => (idx === 0 ? totalCount - 1 : idx - 1)); + return; + } + setHighlighted((idx) => (idx + 1) % totalCount); + }, + }, + { + key: "space", + action: "toggle", + priority: 41, + match: (input) => input === " ", + run: () => toggleAt(highlighted), + }, + { + key: "a", + action: "all", + priority: 42, + match: (input) => input === "a", + run: () => { + setSelected((prev) => { + if (prev.size === totalCount) { + return new Set(); + } + return new Set(prompt.options.map((option) => option.value)); + }); + }, + }, + { + key: "enter", + action: "confirm", + priority: 43, + match: (_input, key) => key.return, + run: commit, + }, + { + key: "esc", + action: "cancel", + priority: 44, + match: (input, key) => key.escape || (key.ctrl && input === "c"), + run: () => prompt.resolve(null), + }, + ], + [commit, highlighted, prompt, toggleAt, totalCount] + ); + useInkShortcuts("multiselect-prompt", shortcuts); + const shortcutText = `space toggle ${ICONS.bullet} a all ${ICONS.bullet} enter confirm ${ICONS.bullet} esc cancel`; + const selectedCount = `${selected.size}/${totalCount}`; return ( - - - - {ICONS.diamondOpen} - - {prompt.message} - - - - space toggle {ICONS.bullet} a all {ICONS.bullet} enter confirm{" "} - {ICONS.bullet} esc cancel - - - {" "} - {selected.size}/{totalCount} - - - + + {isCentered ? ( + + {prompt.message} + + ) : ( + + + + {ICONS.diamondOpen} + + {prompt.message} + + {selectedCount} + + )} + {isCentered ? ( + + {shortcutText} + {selectedCount} + + ) : null} + {prompt.options.map((option, idx) => { const isSelected = selected.has(option.value); const isCursor = idx === highlighted; - const marker = isSelected ? ICONS.squareFilled : ICONS.squareOpen; - // biome-ignore lint/nursery/noLeakedRender: variable assignment, not JSX expression - const markerColor = isSelected ? COLOR_SUCCESS : MUTED_DIM; return ( - - - - {isCursor ? ICONS.triangleSmallRight : " "} - - - {marker} - {option.label} - {option.hint !== undefined && option.hint !== "" ? ( - {option.hint} - ) : null} - + ); })} ); } + +function MultiSelectPromptOptionRow({ + centered, + isCursor, + isSelected, + option, +}: { + centered: boolean; + isCursor: boolean; + isSelected: boolean; + option: MultiSelectPromptOptionData; +}): React.ReactNode { + const marker = isSelected ? ICONS.squareFilled : ICONS.squareOpen; + const markerColor = isSelected ? COLOR_SUCCESS : MUTED_DIM; + if (centered) { + return ( + + + {isCursor ? `${ICONS.triangleSmallRight} ` : " "} + + {marker} + {option.label} + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); + } + return ( + + + {isCursor ? ICONS.triangleSmallRight : " "} + + {marker} + {option.label} + {option.hint !== undefined && option.hint !== "" ? ( + {option.hint} + ) : null} + + ); +} diff --git a/src/lib/init/ui/ink-frame.tsx b/src/lib/init/ui/ink-frame.tsx new file mode 100644 index 000000000..b23497d33 --- /dev/null +++ b/src/lib/init/ui/ink-frame.tsx @@ -0,0 +1,165 @@ +import { Box, Text, useWindowSize } from "ink"; +import { Component, type ReactNode } from "react"; +import { useShortcutHints } from "./ink-shortcuts.js"; + +const MIN_FRAME_WIDTH = 80; +const MAX_FRAME_WIDTH = 120; + +export type FrameTab = { + id: string; + label: string; +}; + +export function getInkFrameWidth(terminalColumns: number): number { + if (terminalColumns < MIN_FRAME_WIDTH) { + return terminalColumns; + } + return Math.min(MAX_FRAME_WIDTH, terminalColumns); +} + +export function getInkFrameMargin( + terminalColumns: number, + frameWidth: number +): number { + return Math.max(0, Math.floor((terminalColumns - frameWidth) / 2)); +} + +export function useInkFrameSize(): { columns: number; rows: number } { + return useWindowSize(); +} + +export function StatusHistory({ + messages, + borderColor, + currentColor, + historyColor, + currentGlyph, + historyGlyph, +}: { + messages: string[]; + borderColor: string; + currentColor: string; + historyColor: string; + currentGlyph: string; + historyGlyph: string; +}): React.ReactNode { + return ( + + {messages.map((message, index, allMessages) => { + const isLatest = index === allMessages.length - 1; + let glyph = historyGlyph; + let color = historyColor; + if (isLatest) { + glyph = currentGlyph; + color = currentColor; + } + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: positional status messages + + {glyph} {message} + + ); + })} + + ); +} + +export function TabFooter({ + tabs, + activeTab, + activeColor, + inactiveColor, + activeGlyph, +}: { + tabs: FrameTab[]; + activeTab: number; + activeColor: string; + inactiveColor: string; + activeGlyph: string; +}): React.ReactNode { + return ( + + {tabs.map((tab, index) => { + const isActive = index === activeTab; + let color = inactiveColor; + let glyph = " "; + if (isActive) { + color = activeColor; + glyph = activeGlyph; + } + return ( + + + {glyph} {tab.label} + + + ); + })} + + ); +} + +export function ShortcutFooter({ color }: { color: string }): React.ReactNode { + const hints = useShortcutHints(); + + return ( + + {hints.map((hint, index) => ( + + + {hint.key} + + {hint.action} + + ))} + + ); +} + +type RenderErrorBoundaryProps = { + children: ReactNode; + errorColor: string; +}; + +type RenderErrorBoundaryState = { + message: string | null; +}; + +export class InitRenderBoundary extends Component< + RenderErrorBoundaryProps, + RenderErrorBoundaryState +> { + override state: RenderErrorBoundaryState = { message: null }; + + static getDerivedStateFromError(error: Error): RenderErrorBoundaryState { + return { message: error.message || "Unknown render error" }; + } + + override render(): ReactNode { + if (this.state.message) { + return ( + + + Sentry init UI hit a rendering error. + + {this.state.message} + + ); + } + + return this.props.children; + } +} diff --git a/src/lib/init/ui/ink-report.ts b/src/lib/init/ui/ink-report.ts new file mode 100644 index 000000000..4ab7820e2 --- /dev/null +++ b/src/lib/init/ui/ink-report.ts @@ -0,0 +1,143 @@ +import chalk from "chalk"; +import { buildFileTree, flattenTree } from "./file-tree.js"; +import type { WizardSummary } from "./types.js"; + +// Brand palette mirrored from `ink-app.tsx` so the post-dispose +// success/failure echo (rendered via chalk after Ink unmounts) feels +// like a continuation of the live screen. +const REPORT_MUTED = "#898294"; +const REPORT_SUCCESS = "#83da90"; +const REPORT_ERROR = "#fe4144"; +const REPORT_WARN = "#FDB81B"; + +/** Splits on `: ` to separate error label from detail. */ +const ERROR_SPLIT_RE = /:\s+/; + +/** + * Build the chalk-formatted failure report shown after alternate + * screen exit. Includes up to 5 recent error log entries with + * structured formatting for readability. + */ +export function formatFailureReport( + message: string, + logs: readonly { severity: string; text: string }[], + feedbackHint?: string +): string { + const icon = chalk.hex(REPORT_ERROR)("\u2716"); + const lines: string[] = [ + `\n${icon} ${chalk.hex(REPORT_ERROR).bold(message)}`, + ]; + const errorLogs = logs.filter( + (entry) => + entry.severity === "error" && + entry.text !== message && + entry.text !== "Failed" + ); + if (errorLogs.length > 0) { + lines.push(""); + } + for (const entry of errorLogs.slice(-5)) { + formatErrorEntry(entry.text, lines); + } + appendFeedbackHint(lines, feedbackHint); + return lines.join("\n"); +} + +export function formatSuccessReport( + message: string, + summary: WizardSummary | undefined, + feedbackHint?: string +): string { + const successIcon = chalk.hex(REPORT_SUCCESS)("✔"); + const lines: string[] = ["", `${successIcon} ${chalk.bold(message)}`]; + if (summary && summary.fields.length > 0) { + lines.push(""); + const labelWidth = Math.max( + ...summary.fields.map((field) => field.label.length) + ); + for (const field of summary.fields) { + const label = chalk.hex(REPORT_MUTED)(field.label.padEnd(labelWidth)); + lines.push(` ${label} ${field.value}`); + } + } + if (summary?.changedFiles && summary.changedFiles.length > 0) { + lines.push(""); + lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); + const tree = buildFileTree(summary.changedFiles); + for (const row of flattenTree(tree)) { + lines.push(formatTreeRowChalk(row)); + } + } + appendFeedbackHint(lines, feedbackHint); + return lines.join("\n"); +} + +function appendFeedbackHint(lines: string[], feedbackHint?: string): void { + if (!feedbackHint) { + return; + } + lines.push(""); + for (const line of feedbackHint.split("\n")) { + lines.push(` ${chalk.hex(REPORT_MUTED)(line)}`); + } + lines.push(""); +} + +/** + * Format a single error log entry into indented report lines. + * Splits on newlines first, then separates the first segment + * (bold red) from subsequent detail (muted) on each line. + */ +function formatErrorEntry(text: string, out: string[]): void { + const rawLines = text + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); + if (rawLines.length === 0) { + return; + } + const first = rawLines[0] ?? ""; + const parts = first.split(ERROR_SPLIT_RE); + out.push(` ${chalk.hex(REPORT_ERROR).bold(parts[0] ?? "")}`); + for (const part of parts.slice(1)) { + out.push(` ${chalk.hex(REPORT_MUTED)(part)}`); + } + for (const line of rawLines.slice(1)) { + out.push(` ${chalk.hex(REPORT_MUTED)(line)}`); + } +} + +/** + * Colored glyph for a changed-files row in the post-dispose report. + * The plain ASCII variant lives in `logging-ui.ts` for the + * non-interactive CI path. + */ +function changedFileGlyphColored(action: string): string { + if (action === "create") { + return chalk.hex(REPORT_SUCCESS)("+"); + } + if (action === "delete") { + return chalk.hex(REPORT_ERROR)("−"); + } + return chalk.hex(REPORT_WARN)("~"); +} + +/** + * Render a single `FileTreeRow` for the post-dispose report. + * Directories show only the box-drawing branch + label; files add + * the action glyph (colored). + */ +function formatTreeRowChalk(row: { + prefix: string; + branch: string; + kind: "file" | "directory"; + label: string; + action?: string; +}): string { + const branch = chalk.hex(REPORT_MUTED)(`${row.prefix}${row.branch}`); + if (row.kind === "directory") { + return ` ${branch} ${row.label}`; + } + const glyph = changedFileGlyphColored(row.action ?? "modify"); + return ` ${branch} ${glyph} ${row.label}`; +} diff --git a/src/lib/init/ui/ink-shortcuts.tsx b/src/lib/init/ui/ink-shortcuts.tsx new file mode 100644 index 000000000..c9761f684 --- /dev/null +++ b/src/lib/init/ui/ink-shortcuts.tsx @@ -0,0 +1,177 @@ +import { useInput } from "ink"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +type InkKey = Parameters[0]>[1]; + +const DEFAULT_SHORTCUT_PRIORITY = 100; + +export type ShortcutHint = { + key: string; + action: string; + priority?: number; +}; + +export type ShortcutBinding = ShortcutHint & { + match: (input: string, key: InkKey) => boolean; + run: (input: string, key: InkKey) => void; + showInFooter?: boolean; +}; + +type ShortcutRegistry = { + setScope: (scope: string, hints: ShortcutHint[]) => void; + clearScope: (scope: string) => void; +}; + +const ShortcutRegistryContext = createContext(null); +const ShortcutHintsContext = createContext([]); + +function shortcutPriority(hint: ShortcutHint): number { + return hint.priority ?? DEFAULT_SHORTCUT_PRIORITY; +} + +function shortcutKey(hint: ShortcutHint): string { + return `${hint.key}\u0000${hint.action}`; +} + +function shortcutSignature(hints: readonly ShortcutHint[]): string { + return hints + .map((hint) => `${shortcutPriority(hint)}:${hint.key}:${hint.action}`) + .join("\n"); +} + +export function arrangeShortcutHints( + hints: readonly ShortcutHint[] +): ShortcutHint[] { + const seen = new Set(); + const unique: Array<{ hint: ShortcutHint; index: number }> = []; + + for (const hint of hints) { + const key = shortcutKey(hint); + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push({ hint, index: unique.length }); + } + + unique.sort((left, right) => { + const priorityDelta = + shortcutPriority(left.hint) - shortcutPriority(right.hint); + if (priorityDelta !== 0) { + return priorityDelta; + } + return left.index - right.index; + }); + + return unique.map(({ hint }) => hint); +} + +export function ShortcutHintProvider({ + children, +}: { + children: ReactNode; +}): React.ReactNode { + const scopes = useRef(new Map()); + const signature = useRef(""); + const [hints, setHints] = useState([]); + + const refresh = useCallback(() => { + const allHints: ShortcutHint[] = []; + for (const scopedHints of scopes.current.values()) { + allHints.push(...scopedHints); + } + const nextHints = arrangeShortcutHints(allHints); + const nextSignature = shortcutSignature(nextHints); + if (nextSignature === signature.current) { + return; + } + signature.current = nextSignature; + setHints(nextHints); + }, []); + + const setScope = useCallback( + (scope: string, scopedHints: ShortcutHint[]) => { + if (scopedHints.length === 0) { + scopes.current.delete(scope); + } else { + scopes.current.set(scope, scopedHints); + } + refresh(); + }, + [refresh] + ); + + const clearScope = useCallback( + (scope: string) => { + scopes.current.delete(scope); + refresh(); + }, + [refresh] + ); + + const registry = useMemo( + () => ({ setScope, clearScope }), + [setScope, clearScope] + ); + + return ( + + + {children} + + + ); +} + +export function useShortcutHints(): ShortcutHint[] { + return useContext(ShortcutHintsContext); +} + +export function useInkShortcuts( + scope: string, + bindings: readonly ShortcutBinding[], + options: { isActive?: boolean } = {} +): void { + const registry = useContext(ShortcutRegistryContext); + const isActive = options.isActive ?? true; + + const footerHints = useMemo(() => { + if (!isActive) { + return []; + } + return bindings + .filter((binding) => binding.showInFooter !== false) + .map(({ key, action, priority }) => ({ key, action, priority })); + }, [bindings, isActive]); + + useEffect(() => { + if (!registry) { + return; + } + registry.setScope(scope, footerHints); + return () => { + registry.clearScope(scope); + }; + }, [registry, scope, footerHints]); + + useInput( + (input, key) => { + for (const binding of bindings) { + if (binding.match(input, key)) { + binding.run(input, key); + return; + } + } + }, + { isActive } + ); +} diff --git a/src/lib/init/ui/ink-ui.ts b/src/lib/init/ui/ink-ui.ts index 65e09a01a..eb86013d6 100644 --- a/src/lib/init/ui/ink-ui.ts +++ b/src/lib/init/ui/ink-ui.ts @@ -46,12 +46,12 @@ import { openSync } from "node:fs"; import { ReadStream } from "node:tty"; -import chalk from "chalk"; +import { CLI_VERSION } from "../../constants.js"; import { stripAnsi } from "../../formatters/plain-detect.js"; -import { buildFileTree, flattenTree } from "./file-tree.js"; +import { formatFeedbackHint, type InitFeedbackOutcome } from "../feedback.js"; +import { formatFailureReport, formatSuccessReport } from "./ink-report.js"; import { LEARN_SEQUENCE } from "./learn-content.js"; import { SENTRY_TIPS } from "./sentry-tips.js"; -import { detectColorScheme } from "./theme.js"; import { CANCELLED, type Cancelled, @@ -60,77 +60,25 @@ import { type SelectOptions, type SpinnerExitCode, type SpinnerHandle, + type WelcomeOptions, type WizardLog, type WizardSummary, type WizardUI, } from "./types.js"; import { WizardStore } from "./wizard-store.js"; -// Brand palette mirrored from `ink-app.tsx` so the post-dispose -// success/failure echo (rendered via chalk after Ink unmounts) feels -// like a continuation of the live screen. -const REPORT_MUTED = "#898294"; -const REPORT_SUCCESS = "#83da90"; -const REPORT_ERROR = "#fe4144"; -const REPORT_WARN = "#FDB81B"; - -/** Splits on `: ` to separate error label from detail. */ -const ERROR_SPLIT_RE = /:\s+/; - -/** - * Build the chalk-formatted failure report shown after alternate - * screen exit. Includes up to 5 recent error log entries with - * structured formatting for readability. - */ -function formatFailureReport( - message: string, - logs: readonly { severity: string; text: string }[] -): string { - const icon = chalk.hex(REPORT_ERROR)("\u2716"); - const lines: string[] = [ - `\n${icon} ${chalk.hex(REPORT_ERROR).bold(message)}`, - ]; - const errorLogs = logs.filter( - (entry) => - entry.severity === "error" && - entry.text !== message && - entry.text !== "Failed" - ); - if (errorLogs.length > 0) { - lines.push(""); - } - for (const entry of errorLogs.slice(-5)) { - formatErrorEntry(entry.text, lines); - } - return lines.join("\n"); -} +type CreateInkUIOptions = { + initialWelcome?: WelcomeOptions; +}; -/** - * Format a single error log entry into indented report lines. - * Splits on newlines first, then separates the first segment - * (bold red) from subsequent detail (muted) on each line. - */ -function formatErrorEntry(text: string, out: string[]): void { - const rawLines = text - .split("\n") - .map((l) => l.trim()) - .filter(Boolean); - if (rawLines.length === 0) { - return; - } - const first = rawLines[0] ?? ""; - const parts = first.split(ERROR_SPLIT_RE); - out.push(` ${chalk.hex(REPORT_ERROR).bold(parts[0] ?? "")}`); - for (const part of parts.slice(1)) { - out.push(` ${chalk.hex(REPORT_MUTED)(part)}`); - } - for (const line of rawLines.slice(1)) { - out.push(` ${chalk.hex(REPORT_MUTED)(line)}`); - } -} +type PendingWelcome = { + promise: Promise<"continue" | Cancelled>; + resolve: (value: "continue" | Cancelled) => void; + settled: boolean; +}; /** Tip rotation cadence in the sidebar — slow enough to read each tip. */ -const TIP_ROTATE_INTERVAL_MS = 8000; +const TIP_ROTATE_INTERVAL_MS = 15_000; /** Sentry brand purple — matches `src/lib/banner.ts`. */ const BANNER_GRADIENT = [ @@ -151,6 +99,48 @@ const BANNER_ROWS = [ " ╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ", ]; +function sanitizeWelcomeOptions(opts: WelcomeOptions): WelcomeOptions { + return { + title: stripAnsi(opts.title), + body: opts.body.map(stripAnsi), + punchline: stripAnsi(opts.punchline), + }; +} + +function createPendingWelcome(): PendingWelcome { + let resolve!: (value: "continue" | Cancelled) => void; + const pending: PendingWelcome = { + promise: new Promise<"continue" | Cancelled>((r) => { + resolve = r; + }), + resolve: (value) => { + if (pending.settled) { + return; + } + pending.settled = true; + resolve(value); + }, + settled: false, + }; + return pending; +} + +function seedWelcomePrompt( + store: WizardStore, + opts: WelcomeOptions, + pending: PendingWelcome +): void { + store.setLayout("intro"); + store.setPrompt({ + kind: "welcome", + options: sanitizeWelcomeOptions(opts), + resolve: (value) => { + store.setPrompt(null); + pending.resolve(value === null ? CANCELLED : value); + }, + }); +} + /** * Log severities recognised by InkUI. Mirrors the keys of * `ICON_BY_SEVERITY` in `ink-app.tsx`. @@ -224,7 +214,9 @@ function openFreshTtyForInk(): ReadStream | null { * bridge instance. Throws if Ink can't be loaded (e.g. missing peer * deps). */ -export async function createInkUI(): Promise { +export async function createInkUI( + opts: CreateInkUIOptions = {} +): Promise { const ink = await import("ink"); const react = await import("react"); // The `?bridge=1` query string is load-bearing. Without it Bun's @@ -242,13 +234,18 @@ export async function createInkUI(): Promise { )) as typeof import("./ink-app.js"); const store = new WizardStore({ + cliVersion: CLI_VERSION, bannerRows: BANNER_ROWS.map((content, i) => ({ content, color: BANNER_GRADIENT[i] ?? BANNER_GRADIENT[0] ?? "#FFFFFF", })), }); - - store.setTheme(detectColorScheme()); + const initialWelcome = opts.initialWelcome + ? createPendingWelcome() + : undefined; + if (opts.initialWelcome && initialWelcome) { + seedWelcomePrompt(store, opts.initialWelcome, initialWelcome); + } // Open a fresh /dev/tty so Ink's `readable` event listener // actually fires — see the module docstring for the Bun bug @@ -283,15 +280,16 @@ export async function createInkUI(): Promise { renderOptions.stdin = freshStdin; } // Enter the alternate screen buffer so the wizard occupies the full - // terminal. On exit, Ink restores the original scrollback. - process.stdout.write("\x1b[?1049h"); + // terminal. Clear and home the cursor before Ink's first frame so + // startup never shows stale layout from a prior render. + process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H"); try { const instance = ink.render( react.createElement(app.App, { store }), renderOptions ); - return new InkUI(instance, store, freshStdin); + return new InkUI(instance, store, freshStdin, initialWelcome); } catch (error) { // Restore the terminal if Ink rendering or UI init fails, // otherwise the user is stuck in the alternate screen buffer. @@ -375,6 +373,8 @@ export class InkUI implements WizardUI { */ private outroMessage: string | undefined; private failureMessage: string | undefined; + private feedbackHint: string | undefined; + private initialWelcome: PendingWelcome | undefined; /** * Resolved when the user presses any key on the outro screen. * `[Symbol.asyncDispose]` awaits this so the `using` block keeps the @@ -384,12 +384,20 @@ export class InkUI implements WizardUI { constructor( instance: InkInstance, store: WizardStore, - freshStdin: ReadStream | null + freshStdin: ReadStream | null, + initialWelcome?: PendingWelcome ) { this.instance = instance; this.store = store; this.freshStdin = freshStdin; - this.startLearnSequence(); + this.initialWelcome = initialWelcome; + if (initialWelcome && !initialWelcome.settled) { + this.activePromptCancel = () => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + initialWelcome.resolve(CANCELLED); + }; + } this.installCancelHandler(); // Hand the App a reference to `requestCancel` via the store so // the top-level `useInput` Ctrl+C catcher in `ink-app.tsx` can @@ -425,6 +433,10 @@ export class InkUI implements WizardUI { this.failureMessage = clean; } + feedback(outcome: InitFeedbackOutcome): void { + this.feedbackHint = formatFeedbackHint(outcome); + } + summary(summary: WizardSummary): void { this.store.setSummary(summary); } @@ -460,6 +472,16 @@ export class InkUI implements WizardUI { this.store.clearOverlay(); } + setIntroMode(enabled: boolean): void { + if (enabled) { + this.store.setLayout("intro"); + this.pauseSidebarTimers(); + return; + } + this.store.setLayout("workflow"); + this.startSidebarTimers(); + } + // ── Logging ─────────────────────────────────────────────────────── log: WizardLog = { @@ -491,9 +513,10 @@ export class InkUI implements WizardUI { } }, stop: (message?: string, code: SpinnerExitCode = 0) => { - const finalMessage = message - ? stripAnsi(message) - : this.store.getSnapshot().spinner.message; + const finalMessage = + message !== undefined + ? stripAnsi(message) + : this.store.getSnapshot().spinner.message; this.store.stopSpinner(); if (finalMessage) { this.appendLog(severityForStopCode(code), finalMessage); @@ -598,6 +621,40 @@ export class InkUI implements WizardUI { }); } + welcome(opts: WelcomeOptions): Promise<"continue" | Cancelled> { + this.store.setLayout("intro"); + this.pauseSidebarTimers(); + if (this.initialWelcome) { + if (!this.initialWelcome.settled) { + seedWelcomePrompt(this.store, opts, this.initialWelcome); + } + return this.initialWelcome.promise.finally(() => { + this.activePromptCancel = undefined; + this.initialWelcome = undefined; + }); + } + return new Promise<"continue" | Cancelled>((resolve) => { + this.activePromptCancel = () => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + resolve(CANCELLED); + }; + this.store.setPrompt({ + kind: "welcome", + options: sanitizeWelcomeOptions(opts), + resolve: (value) => { + this.store.setPrompt(null); + this.activePromptCancel = undefined; + if (value === null) { + resolve(CANCELLED); + } else { + resolve(value); + } + }, + }); + }); + } + // ── Disposal ────────────────────────────────────────────────────── [Symbol.asyncDispose](): Promise { @@ -734,6 +791,7 @@ export class InkUI implements WizardUI { } this.cancelRequested = true; this.failureMessage = "Setup cancelled."; + this.feedback("cancelled"); this.tearDown(); // Match the SIGINT convention so shells (and CI) see a // distinguishable exit. The runner's `await using` won't get a @@ -746,7 +804,7 @@ export class InkUI implements WizardUI { } /** - * Build a compact final summary echoed to stderr after Ink + * Build a compact final summary echoed to stdout after Ink * unmounts. Ink's inline rendering means the run's log lines are * already in the user's scrollback; this report just emphasises * the outcome so it's the last thing on screen. @@ -764,37 +822,18 @@ export class InkUI implements WizardUI { if (this.failureMessage) { return formatFailureReport( this.failureMessage, - this.store.getSnapshot().logs + this.store.getSnapshot().logs, + this.feedbackHint ); } if (!this.outroMessage) { return; } - const successIcon = chalk.hex(REPORT_SUCCESS)("✔"); - const lines: string[] = [ - "", - `${successIcon} ${chalk.bold(this.outroMessage)}`, - ]; - const summary = this.store.getSnapshot().summary; - if (summary && summary.fields.length > 0) { - lines.push(""); - const labelWidth = Math.max( - ...summary.fields.map((field) => field.label.length) - ); - for (const field of summary.fields) { - const label = chalk.hex(REPORT_MUTED)(field.label.padEnd(labelWidth)); - lines.push(` ${label} ${field.value}`); - } - } - if (summary?.changedFiles && summary.changedFiles.length > 0) { - lines.push(""); - lines.push(` ${chalk.hex(REPORT_MUTED).bold("Changed files")}`); - const tree = buildFileTree(summary.changedFiles); - for (const row of flattenTree(tree)) { - lines.push(formatTreeRowChalk(row)); - } - } - return lines.join("\n"); + return formatSuccessReport( + this.outroMessage, + this.store.getSnapshot().summary ?? undefined, + this.feedbackHint + ); } // ── Internal helpers ────────────────────────────────────────────── @@ -814,6 +853,9 @@ export class InkUI implements WizardUI { } private startLearnSequence(): void { + if (this.learnTimer) { + return; + } const store = this.store; this.learnTimer = setInterval(() => { if (this.torndown) { @@ -848,6 +890,25 @@ export class InkUI implements WizardUI { } } + private startSidebarTimers(): void { + if (this.torndown) { + return; + } + if (this.store.getSnapshot().learnState.complete) { + this.startTipRotation(); + return; + } + this.startLearnSequence(); + } + + private pauseSidebarTimers(): void { + if (this.tipTimer) { + clearInterval(this.tipTimer); + this.tipTimer = undefined; + } + this.stopLearnSequence(); + } + /** * Fallback SIGINT handler for the (rare) windows where raw mode * is OFF and Node's terminal layer DOES deliver SIGINT for @@ -876,38 +937,3 @@ export class InkUI implements WizardUI { process.on("SIGINT", handler); } } - -/** - * Colored glyph for a changed-files row in the post-dispose report. - * The plain ASCII variant lives in `logging-ui.ts` for the - * non-interactive CI path. - */ -function changedFileGlyphColored(action: string): string { - if (action === "create") { - return chalk.hex(REPORT_SUCCESS)("+"); - } - if (action === "delete") { - return chalk.hex(REPORT_ERROR)("−"); - } - return chalk.hex(REPORT_WARN)("~"); -} - -/** - * Render a single `FileTreeRow` for the post-dispose stderr report. - * Directories show only the box-drawing branch + label; files add - * the action glyph (colored). - */ -function formatTreeRowChalk(row: { - prefix: string; - branch: string; - kind: "file" | "directory"; - label: string; - action?: string; -}): string { - const branch = chalk.hex(REPORT_MUTED)(`${row.prefix}${row.branch}`); - if (row.kind === "directory") { - return ` ${branch} ${row.label}`; - } - const glyph = changedFileGlyphColored(row.action ?? "modify"); - return ` ${branch} ${glyph} ${row.label}`; -} diff --git a/src/lib/init/ui/learn-content.ts b/src/lib/init/ui/learn-content.ts index 025a1d995..473f8146f 100644 --- a/src/lib/init/ui/learn-content.ts +++ b/src/lib/init/ui/learn-content.ts @@ -22,14 +22,27 @@ export const LEARN_SEQUENCE: ContentBlock[] = [ { title: "How Sentry Works", lines: [ - "App → SDK → Sentry → Alert", + "App → SDK → Sentry → Issue", "", - "The SDK captures errors and", - "performance data, then sends", - "them to Sentry for grouping,", - "alerting, and root-cause", - "analysis.", + "The SDK runs in your app.", + "It sends errors, traces, and", + "context from production to", + "Sentry, where related events", + "become issues with the clues", + "you need to fix them faster.", + ], + }, + { + title: "Debug With Context", + lines: [ + "Issue → Trace → Replay → Fix", "", + "Start with the grouped issue.", + "Traces show the request path.", + "Replay shows the user session.", + "Logs show what happened nearby.", + "Releases show what changed.", + "That context points to the fix.", ], }, { diff --git a/src/lib/init/ui/logging-ui.ts b/src/lib/init/ui/logging-ui.ts index d3579ff78..75f14e883 100644 --- a/src/lib/init/ui/logging-ui.ts +++ b/src/lib/init/ui/logging-ui.ts @@ -22,6 +22,7 @@ import { renderInlineMarkdown, renderMarkdown, } from "../../formatters/markdown.js"; +import { formatFeedbackHint, type InitFeedbackOutcome } from "../feedback.js"; import { buildFileTree, flattenTree } from "./file-tree.js"; import type { ConfirmOptions, @@ -128,6 +129,11 @@ export class LoggingUI implements WizardUI { this.writeLine(this.stderr, message); } + feedback(outcome: InitFeedbackOutcome): void { + this.writeLine(this.stdout, formatFeedbackHint(outcome)); + this.writeLine(this.stdout, ""); + } + // ── Logging ─────────────────────────────────────────────────────── log: WizardLog = { diff --git a/src/lib/init/ui/sentry-tips.ts b/src/lib/init/ui/sentry-tips.ts index eded84994..36454f2ce 100644 --- a/src/lib/init/ui/sentry-tips.ts +++ b/src/lib/init/ui/sentry-tips.ts @@ -31,11 +31,11 @@ export const SENTRY_TIPS: SentryTip[] = [ }, { title: "Session Replay shows the user's view", - body: "Replay captures DOM mutations, network calls, and console logs alongside your error. Reproducing a bug becomes scrubbing a timeline instead of guessing from a stack trace.", + body: "Replay keeps UI changes, clicks, network calls, and console logs next to the related error. When the stack trace says what broke, replay shows what the user did before it.", }, { title: "Tracing finds the slow piece", - body: "Performance Monitoring surfaces the spans inside a transaction so you can see whether the database, an HTTP call, or your own code is the bottleneck — without adding manual timers.", + body: "Tracing connects the slow or failed request to its spans, so you can see whether the database, an HTTP call, rendering, or your own code caused the wait.", }, { title: "Alerts on real signals", @@ -47,7 +47,7 @@ export const SENTRY_TIPS: SentryTip[] = [ }, { title: "Source maps make stack traces readable", - body: "Upload source maps with each release (the wizard can set this up for you) and your minified production stack traces resolve back to original TypeScript/JSX line numbers.", + body: "Source maps connect minified production frames back to the code you wrote. Upload them with the release so an issue opens on the useful TypeScript or JSX line.", }, { title: "Cron monitoring catches missed jobs", @@ -59,7 +59,7 @@ export const SENTRY_TIPS: SentryTip[] = [ }, { title: "Profiling for hot code paths", - body: "Continuous profiling samples your production code and shows which functions burn the most CPU. Pair with tracing to see exactly which transaction a slow function ran inside.", + body: "Profiling adds function-level cost to the same debugging story. Pair it with a trace to see which code path burned CPU during a slow transaction.", }, { title: "AI Monitoring for LLM apps", @@ -67,10 +67,10 @@ export const SENTRY_TIPS: SentryTip[] = [ }, { title: "Seer: AI-powered debugging", - body: "Run `sentry issue explain ` after this wizard finishes to get an AI root-cause analysis of any error, with a suggested fix and the lines of code most likely responsible.", + body: "After setup, run `sentry issue explain `. Seer uses the issue, stack trace, and nearby context to summarize the likely cause and point at a fix.", }, { title: "Self-hosted is a flag away", - body: "Sentry SaaS and self-hosted share the same SDK, the same wire protocol, and the same CLI. Set `SENTRY_URL` to point at your own instance — everything else just works.", + body: "Using self-hosted Sentry? Point the CLI at your instance with `SENTRY_URL`. Your SDK setup and debugging workflow stay the same.", }, ]; diff --git a/src/lib/init/ui/theme.ts b/src/lib/init/ui/theme.ts deleted file mode 100644 index 85966fb48..000000000 --- a/src/lib/init/ui/theme.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Terminal Color Scheme Detection - * - * Auto-detects whether the terminal has a dark or light background - * and provides matching color palettes. Dark terminals get the - * standard Sentry purple palette; light terminals get darker, - * higher-contrast variants. - * - * Detection priority: - * 1. `SENTRY_THEME=dark|light` env var override - * 2. `COLORFGBG` env var (standard: `"15;0"` = light-on-dark bg) - * 3. Default to `"dark"` (most terminals) - */ - -export type ColorScheme = "dark" | "light"; - -export type ThemePalette = { - accent: string; - primary: string; - muted: string; - mutedDim: string; - info: string; - warn: string; - error: string; - success: string; -}; - -const DARK_PALETTE: ThemePalette = { - accent: "#7553FF", - primary: "#8B6AC8", - muted: "gray", - mutedDim: "#555555", - info: "#9C84D4", - warn: "#FDB81B", - error: "#fe4144", - success: "#83da90", -}; - -const LIGHT_PALETTE: ThemePalette = { - accent: "#5538A8", - primary: "#6C4EBA", - muted: "#666666", - mutedDim: "#999999", - info: "#5D3EB2", - warn: "#B8860B", - error: "#b91c1c", - success: "#15803d", -}; - -/** Detect terminal color scheme from environment. */ -export function detectColorScheme(): ColorScheme { - const override = process.env.SENTRY_THEME; - if (override === "light" || override === "dark") { - return override; - } - const colorFgBg = process.env.COLORFGBG; - if (colorFgBg) { - const parts = colorFgBg.split(";"); - const bg = Number.parseInt(parts.at(-1) ?? "", 10); - if (!Number.isNaN(bg) && bg > 8) { - return "light"; - } - } - return "dark"; -} - -/** Get the theme palette for the detected or specified scheme. */ -export function getThemePalette(scheme?: ColorScheme): ThemePalette { - const resolved = scheme ?? detectColorScheme(); - return resolved === "light" ? LIGHT_PALETTE : DARK_PALETTE; -} diff --git a/src/lib/init/ui/types.ts b/src/lib/init/ui/types.ts index d8299c1fd..a88b9f7d9 100644 --- a/src/lib/init/ui/types.ts +++ b/src/lib/init/ui/types.ts @@ -28,6 +28,8 @@ * check overlays. */ +import type { InitFeedbackOutcome } from "../feedback.js"; + /** Sentinel symbol returned by prompt methods when the user cancels. */ export const CANCELLED: unique symbol = Symbol.for( "sentry-cli:wizard-ui:cancelled" @@ -114,6 +116,13 @@ export type ConfirmOptions = { initialValue?: boolean; }; +/** Args for the richer Ink-only welcome screen. */ +export type WelcomeOptions = { + title: string; + body: string[]; + punchline: string; +}; + /** * Structured completion summary handed to `WizardUI.summary()`. * @@ -174,6 +183,9 @@ export type WizardUI = AsyncDisposable & { */ cancel(message: string): void; + /** Display an outcome-specific feedback prompt after a terminal outcome. */ + feedback(outcome: InitFeedbackOutcome): void; + /** * Notify the UI that the wizard is reading the listed files from * disk. Optional — implementations that don't track reads (e.g. @@ -226,6 +238,13 @@ export type WizardUI = AsyncDisposable & { /** Clear the active overlay. */ clearOverlay?(): void; + /** + * Keep rendering the lightweight intro layout while local preflight + * prompts/checks run. Ink uses this to keep git/org/project/team prompts + * centered with the opening copy until the remote workflow starts. + */ + setIntroMode?(enabled: boolean): void; + // ── Logging ─────────────────────────────────────────────────────── log: WizardLog; @@ -260,4 +279,10 @@ export type WizardUI = AsyncDisposable & { * the user aborted. */ confirm(opts: ConfirmOptions): Promise; + + /** + * Richer Ink-only opening screen. Plain UIs leave this undefined and + * callers fall back to `select()`. + */ + welcome?(opts: WelcomeOptions): Promise<"continue" | Cancelled>; }; diff --git a/src/lib/init/ui/wizard-store.ts b/src/lib/init/ui/wizard-store.ts index 160d36a20..5030a02f9 100644 --- a/src/lib/init/ui/wizard-store.ts +++ b/src/lib/init/ui/wizard-store.ts @@ -21,8 +21,11 @@ import { CHECKLIST_VISIBLE_STEPS, shortStepLabel, } from "../clack-utils.js"; -import type { ColorScheme } from "./theme.js"; -import type { SpinnerExitCode, WizardSummary } from "./types.js"; +import type { + SpinnerExitCode, + WelcomeOptions, + WizardSummary, +} from "./types.js"; export type LogSeverity = "info" | "warn" | "error" | "success" | "message"; @@ -114,6 +117,11 @@ export type ActivePrompt = message: string; initialValue: boolean; resolve: (value: boolean | null) => void; + } + | { + kind: "welcome"; + options: WelcomeOptions; + resolve: (value: "continue" | null) => void; }; /** Non-blocking overlay shown on top of the normal content. */ @@ -137,7 +145,13 @@ export type LearnState = { complete: boolean; }; +export type WizardLayout = "intro" | "workflow"; + export type WizardSnapshot = { + /** Top-level layout: centered intro/preflight or full workflow shell. */ + layout: WizardLayout; + /** CLI version displayed in the persistent Ink footer banner. */ + cliVersion: string | null; bannerRows: { content: string; color: string }[]; logs: LogEntry[]; spinner: SpinnerState; @@ -190,8 +204,6 @@ export type WizardSnapshot = { overlay: Overlay; /** When set, overrides the normal tab content with an outro screen. */ outroState: OutroState; - /** Terminal color scheme for adaptive palette. */ - theme: ColorScheme; /** Learn sequence progressive reveal state. */ learnState: LearnState; }; @@ -209,6 +221,8 @@ export class WizardStore { constructor(initial: Partial = {}) { this.snapshot = { + layout: initial.layout ?? "workflow", + cliVersion: initial.cliVersion ?? null, bannerRows: initial.bannerRows ?? [], logs: initial.logs ?? [], spinner: initial.spinner ?? { active: false, frame: 0, message: "" }, @@ -228,7 +242,6 @@ export class WizardStore { statusExpanded: initial.statusExpanded ?? false, overlay: initial.overlay ?? null, outroState: initial.outroState ?? null, - theme: initial.theme ?? "dark", learnState: initial.learnState ?? { blockIndex: 0, lineIndex: 0, @@ -250,6 +263,13 @@ export class WizardStore { this.update({ bannerRows: rows }); } + setLayout(layout: WizardLayout): void { + if (this.snapshot.layout === layout) { + return; + } + this.update({ layout }); + } + appendLog(severity: LogSeverity, text: string): LogEntry { const entry: LogEntry = { id: this.nextLogId, @@ -435,13 +455,6 @@ export class WizardStore { this.update({ outroState: state }); } - setTheme(theme: ColorScheme): void { - if (this.snapshot.theme === theme) { - return; - } - this.update({ theme }); - } - advanceLearnLine(): void { const { learnState } = this.snapshot; if (learnState.complete) { diff --git a/src/lib/init/wizard-runner.ts b/src/lib/init/wizard-runner.ts index 49b1a059a..f54d60203 100644 --- a/src/lib/init/wizard-runner.ts +++ b/src/lib/init/wizard-runner.ts @@ -19,7 +19,6 @@ import { formatBanner } from "../banner.js"; import { CLI_VERSION } from "../constants.js"; import { detectAgent } from "../detect-agent.js"; import { EXIT, WizardError } from "../errors.js"; -import { terminalLink } from "../formatters/colors.js"; import { renderInlineMarkdown, stripColorTags, @@ -36,7 +35,6 @@ import { EXIT_PLATFORM_NOT_DETECTED, EXIT_VERIFICATION_FAILED, MASTRA_API_URL, - SENTRY_DOCS_URL, VERIFY_CHANGES_STEP, WORKFLOW_ID, } from "./constants.js"; @@ -55,7 +53,7 @@ import type { } from "./types.js"; import { getUIAsync } from "./ui/factory.js"; import { LoggingUIPromptError } from "./ui/logging-ui.js"; -import type { SpinnerHandle, WizardUI } from "./ui/types.js"; +import type { SpinnerHandle, WelcomeOptions, WizardUI } from "./ui/types.js"; import { precomputeDirListing, precomputeSentryDetection, @@ -234,17 +232,25 @@ async function handleSuspendedStep( spin.stop("Error", 1); spinState.running = false; - ui.log.error( - `Unknown suspend payload type "${(payload as { type: string }).type}"` - ); - ui.cancel("Setup failed"); - throw new WizardCancelledError(); + const message = `Unknown suspend payload type "${(payload as { type: string }).type}"`; + ui.log.error(message); + throw new WizardError(message); } function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function showCancelledFeedback(ui: WizardUI): void { + ui.cancel("Setup cancelled."); + ui.feedback("cancelled"); +} + +function showFailedFeedback(ui: WizardUI, message = "Setup failed"): void { + ui.cancel(message); + ui.feedback("failed"); +} + function assertWorkflowResult(raw: unknown): WorkflowRunResult { if (!raw || typeof raw !== "object") { throw new Error("Invalid workflow response: expected object"); @@ -296,13 +302,28 @@ function withTimeout( }); } +function buildWelcomeOptions(): WelcomeOptions { + return { + title: "Sentry Init", + body: [ + "We'll use AI to inspect this project and configure Sentry.", + "You'll choose the setup before local files change.", + ], + punchline: "Continue to let Sentry use AI for setup.", + }; +} + async function confirmExperimental( - yes: boolean, + options: WizardOptions, ui: WizardUI ): Promise { - if (yes) { + if (options.yes || options.dryRun) { return true; } + if (ui.welcome) { + const choice = await ui.welcome(buildWelcomeOptions()); + return abortIfCancelled(choice) === "continue"; + } // The wizard modifies files on disk. We use `select` rather than // `confirm` so the cancel path can carry a muted, explicit hint // ("exits without changes") — the previous binary yes/no felt @@ -332,12 +353,10 @@ async function confirmExperimental( } async function preamble( - directory: string, - yes: boolean, - dryRun: boolean, + options: WizardOptions, ui: WizardUI ): Promise { - if (!(yes || dryRun || process.stdin.isTTY)) { + if (!(options.yes || options.dryRun || process.stdin.isTTY)) { throw new WizardError( "Interactive mode requires a terminal. Use --yes for non-interactive mode.", { rendered: false } @@ -356,10 +375,11 @@ async function preamble( let confirmed: boolean; try { - confirmed = await confirmExperimental(yes || dryRun, ui); + confirmed = await confirmExperimental(options, ui); } catch (err) { if (err instanceof WizardCancelledError) { captureException(err); + showCancelledFeedback(ui); process.exitCode = 0; return false; } @@ -372,22 +392,22 @@ async function preamble( throw err; } if (!confirmed) { - ui.cancel("Setup cancelled."); + showCancelledFeedback(ui); process.exitCode = 0; return false; } - if (dryRun) { + if (options.dryRun) { ui.log.warn("Dry-run mode: no files will be modified."); } const gitOk = await checkGitStatus({ - cwd: directory, - yes: yes || dryRun, + cwd: options.directory, + yes: options.yes || options.dryRun, ui, }); if (!gitOk) { - ui.cancel("Setup cancelled."); + showCancelledFeedback(ui); process.exitCode = 0; return false; } @@ -469,21 +489,19 @@ export async function runWizard(initialOptions: WizardOptions): Promise { // path via `await using`. The factory picks `InkUI` for interactive // runs on the Bun binary and `LoggingUI` everywhere else (CI, // `--yes`, `--no-tui`, npm/Node distribution). + const initialWelcome = yes || dryRun ? undefined : buildWelcomeOptions(); await using ui = await getUIAsync({ yes, forceLegacy: forceLegacyUi, + ...(initialWelcome ? { initialWelcome } : {}), }); + ui.setIntroMode?.(!yes); - await checkReadiness(ui); - - if (!(await preamble(directory, yes, dryRun, ui))) { + if (!(await preamble(initialOptions, ui))) { return; } - ui.log.info( - "This wizard uses AI to analyze your project and configure Sentry." + - `\nFor manual setup: ${terminalLink(SENTRY_DOCS_URL)}` - ); + await checkReadiness(ui); const effectiveOptions = dryRun ? { ...initialOptions, yes: true } @@ -558,6 +576,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { precomputeSentryDetection(directory).catch(() => null), ]); const fileCache = await preReadCommonFiles(directory, dirListing); + ui.setIntroMode?.(false); spin.message("Connecting to wizard..."); run = await workflow.createRun(); // Large shared context (dirListing, fileCache, existingSentry) @@ -591,7 +610,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { spin.stop("Connection failed", 1); spinState.running = false; ui.log.error(errorMessage(err)); - ui.cancel("Setup failed"); + showFailedFeedback(ui); throw new WizardError(errorMessage(err)); } @@ -619,7 +638,6 @@ export async function runWizard(initialOptions: WizardOptions): Promise { ui.setStep?.(activeStepId, "failed"); } ui.log.error(`No suspend payload found for step "${stepId}"`); - ui.cancel("Setup failed"); throw new WizardError(`No suspend payload found for step "${stepId}"`); } @@ -675,6 +693,7 @@ export async function runWizard(initialOptions: WizardOptions): Promise { // failed; the post-dispose report shows the cancel message // instead. captureException(err); + showCancelledFeedback(ui); process.exitCode = 0; return; } @@ -682,10 +701,11 @@ export async function runWizard(initialOptions: WizardOptions): Promise { ui.setStep?.(activeStepId, "failed"); } if (err instanceof WizardError) { + showFailedFeedback(ui); throw err; } ui.log.error(errorMessage(err)); - ui.cancel("Setup failed"); + showFailedFeedback(ui); throw new WizardError(errorMessage(err)); } diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 9b113e0e4..84a2a5297 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -34,21 +34,24 @@ let warmSpy: ReturnType; const func = (await initCommand.loader()) as unknown as ( this: { cwd: string; - stdout: { write: () => boolean }; + stdout: { write: () => boolean; isTTY?: boolean }; stderr: { write: () => boolean }; - stdin: typeof process.stdin; + stdin: { isTTY?: boolean }; }, flags: Record, first?: string, second?: string ) => Promise; -function makeContext(cwd = "/projects/app") { +function makeContext( + cwd = "/projects/app", + tty: { stdinTTY?: boolean; stdoutTTY?: boolean } = {} +) { return { cwd, - stdout: { write: () => true }, + stdout: { write: () => true, isTTY: tty.stdoutTTY ?? true }, stderr: { write: () => true }, - stdin: process.stdin, + stdin: { isTTY: tty.stdinTTY ?? true }, }; } @@ -95,7 +98,11 @@ describe("init command func", () => { ...DEFAULT_FLAGS, features: ["errors,tracing,logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "logs", + ]); }); test("splits plus-separated features", async () => { @@ -104,7 +111,11 @@ describe("init command func", () => { ...DEFAULT_FLAGS, features: ["errors+tracing+logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "logs", + ]); }); test("splits space-separated features", async () => { @@ -113,7 +124,11 @@ describe("init command func", () => { ...DEFAULT_FLAGS, features: ["errors tracing logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "logs", + ]); }); test("merges multiple --features flags", async () => { @@ -122,7 +137,11 @@ describe("init command func", () => { ...DEFAULT_FLAGS, features: ["errors,tracing", "logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "logs", + ]); }); test("trims whitespace from features", async () => { @@ -131,7 +150,10 @@ describe("init command func", () => { ...DEFAULT_FLAGS, features: [" errors , tracing "], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing"]); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + ]); }); test("filters empty segments", async () => { @@ -140,7 +162,55 @@ describe("init command func", () => { ...DEFAULT_FLAGS, features: ["errors,,tracing,"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing"]); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + ]); + }); + + test("normalizes public aliases to wizard feature ids", async () => { + const ctx = makeContext(); + await func.call(ctx, { + ...DEFAULT_FLAGS, + features: ["errors,tracing,replay,sourcemaps"], + }); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + "sourceMaps", + ]); + }); + + test("accepts canonical wizard feature ids", async () => { + const ctx = makeContext(); + await func.call(ctx, { + ...DEFAULT_FLAGS, + features: [ + "errorMonitoring,performanceMonitoring,sessionReplay,sourceMaps", + ], + }); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + "sourceMaps", + ]); + }); + + test("throws ValidationError for unknown features", async () => { + const ctx = makeContext(); + const promise = func.call(ctx, { + ...DEFAULT_FLAGS, + features: ["errors,unknownFeature"], + }); + 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" + ); + expect(runWizardSpy).not.toHaveBeenCalled(); + expect(findProjectsSpy).not.toHaveBeenCalled(); + expect(warmSpy).not.toHaveBeenCalled(); }); test("passes undefined when features not provided", async () => { @@ -150,6 +220,46 @@ describe("init command func", () => { }); }); + // ── Non-TTY validation ─────────────────────────────────────────────── + + describe("non-TTY validation", () => { + test("requires --yes before project lookup or wizard startup", async () => { + const ctx = makeContext("/projects/app", { stdinTTY: false }); + await expect( + func.call(ctx, { yes: false, "dry-run": false }, "my-app") + ).rejects.toThrow(ContextError); + expect(findProjectsSpy).not.toHaveBeenCalled(); + expect(warmSpy).not.toHaveBeenCalled(); + expect(runWizardSpy).not.toHaveBeenCalled(); + }); + + test("requires explicit features with --yes", async () => { + const ctx = makeContext("/projects/app", { stdoutTTY: false }); + const promise = func.call(ctx, DEFAULT_FLAGS); + await expect(promise).rejects.toThrow(ContextError); + await expect(promise).rejects.toThrow( + "sentry init --yes --features errors,tracing,replay [target] [directory]" + ); + expect(warmSpy).not.toHaveBeenCalled(); + expect(runWizardSpy).not.toHaveBeenCalled(); + }); + + test("runs when --yes and --features are both provided", async () => { + const ctx = makeContext("/projects/app", { stdinTTY: false }); + await func.call(ctx, { + ...DEFAULT_FLAGS, + features: ["errors,tracing,replay"], + }); + expect(capturedArgs?.features).toEqual([ + "errorMonitoring", + "performanceMonitoring", + "sessionReplay", + ]); + expect(capturedArgs?.yes).toBe(true); + expect(runWizardSpy).toHaveBeenCalledTimes(1); + }); + }); + // ── No arguments ────────────────────────────────────────────────────── describe("no arguments", () => { @@ -337,16 +447,16 @@ describe("init command func", () => { describe("error cases", () => { test("two paths throws ContextError", async () => { const ctx = makeContext(); - expect(func.call(ctx, DEFAULT_FLAGS, "./dir1", "./dir2")).rejects.toThrow( - ContextError - ); + await expect( + func.call(ctx, DEFAULT_FLAGS, "./dir1", "./dir2") + ).rejects.toThrow(ContextError); }); test("two targets throws ContextError", async () => { const ctx = makeContext(); - expect(func.call(ctx, DEFAULT_FLAGS, "acme/", "other/")).rejects.toThrow( - ContextError - ); + await expect( + func.call(ctx, DEFAULT_FLAGS, "acme/", "other/") + ).rejects.toThrow(ContextError); }); test("org slug with whitespace is rejected by validateResourceId", async () => { diff --git a/test/lib/init/clack-utils.test.ts b/test/lib/init/clack-utils.test.ts index a733b18ad..4256a4e37 100644 --- a/test/lib/init/clack-utils.test.ts +++ b/test/lib/init/clack-utils.test.ts @@ -46,9 +46,7 @@ describe("abortIfCancelled", () => { describe("featureLabel", () => { test("returns label for known feature", () => { expect(featureLabel("errorMonitoring")).toBe("Error Monitoring"); - expect(featureLabel("performanceMonitoring")).toBe( - "Performance Monitoring (Tracing)" - ); + expect(featureLabel("performanceMonitoring")).toBe("Tracing"); expect(featureLabel("logs")).toBe("Logging"); expect(featureLabel("crons")).toBe("Crons"); expect(featureLabel("aiMonitoring")).toBe("AI Monitoring"); @@ -62,14 +60,34 @@ describe("featureLabel", () => { describe("featureHint", () => { test("returns hint for known feature", () => { - expect(featureHint("errorMonitoring")).toBe("Error and crash reporting"); - expect(featureHint("sessionReplay")).toBe("Visual replay of user sessions"); - expect(featureHint("crons")).toBe("Monitor scheduled and recurring jobs"); + expect(featureHint("errorMonitoring")).toBe( + "Group exceptions into issues with context" + ); + expect(featureHint("performanceMonitoring")).toBe( + "See request paths, spans, and bottlenecks" + ); + expect(featureHint("sessionReplay")).toBe( + "Replay sessions linked to errors" + ); + expect(featureHint("profiling")).toBe( + "Find CPU-heavy functions in production" + ); + 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(featureHint("crons")).toBe( + "Alert on failed or missed scheduled jobs" + ); expect(featureHint("aiMonitoring")).toBe( - "Track AI model calls, latency, and failures" + "Track AI calls, latency, cost, and failures" ); expect(featureHint("userFeedback")).toBe( - "Collect in-app user feedback and reports" + "Collect user reports with issue context" + ); + expect(featureHint("reactFeatures")).toBe( + "Add React-specific context and integrations" ); }); diff --git a/test/lib/init/feedback.test.ts b/test/lib/init/feedback.test.ts new file mode 100644 index 000000000..b4eec0ee9 --- /dev/null +++ b/test/lib/init/feedback.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { formatFeedbackHint } from "../../../src/lib/init/feedback.js"; + +describe("formatFeedbackHint", () => { + test("maps init outcomes to copy-paste feedback commands", () => { + expect(formatFeedbackHint("success")).toBe( + [ + "Nice, setup made it through.", + "Tell us what felt great or rough:", + '$ sentry cli feedback "sentry init worked well"', + ].join("\n") + ); + expect(formatFeedbackHint("cancelled")).toBe( + [ + "Sad to see setup stop. Was something going sideways?", + "Tell us so we can fix it:", + '$ sentry cli feedback "sentry init was cancelled"', + ].join("\n") + ); + expect(formatFeedbackHint("failed")).toBe( + [ + "Setup hit a wall.", + "Tell us what happened so we can fix it:", + '$ sentry cli feedback "sentry init failed"', + ].join("\n") + ); + }); +}); diff --git a/test/lib/init/formatters.test.ts b/test/lib/init/formatters.test.ts index 3a944c39d..03f860504 100644 --- a/test/lib/init/formatters.test.ts +++ b/test/lib/init/formatters.test.ts @@ -46,6 +46,14 @@ function infoMessages(calls: MockCall[]): string[] { .map((c) => c.message); } +function feedbackOutcomes(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "feedback" + ) + .map((c) => c.outcome); +} + describe("formatResult", () => { test("emits a structured summary with all fields and the changed-files list", () => { const { ui, calls } = createMockUI(); @@ -82,7 +90,7 @@ describe("formatResult", () => { { label: "Directory", value: "/app" }, { label: "Features", - value: "Error Monitoring, Performance Monitoring (Tracing)", + value: "Error Monitoring, Tracing", }, { label: "Commands", value: "npm install @sentry/nextjs" }, { label: "Project", value: "https://sentry.io/project" }, @@ -94,6 +102,12 @@ describe("formatResult", () => { { action: "modify", path: "src/app/layout.tsx" }, { action: "delete", path: "src/old-sentry.js" }, ]); + expect(feedbackOutcomes(calls)).toEqual(["success"]); + expect( + infoMessages(calls).some((message) => + message.includes("one of the first") + ) + ).toBe(false); }); test("skips the summary call when result has no summary-worthy fields", () => { @@ -178,6 +192,7 @@ describe("formatError", () => { expect(errorMessages(calls)).toContain("Connection timed out"); const cancel = calls.find((c) => c.kind === "cancel"); expect(cancel?.kind === "cancel" && cancel.message).toBe("Setup failed"); + expect(feedbackOutcomes(calls)).toEqual(["failed"]); }); test("extracts message from nested result.message", () => { diff --git a/test/lib/init/interactive.test.ts b/test/lib/init/interactive.test.ts index 6e1892fad..8b2034775 100644 --- a/test/lib/init/interactive.test.ts +++ b/test/lib/init/interactive.test.ts @@ -266,6 +266,39 @@ describe("handleMultiSelect", () => { expect(multiselectCall?.options).not.toContain("errorMonitoring"); expect(multiselectCall?.options).toContain("performanceMonitoring"); }); + + test("shows available optional features without client-side recommendations", async () => { + const { ui, calls, respond } = createMockUI(); + respond.multiselect(["sessionReplay"]); + + const result = await handleInteractive( + { + type: "interactive", + prompt: "Select features", + kind: "multi-select", + availableFeatures: [ + "errorMonitoring", + "performanceMonitoring", + "sourceMaps", + "sessionReplay", + ], + }, + makeOptions({ yes: false }), + ui + ); + + expect(result.features).toEqual(["errorMonitoring", "sessionReplay"]); + + const multiselectCall = calls.find((c) => c.kind === "multiselect") as + | Extract<(typeof calls)[number], { kind: "multiselect" }> + | undefined; + expect(multiselectCall?.options).toEqual([ + "sessionReplay", + "performanceMonitoring", + "sourceMaps", + ]); + expect(multiselectCall?.initialValues).toBeUndefined(); + }); }); describe("handleConfirm", () => { diff --git a/test/lib/init/preflight.test.ts b/test/lib/init/preflight.test.ts index fb16e0fee..9a2ebdde8 100644 --- a/test/lib/init/preflight.test.ts +++ b/test/lib/init/preflight.test.ts @@ -20,7 +20,7 @@ import { CANCELLED } from "../../../src/lib/init/ui/types.js"; import * as resolveTarget from "../../../src/lib/resolve-target.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as resolveTeam from "../../../src/lib/resolve-team.js"; -import { createMockUI } from "./ui/mock-ui.js"; +import { createMockUI, type MockCall } from "./ui/mock-ui.js"; function makeOptions(overrides?: Partial): WizardOptions { return { @@ -31,6 +31,14 @@ function makeOptions(overrides?: Partial): WizardOptions { }; } +function feedbackOutcomes(calls: MockCall[]): string[] { + return calls + .filter( + (c): c is Extract => c.kind === "feedback" + ) + .map((c) => c.outcome); +} + let resolveOrgPrefetchedSpy: ReturnType; let listOrganizationsSpy: ReturnType; let getProjectSpy: ReturnType; @@ -306,6 +314,7 @@ describe("resolveInitContext", () => { expect(cancelCall?.kind === "cancel" && cancelCall.message).toBe( "Setup cancelled." ); + expect(feedbackOutcomes(calls)).toEqual(["cancelled"]); }); test("includes the auth token in the resolved context", async () => { diff --git a/test/lib/init/ui/ink-app.snapshot.test.tsx b/test/lib/init/ui/ink-app.snapshot.test.tsx index 235be9549..1547bd1f9 100644 --- a/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -1,7 +1,7 @@ /** * Smoke-test the Ink App by mounting it with mocked stdin/stdout * inside `bun test`. Verifies the full-screen layout (tabbed - * content, status bar, keyboard hints) without needing a real TTY. + * content and keyboard hints) without needing a real TTY. * * Note: The first Ink render() in a bun test CI worker can hang * indefinitely (Ink's internal reconciler keeps the event loop @@ -22,8 +22,26 @@ const FILES_TAB_RE = /Files/; const FILES_HEADER_PINNED_RE = /Files analyzed\s+\d+\/\d+/; 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 ESC_CANCEL_HINT_RE = /esc\s+cancel/; +const COMPLETED_SELECTING_FEATURES_RE = /✔\s+Selecting features/; +const ANSI_ESCAPE_PREFIX = "\u001B["; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in captured Ink output +const ANSI_CSI_RE = /\u001B\[[0-9;?]*[ -/]*[@-~]/g; +// biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escape sequences in captured Ink output +const ANSI_OSC_RE = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g; +const LINE_SPLIT_RE = /\r?\n/; +const RIGHT_ARROW = "\u001B[C"; +const FEEDBACK_BANNER_TEXT = + 'For feedback run: sentry cli feedback "what worked or broke"'; const FRAME_SETTLE_MS = 80; +const TEST_BANNER_ROWS = [ + { content: " ███████╗███████╗███╗ ██╗", color: "#B4A4DE" }, + { content: " ╚══════╝╚══════╝╚═╝ ╚═══╝", color: "#432B8A" }, +]; class CaptureStream extends Writable { frames: string[] = []; @@ -70,16 +88,22 @@ function makeStdin(): Readable { async function renderApp( store: WizardStore, - columns: number + columns: number, + options: { rows?: number; input?: string[] } = {} ): Promise { - const out = new CaptureStream(columns, 40); + const out = new CaptureStream(columns, options.rows ?? 40); + const stdin = makeStdin(); const instance = render(createElement(App, { store }), { stdout: out as unknown as NodeJS.WriteStream, stderr: out as unknown as NodeJS.WriteStream, - stdin: makeStdin() as unknown as NodeJS.ReadStream, + stdin: stdin as unknown as NodeJS.ReadStream, patchConsole: false, exitOnCtrlC: false, }); + for (const input of options.input ?? []) { + stdin.push(input); + await Bun.sleep(20); + } await Bun.sleep(FRAME_SETTLE_MS); instance.unmount(); // waitUntilExit() hangs in CI — race with a short unref'd timeout. @@ -97,6 +121,57 @@ async function renderApp( return out; } +function hasForcedWhiteForeground(output: string): boolean { + return ( + output.includes(`${ANSI_ESCAPE_PREFIX}37m`) || + output.includes(`${ANSI_ESCAPE_PREFIX}97m`) || + output.includes(`${ANSI_ESCAPE_PREFIX}38;2;255;255;255m`) + ); +} + +function withoutFeedbackBanner(output: string): string { + return output + .split(LINE_SPLIT_RE) + .filter((line) => !line.includes(FEEDBACK_BANNER_TEXT)) + .join("\n"); +} + +function stripAnsi(output: string): string { + return output.replace(ANSI_CSI_RE, "").replace(ANSI_OSC_RE, ""); +} + +function firstLogoLineIndex(output: string): number { + return stripAnsi(output) + .split(LINE_SPLIT_RE) + .findIndex((line) => line.includes("███████╗███████╗")); +} + +function ignorePromptResolution(): void { + // Snapshot tests render the prompt but never submit it. +} + +function setWelcomePrompt(store: WizardStore): void { + store.setPrompt({ + kind: "welcome", + options: { + title: "Sentry Init", + body: [ + "We'll use AI to inspect this project and configure Sentry.", + "You'll choose the setup before local files change.", + ], + punchline: "Continue to let Sentry use AI for setup.", + }, + resolve: ignorePromptResolution, + }); +} + +function makeReadFiles(count: number): string[] { + return Array.from( + { length: count }, + (_value, index) => `src/file-${String(index + 1).padStart(2, "0")}.ts` + ); +} + describe("Ink App snapshot", () => { test("renders full-screen layout at 120 cols", async () => { const store = new WizardStore(); @@ -105,6 +180,10 @@ describe("Ink App snapshot", () => { const frame = (await renderApp(store, 120)).allOutput(); expect(frame).toMatch(LEARN_HEADER_RE); + expect(frame).toContain("App → SDK → Sentry → Issue"); + expect(frame).toContain("The SDK runs in your app."); + expect(frame).toContain("become issues with the clues"); + expect(frame).toContain("1/7"); expect(frame).toMatch(TASKS_HEADER_RE); expect(frame).toContain("Hello world"); expect(frame).toContain("Working\u2026"); @@ -113,6 +192,19 @@ describe("Ink App snapshot", () => { expect(frame).toMatch(KEYBOARD_HINT_RE); }); + test("renders the second learn card about debugging context", async () => { + const store = new WizardStore({ + learnState: { blockIndex: 1, lineIndex: 0, complete: false }, + }); + store.appendLog("info", "Reading project context"); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("Debug With Context"); + expect(frame).toContain("Issue → Trace → Replay → Fix"); + expect(frame).toContain("That context points to the fix."); + expect(frame).toContain("2/7"); + }); + test("renders single-column layout at narrow width", async () => { const store = new WizardStore(); store.appendLog("info", "Narrow terminal"); @@ -122,13 +214,281 @@ describe("Ink App snapshot", () => { expect(frame).toMatch(STATUS_TAB_RE); }); - test("status bar shows messages", async () => { + test("workflow screen does not repeat status messages in the footer", async () => { + const store = new WizardStore(); + store.appendStatus("Analyzing project..."); + store.appendStatus("Reading package.json"); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).not.toContain("Analyzing project..."); + expect(frame).not.toContain("Reading package.json"); + }); + + test("status history shortcut is not shown", async () => { const store = new WizardStore(); store.appendStatus("Analyzing project..."); store.appendStatus("Reading package.json"); + store.appendStatus("Installing SDK"); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).not.toContain("toggle status"); + }); + + test("focused prompt text inherits terminal foreground", async () => { + const store = new WizardStore({ bannerRows: [], layout: "intro" }); + store.setPrompt({ + kind: "select", + message: "Choose a feature", + options: [ + { value: "errors", label: "Error Monitoring" }, + { value: "tracing", label: "Tracing" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("Choose a feature"); + expect(frame).toContain("Error Monitoring"); + expect(hasForcedWhiteForeground(withoutFeedbackBanner(frame))).toBe(false); + }); + + test("workflow screen hides logo and shows feedback banner", async () => { + const store = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + cliVersion: "0.32.0-test.0", + }); + store.appendLog("info", "Checking project..."); const frame = (await renderApp(store, 120)).allOutput(); - expect(frame).toContain("Reading package.json"); + expect(frame).not.toContain("███████╗███████╗"); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + expect(frame).toContain("Sentry v0.32.0-test.0"); + + const plainFrame = stripAnsi(frame); + const bannerLine = plainFrame + .split(LINE_SPLIT_RE) + .find((line) => line.includes("Sentry v") && line.includes("feedback")); + expect(bannerLine).toBeDefined(); + expect(bannerLine?.indexOf("Sentry v0.32.0-test.0")).toBeLessThan( + bannerLine?.indexOf("For feedback run") ?? 0 + ); + }); + + test("welcome screen is centered and standalone", async () => { + const store = new WizardStore({ bannerRows: TEST_BANNER_ROWS }); + setWelcomePrompt(store); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("███████╗███████╗"); + expect(frame).not.toContain("Sentry Init"); + expect(frame).toContain("We'll use AI to inspect this project"); + expect(frame).toContain("Continue to let Sentry use AI for setup."); + expect(frame).toContain("Continue"); + expect(frame).toContain("Cancel"); + expect(frame).not.toMatch(LEARN_HEADER_RE); + expect(frame).not.toMatch(TASKS_HEADER_RE); + expect(frame).not.toMatch(STATUS_TAB_RE); + expect(frame).not.toMatch(FILES_TAB_RE); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + expect(hasForcedWhiteForeground(withoutFeedbackBanner(frame))).toBe(false); + }); + + test("intro preflight prompts stay centered and standalone", async () => { + const store = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + layout: "intro", + }); + store.appendLog("warn", "You have uncommitted or untracked files."); + store.appendLog("success", "Prerequisites OK"); + store.setPrompt({ + kind: "confirm", + message: "Continue with uncommitted changes?", + initialValue: true, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("███████╗███████╗"); + expect(frame).not.toContain("Sentry Init"); + expect(frame).not.toContain("uncommitted or untracked files"); + expect(frame).not.toContain("Prerequisites OK"); + expect(frame).toContain("Continue with uncommitted changes?"); + expect(frame).not.toContain("We'll use AI to inspect this project"); + expect(frame).not.toContain("Continue to let Sentry use AI for setup."); + expect(frame).not.toContain("◇ Continue with uncommitted changes?"); + expect(frame).not.toMatch(LEARN_HEADER_RE); + expect(frame).not.toMatch(TASKS_HEADER_RE); + expect(frame).not.toMatch(STATUS_TAB_RE); + expect(frame).not.toMatch(FILES_TAB_RE); + expect(frame).not.toContain("switch tab"); + expect(frame).toContain(FEEDBACK_BANNER_TEXT); + expect(hasForcedWhiteForeground(withoutFeedbackBanner(frame))).toBe(false); + }); + + test("intro logo row stays fixed across prompt heights", async () => { + const shortPrompt = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + layout: "intro", + }); + shortPrompt.setPrompt({ + kind: "confirm", + message: "Continue with setup?", + initialValue: true, + resolve: ignorePromptResolution, + }); + + const longPrompt = new WizardStore({ + bannerRows: TEST_BANNER_ROWS, + layout: "intro", + }); + longPrompt.setPrompt({ + kind: "select", + message: + "Choose the Sentry project and team context to use for this initialization before setup continues.", + options: [ + { value: "recommended", label: "Use the detected project" }, + { value: "existing", label: "Choose an existing project" }, + { value: "create", label: "Create a new project" }, + { value: "team", label: "Change team first" }, + { value: "cancel", label: "Cancel setup" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const shortFrame = ( + await renderApp(shortPrompt, 120, { rows: 24 }) + ).allOutput(); + const longFrame = ( + await renderApp(longPrompt, 120, { rows: 24 }) + ).allOutput(); + + const shortLogoLine = firstLogoLineIndex(shortFrame); + const longLogoLine = firstLogoLineIndex(longFrame); + expect(shortLogoLine).toBeGreaterThanOrEqual(0); + expect(longLogoLine).toBe(shortLogoLine); + }); + + test("feature multiselect shows available features directly", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + options: [ + { value: "sessionReplay", label: "Session Replay" }, + { + value: "performanceMonitoring", + label: "Tracing", + hint: "See request paths, spans, and bottlenecks", + }, + { value: "sourceMaps", label: "Source Maps" }, + ], + initialSelected: [], + required: false, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + const plainFrame = stripAnsi(frame); + expect(frame).toContain("Session Replay"); + expect(frame).toContain("Tracing"); + expect(frame).toContain("See request paths, spans, and bottlenecks"); + expect(frame).toContain("Source Maps"); + expect(plainFrame).toContain("0/3"); + expect(plainFrame).not.toContain( + "space toggle • a all • enter confirm • esc cancel" + ); + expect(plainFrame).toMatch(SPACE_TOGGLE_HINT_RE); + expect(plainFrame).toMatch(A_ALL_HINT_RE); + expect(plainFrame).toMatch(ENTER_CONFIRM_HINT_RE); + expect(plainFrame).toMatch(ESC_CANCEL_HINT_RE); + expect(frame).not.toContain("Recommended setup"); + expect(frame).not.toContain("Apply recommended setup"); + }); + + test("workflow prompts hide routine logs but keep warnings and tasks", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.appendLog( + "success", + 'Using existing project "nextjs-sentry-test" in bete-dev' + ); + store.appendLog("success", "Selecting features"); + store.appendLog("info", "Routine context loaded"); + store.appendLog("message", "Internal progress detail"); + store.appendLog("warn", "Heads up before choosing features"); + store.appendLog("error", "Something needs attention"); + store.setPrompt({ + kind: "multiselect", + message: "Select features", + options: [ + { value: "sessionReplay", label: "Session Replay" }, + { value: "profiling", label: "Profiling" }, + ], + initialSelected: [], + required: false, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + const plainFrame = stripAnsi(frame); + expect(frame).not.toContain("Using existing project"); + expect(plainFrame).not.toMatch(COMPLETED_SELECTING_FEATURES_RE); + expect(frame).not.toContain("Routine context loaded"); + expect(frame).not.toContain("Internal progress detail"); + expect(frame).toContain("Heads up before choosing features"); + expect(frame).toContain("Something needs attention"); + expect(frame).toContain("Select features"); + expect(frame).toMatch(TASKS_HEADER_RE); + }); + + test("prompt shortcuts replace app shortcuts while prompt is active", async () => { + const store = new WizardStore({ bannerRows: [] }); + store.setPrompt({ + kind: "select", + message: "Choose a feature", + options: [ + { value: "errors", label: "Error Monitoring" }, + { value: "tracing", label: "Tracing" }, + ], + initialIndex: 0, + resolve: ignorePromptResolution, + }); + + const frame = (await renderApp(store, 120)).allOutput(); + expect(frame).toContain("navigate"); + expect(frame).toContain("confirm"); + expect(frame).toContain("cancel"); + expect(frame).not.toContain("switch tab"); + }); + + test("file scroll shortcut appears only when the file tree overflows", async () => { + const shortTree = new WizardStore({ bannerRows: [] }); + shortTree.recordFilesReading(["src/app.ts"]); + shortTree.markFilesAnalyzed(["src/app.ts"]); + + const shortFrame = ( + await renderApp(shortTree, 120, { + input: [RIGHT_ARROW], + rows: 16, + }) + ).allOutput(); + expect(shortFrame).toMatch(FILES_HEADER_PINNED_RE); + expect(shortFrame).not.toContain("scroll"); + + const tallTree = new WizardStore({ bannerRows: [] }); + const readFiles = makeReadFiles(12); + tallTree.recordFilesReading(readFiles); + tallTree.markFilesAnalyzed(readFiles); + + const tallFrame = ( + await renderApp(tallTree, 120, { + input: [RIGHT_ARROW], + rows: 16, + }) + ).allOutput(); + expect(tallFrame).toMatch(FILES_HEADER_PINNED_RE); + expect(tallFrame).toContain("scroll"); }); test("Status screen shows logs and banner, not file tree", async () => { diff --git a/test/lib/init/ui/ink-report.test.ts b/test/lib/init/ui/ink-report.test.ts new file mode 100644 index 000000000..a2c008f1e --- /dev/null +++ b/test/lib/init/ui/ink-report.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { stripAnsi } from "../../../../src/lib/formatters/plain-detect.js"; +import { + formatFailureReport, + formatSuccessReport, +} from "../../../../src/lib/init/ui/ink-report.js"; + +describe("Ink post-dispose feedback reports", () => { + test("success report includes the success feedback command", () => { + const output = stripAnsi( + formatSuccessReport( + "Sentry SDK installed successfully!", + undefined, + [ + "Nice, setup made it through.", + "Tell us what felt great or rough:", + '$ sentry cli feedback "sentry init worked well"', + ].join("\n") + ) + ); + + expect(output).toContain("Sentry SDK installed successfully!"); + expect(output).toContain( + [ + "Nice, setup made it through.", + " Tell us what felt great or rough:", + ' $ sentry cli feedback "sentry init worked well"', + "", + ].join("\n") + ); + expect(output.endsWith("\n")).toBe(true); + }); + + test("failure report includes the failed feedback command", () => { + const output = stripAnsi( + formatFailureReport( + "Setup failed", + [], + [ + "Setup hit a wall.", + "Tell us what happened so we can fix it:", + '$ sentry cli feedback "sentry init failed"', + ].join("\n") + ) + ); + + expect(output).toContain("Setup failed"); + expect(output).toContain( + [ + "Setup hit a wall.", + " Tell us what happened so we can fix it:", + ' $ sentry cli feedback "sentry init failed"', + "", + ].join("\n") + ); + expect(output.endsWith("\n")).toBe(true); + }); + + test("cancel report includes the cancelled feedback command", () => { + const output = stripAnsi( + formatFailureReport( + "Setup cancelled.", + [], + [ + "Sad to see setup stop. Was something going sideways?", + "Tell us so we can fix it:", + '$ sentry cli feedback "sentry init was cancelled"', + ].join("\n") + ) + ); + + expect(output).toContain("Setup cancelled."); + expect(output).toContain( + [ + "Sad to see setup stop. Was something going sideways?", + " Tell us so we can fix it:", + ' $ sentry cli feedback "sentry init was cancelled"', + "", + ].join("\n") + ); + expect(output.endsWith("\n")).toBe(true); + }); +}); diff --git a/test/lib/init/ui/ink-shortcuts.test.ts b/test/lib/init/ui/ink-shortcuts.test.ts new file mode 100644 index 000000000..f41761d19 --- /dev/null +++ b/test/lib/init/ui/ink-shortcuts.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { arrangeShortcutHints } from "../../../../src/lib/init/ui/ink-shortcuts.js"; + +describe("arrangeShortcutHints", () => { + test("orders by priority while preserving same-priority order", () => { + expect( + arrangeShortcutHints([ + { key: "enter", action: "confirm", priority: 40 }, + { key: "\u2190\u2192", action: "switch tab", priority: 10 }, + { key: "esc", action: "cancel", priority: 40 }, + ]) + ).toEqual([ + { key: "\u2190\u2192", action: "switch tab", priority: 10 }, + { key: "enter", action: "confirm", priority: 40 }, + { key: "esc", action: "cancel", priority: 40 }, + ]); + }); + + test("drops duplicate key/action pairs", () => { + expect( + arrangeShortcutHints([ + { key: "s", action: "toggle status", priority: 20 }, + { key: "s", action: "toggle status", priority: 20 }, + { key: "s", action: "save", priority: 30 }, + ]) + ).toEqual([ + { key: "s", action: "toggle status", priority: 20 }, + { key: "s", action: "save", priority: 30 }, + ]); + }); +}); diff --git a/test/lib/init/ui/logging-ui.test.ts b/test/lib/init/ui/logging-ui.test.ts index 86c01fde6..f26322b8d 100644 --- a/test/lib/init/ui/logging-ui.test.ts +++ b/test/lib/init/ui/logging-ui.test.ts @@ -71,6 +71,21 @@ describe("LoggingUI lifecycle messages", () => { expect(stdout()).toBe(""); expect(stderr()).toBe("Aborted by user\n"); }); + + test("feedback writes the copy-paste command to stdout", () => { + const { ui, stdout, stderr } = createUI(); + ui.feedback("cancelled"); + expect(stdout()).toBe( + [ + "Sad to see setup stop. Was something going sideways?", + "Tell us so we can fix it:", + '$ sentry cli feedback "sentry init was cancelled"', + "", + "", + ].join("\n") + ); + expect(stderr()).toBe(""); + }); }); describe("LoggingUI log API", () => { diff --git a/test/lib/init/ui/mock-ui.ts b/test/lib/init/ui/mock-ui.ts index 180930f4a..460bf5553 100644 --- a/test/lib/init/ui/mock-ui.ts +++ b/test/lib/init/ui/mock-ui.ts @@ -11,6 +11,7 @@ * test-only helper — it should not be bundled into the CLI. */ +import type { InitFeedbackOutcome } from "../../../../src/lib/init/feedback.js"; import { CANCELLED, type Cancelled, @@ -19,6 +20,7 @@ import { type SelectOptions, type SpinnerExitCode, type SpinnerHandle, + type WelcomeOptions, type WizardLog, type WizardSummary, type WizardUI, @@ -30,6 +32,7 @@ export type MockCall = | { kind: "summary"; summary: WizardSummary } | { kind: "outro"; message: string } | { kind: "cancel"; message: string } + | { kind: "feedback"; outcome: InitFeedbackOutcome } | { kind: "log.info"; message: string } | { kind: "log.warn"; message: string } | { kind: "log.error"; message: string } @@ -39,6 +42,7 @@ export type MockCall = | { kind: "spinner.message"; message?: string } | { kind: "spinner.stop"; message?: string; code?: SpinnerExitCode } | { kind: "select"; message: string; options: string[] } + | { kind: "welcome"; options: WelcomeOptions } | { kind: "multiselect"; message: string; @@ -46,6 +50,7 @@ export type MockCall = initialValues?: string[]; } | { kind: "confirm"; message: string; initialValue?: boolean } + | { kind: "setIntroMode"; enabled: boolean } | { kind: "recordFilesReading"; paths: string[] } | { kind: "markFilesAnalyzed"; paths: string[] } | { @@ -60,20 +65,26 @@ export type MockCall = * user abort). */ export type MockResponse = + | { kind: "welcome"; value: "continue" | Cancelled } | { kind: "select"; value: string | Cancelled } | { kind: "multiselect"; value: string[] | Cancelled } | { kind: "confirm"; value: boolean | Cancelled }; +type MockUIOptions = { + welcome?: boolean; +}; + /** * Build a mock `WizardUI` plus its observable state. * * Returns the impl, the call trace, and a `respond()` helper for * pushing canned responses onto the prompt queue. */ -export function createMockUI(): { +export function createMockUI(options: MockUIOptions = {}): { ui: WizardUI; calls: MockCall[]; respond: { + welcome(value: "continue" | Cancelled): void; select(value: string | Cancelled): void; multiselect(value: string[] | Cancelled): void; confirm(value: boolean | Cancelled): void; @@ -120,12 +131,14 @@ export function createMockUI(): { summary: (summary) => calls.push({ kind: "summary", summary }), outro: (message) => calls.push({ kind: "outro", message }), cancel: (message) => calls.push({ kind: "cancel", message }), + feedback: (outcome) => calls.push({ kind: "feedback", outcome }), recordFilesReading: (paths) => calls.push({ kind: "recordFilesReading", paths }), markFilesAnalyzed: (paths) => calls.push({ kind: "markFilesAnalyzed", paths }), setStep: (stepId, status) => calls.push({ kind: "setStep", stepId, status }), + setIntroMode: (enabled) => calls.push({ kind: "setIntroMode", enabled }), log, spinner, select: (opts: SelectOptions) => { @@ -158,10 +171,18 @@ export function createMockUI(): { [Symbol.asyncDispose]: () => Promise.resolve(), }; + if (options.welcome) { + ui.welcome = (opts: WelcomeOptions) => { + calls.push({ kind: "welcome", options: opts }); + return Promise.resolve(takeResponse("welcome")); + }; + } + return { ui, calls, respond: { + welcome: (value) => responses.push({ kind: "welcome", value }), select: (value) => responses.push({ kind: "select", value }), multiselect: (value) => responses.push({ kind: "multiselect", value }), confirm: (value) => responses.push({ kind: "confirm", value }), diff --git a/test/lib/init/wizard-runner.test.ts b/test/lib/init/wizard-runner.test.ts index 56825ccdc..e39d975b3 100644 --- a/test/lib/init/wizard-runner.test.ts +++ b/test/lib/init/wizard-runner.test.ts @@ -34,9 +34,10 @@ import type { } from "../../../src/lib/init/types.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as uiFactory from "../../../src/lib/init/ui/factory.js"; -import type { - SpinnerHandle, - WizardUI, +import { + CANCELLED, + type SpinnerHandle, + type WizardUI, } from "../../../src/lib/init/ui/types.js"; import { runWizard } from "../../../src/lib/init/wizard-runner.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference @@ -115,6 +116,33 @@ let capturedClientOptions: { abortSignal?: AbortSignal }[] = []; let savedPlainOutput: string | undefined; +function forceStdinTty(action: () => Promise): Promise { + const originalDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY" + ); + Object.defineProperty(process.stdin, "isTTY", { + value: true, + configurable: true, + writable: true, + }); + return action().finally(() => { + if (originalDescriptor) { + Object.defineProperty(process.stdin, "isTTY", originalDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + }); +} + +function useMockUI(ui: WizardUI, calls: MockCall[]): void { + mockUICalls = calls; + getUISpy.mockResolvedValue({ + ...ui, + spinner: () => spinnerMock, + }); +} + beforeEach(() => { // Force rich output so clack-plain.ts delegates to real clack (spied below) savedPlainOutput = process.env.SENTRY_PLAIN_OUTPUT; @@ -249,6 +277,16 @@ function lastCancelMessage(): string | undefined { return; } +function lastFeedbackOutcome(): string | undefined { + for (let i = mockUICalls.length - 1; i >= 0; i--) { + const call = mockUICalls[i]; + if (call?.kind === "feedback") { + return call.outcome; + } + } + return; +} + function lastWarn(): string | undefined { for (let i = mockUICalls.length - 1; i >= 0; i--) { const call = mockUICalls[i]; @@ -269,20 +307,27 @@ describe("runWizard", () => { }); test("throws when stdin is not a TTY without --yes", async () => { - const originalIsTTY = process.stdin.isTTY; + const originalDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "isTTY" + ); Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true, + writable: true, }); - await expect(runWizard(makeOptions({ yes: false }))).rejects.toThrow( - WizardError - ); - - Object.defineProperty(process.stdin, "isTTY", { - value: originalIsTTY, - configurable: true, - }); + try { + await expect(runWizard(makeOptions({ yes: false }))).rejects.toThrow( + WizardError + ); + } finally { + if (originalDescriptor) { + Object.defineProperty(process.stdin, "isTTY", originalDescriptor); + } else { + delete (process.stdin as { isTTY?: boolean }).isTTY; + } + } }); test("passes dry-run as non-interactive into preflight", async () => { @@ -295,6 +340,112 @@ describe("runWizard", () => { expect(lastWarn()).toContain("Dry-run"); }); + test("uses rich welcome screen when available", async () => { + const { ui, calls, respond } = createMockUI({ welcome: true }); + respond.welcome("continue"); + useMockUI(ui, calls); + + await forceStdinTty(() => + runWizard( + makeOptions({ + yes: false, + features: ["errorMonitoring", "performanceMonitoring"], + org: "bete-dev", + project: "nextjs", + }) + ) + ); + + const welcome = calls.find((call) => call.kind === "welcome"); + expect(welcome).toBeDefined(); + if (welcome?.kind !== "welcome") { + throw new Error("expected welcome call"); + } + expect(welcome.options.title).toBe("Sentry Init"); + expect(welcome.options.body).toContain( + "We'll use AI to inspect this project and configure Sentry." + ); + expect(welcome.options.punchline).toContain("use AI for setup"); + expect(getUISpy.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + initialWelcome: expect.objectContaining({ + title: "Sentry Init", + }), + }) + ); + expect( + calls.some((call) => call.kind === "select" || call.kind === "confirm") + ).toBe(false); + const introOn = calls.findIndex( + (call) => call.kind === "setIntroMode" && call.enabled + ); + const introOff = calls.findIndex( + (call) => call.kind === "setIntroMode" && !call.enabled + ); + expect(introOn).toBeGreaterThanOrEqual(0); + expect(introOff).toBeGreaterThanOrEqual(0); + expect(spinnerMock.message.mock.calls).toContainEqual([ + "Connecting to wizard...", + ]); + expect(formatResultSpy).toHaveBeenCalled(); + }); + + test("does not log a second AI disclaimer after welcome", async () => { + const { ui, calls, respond } = createMockUI({ welcome: true }); + respond.welcome("continue"); + useMockUI(ui, calls); + + await forceStdinTty(() => + runWizard( + makeOptions({ + yes: false, + features: ["errorMonitoring"], + org: "bete-dev", + project: "nextjs", + }) + ) + ); + + const infoMessages = calls + .filter((call) => call.kind === "log.info") + .map((call) => call.message); + expect( + infoMessages.some((message) => message.includes("This wizard uses AI")) + ).toBe(false); + expect( + infoMessages.some((message) => message.includes("For manual setup")) + ).toBe(false); + }); + + test("cancels cleanly from rich welcome screen", async () => { + const { ui, calls, respond } = createMockUI({ welcome: true }); + respond.welcome(CANCELLED); + useMockUI(ui, calls); + + await forceStdinTty(() => runWizard(makeOptions({ yes: false }))); + + expect(process.exitCode).toBe(0); + expect(lastCancelMessage()).toBe("Setup cancelled."); + expect(lastFeedbackOutcome()).toBe("cancelled"); + expect(getWorkflowSpy).not.toHaveBeenCalled(); + }); + + test("falls back to generic continue prompt without rich welcome", async () => { + const { ui, calls, respond } = createMockUI(); + respond.select("continue"); + useMockUI(ui, calls); + + await forceStdinTty(() => runWizard(makeOptions({ yes: false }))); + + const select = calls.find((call) => call.kind === "select"); + expect(select).toBeDefined(); + if (select?.kind !== "select") { + throw new Error("expected select call"); + } + expect(select.message).toContain("experimental"); + expect(formatResultSpy).toHaveBeenCalled(); + }); + test("stops before workflow creation when preflight returns null", async () => { resolveInitContextSpy.mockResolvedValue(null); @@ -310,6 +461,7 @@ describe("runWizard", () => { await runWizard(makeOptions()); expect(lastCancelMessage()).toBe("Setup cancelled."); + expect(lastFeedbackOutcome()).toBe("cancelled"); expect(getWorkflowSpy).not.toHaveBeenCalled(); }); @@ -476,6 +628,7 @@ describe("runWizard", () => { expect(spinnerMock.stop).toHaveBeenCalledWith("Error", 1); expect(lastCancelMessage()).toBe("Setup failed"); + expect(lastFeedbackOutcome()).toBe("failed"); }); test("tears down forwarding and stops the spinner on cancellation", async () => { @@ -498,6 +651,8 @@ describe("runWizard", () => { expect(process.exitCode).toBe(0); expect(spinnerMock.stop).toHaveBeenCalledWith("Cancelled", 0); + expect(lastCancelMessage()).toBe("Setup cancelled."); + expect(lastFeedbackOutcome()).toBe("cancelled"); }); test("tears down forwarding when a WizardError is rethrown from a tool", async () => { @@ -525,6 +680,8 @@ describe("runWizard", () => { await expect(runWizard(makeOptions())).rejects.toThrow(WizardError); expect(spinnerMock.stop).toHaveBeenCalledWith("Error", 1); + expect(lastCancelMessage()).toBe("Setup failed"); + expect(lastFeedbackOutcome()).toBe("failed"); }); test("shows count-based messages while reading and analyzing files", async () => {