From e6aea13e19697b7274848ce69b0cb3947b4db127 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 6 Oct 2026 18:14:59 -0400 Subject: [PATCH 1/4] feat(cli): Add optional Snake game to the init waiting screen `sentry init` spends a long time waiting on the remote setup workflow. While it waits, a small box invites the user to press `g` and play Snake in the activity pane. The setup spinner and progress sidebar stay visible. The game yields the pane when the wizard needs attention: a prompt, a new warning or error, or the end of the run closes the board and pauses the game, and the invite then offers to resume. Game state lives outside WizardStore so each tick re-renders only the board, which is drawn as a single half-block string. `pnpm --filter sentry run proto:snake` drives the real Ink UI through a fake, offline workflow for local testing. Co-Authored-By: Claude Opus 5.5 --- packages/cli/package.json | 1 + packages/cli/script/prototype-init-snake.ts | 53 ++++ packages/cli/src/lib/init/ui/ink-app.tsx | 112 +++++++- packages/cli/src/lib/init/ui/ink-snake.tsx | 227 +++++++++++++++ packages/cli/src/lib/init/ui/snake-game.ts | 270 ++++++++++++++++++ .../lib/init/ui/ink-app.snapshot.test.tsx | 41 +++ .../cli/test/lib/init/ui/snake-game.test.ts | 119 ++++++++ 7 files changed, 813 insertions(+), 10 deletions(-) create mode 100644 packages/cli/script/prototype-init-snake.ts create mode 100644 packages/cli/src/lib/init/ui/ink-snake.tsx create mode 100644 packages/cli/src/lib/init/ui/snake-game.ts create mode 100644 packages/cli/test/lib/init/ui/snake-game.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index e3556c168..c934cbe3b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -57,6 +57,7 @@ "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", "cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", + "proto:snake": "tsx --import ./script/require-shim.mjs script/prototype-init-snake.ts", "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", "build": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", diff --git a/packages/cli/script/prototype-init-snake.ts b/packages/cli/script/prototype-init-snake.ts new file mode 100644 index 000000000..a130800cf --- /dev/null +++ b/packages/cli/script/prototype-init-snake.ts @@ -0,0 +1,53 @@ +/** + * Prototype harness: drives the real `sentry init` Ink UI through a fake, + * offline workflow so the waiting-screen Snake game can be tried locally. + * No network calls, no auth, no file changes. + * + * pnpm --filter sentry run proto:snake + * PROTO_STEP_MS=5000 pnpm --filter sentry run proto:snake + */ + +import { setTimeout as sleep } from "node:timers/promises"; +import { createInkUI } from "../src/lib/init/ui/ink-ui.js"; + +const STEP_MS = Number(process.env.PROTO_STEP_MS ?? 20_000); + +const STEPS: Array<{ id: string; message: string; files?: string[] }> = [ + { + id: "discover-context", + message: "Reading project files...", + files: ["package.json", "src/index.ts", "src/server.ts", "tsconfig.json"], + }, + { id: "detect-platform", message: "Detecting platform..." }, + { id: "ensure-sentry-project", message: "Creating Sentry project..." }, + { id: "plan-codemods", message: "Planning SDK setup..." }, + { id: "apply-codemods", message: "Applying changes..." }, + { id: "verify-changes", message: "Verifying setup..." }, +]; + +const ui = await createInkUI(); +ui.setIntroMode?.(false); +const spin = ui.spinner(); + +for (const [index, step] of STEPS.entries()) { + ui.setStep?.(step.id, "in_progress"); + spin.start(step.message); + if (step.files) { + ui.recordFilesReading?.(step.files); + } + await sleep(STEP_MS); + spin.stop(`${step.message.replace("...", "")} done`, 0); + ui.setStep?.(step.id, "completed"); + + // Mid-run prompt checks that the game yields the pane and keys. + if (index === 1) { + const answer = await ui.confirm({ + message: "Prototype prompt: does the game get out of the way?", + initialValue: true, + }); + ui.log.info(`Prompt answered: ${String(answer)}`); + } +} + +ui.outro("Prototype finished. Press any key to exit."); +await ui[Symbol.asyncDispose](); diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index b64e7fa6a..ea7b10b32 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -62,6 +62,12 @@ import { ShortcutHintProvider, useInkShortcuts, } from "./ink-shortcuts.js"; +import { + createSnakeSession, + SnakeGame, + SnakeInvite, + snakeBoardSize, +} from "./ink-snake.js"; import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js"; import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; import type { @@ -96,6 +102,12 @@ const COLOR_INFO = "#9C84D4"; const COLOR_WARN = "#FDB81B"; const COLOR_ERROR = "#fe4144"; const COLOR_SUCCESS = "#83da90"; + +/** Below this height the invite and board crowd out the activity log. */ +const MIN_SNAKE_ROWS = 20; +/** Tab bar, shortcut hints, and feedback banner under the panes. */ +const SNAKE_FOOTER_ROWS = 3; + const ACTIVE_TASK_PULSE_INTERVAL_MS = 600; const ICON_BY_SEVERITY: Record = @@ -196,11 +208,45 @@ function AppBody({ store }: AppProps): React.ReactNode { ); const { columns, rows } = useInkFrameSize(); const [activeTab, setActiveTab] = useState(0); + const [snakeSession] = useState(createSnakeSession); + // Latest warning/error log id when the game opened; null while closed. + const [snakeAlertBaseline, setSnakeAlertBaseline] = useState( + null, + ); const width = getInkFrameWidth(columns); const contentHeight = Math.max(5, rows - 4); const isWide = width >= 80; + const canPlaySnake = + snapshot.layout === "workflow" && + snapshot.prompt === null && + snapshot.outroState === null && + snapshot.summary === null && + activeTab === 0 && + rows >= MIN_SNAKE_ROWS; + const latestAlertId = latestAlertLogId(snapshot.logs); + const showSnake = + snakeAlertBaseline !== null && + snakeAlertBaseline === latestAlertId && + canPlaySnake; + // A prompt, a new warning or error, or the end of the run takes the pane + // back from the game so the user sees what needs attention. + useEffect(() => { + if (snakeAlertBaseline !== null && !showSnake) { + setSnakeAlertBaseline(null); + } + }, [showSnake, snakeAlertBaseline]); + const closeSnake = useCallback(() => setSnakeAlertBaseline(null), []); + const requestCancel = snapshot.requestCancel; + const cancelFromSnake = useCallback(() => requestCancel?.(), [requestCancel]); + // Mirrors the frame layout: sidebar is 40% plus a 1-col gap; the spinner + // row (2) is always reserved so the board keeps its size between steps. + const snakeBoard = snakeBoardSize( + isWide ? width - Math.round(width * 0.4) - 1 : width, + contentHeight - SNAKE_FOOTER_ROWS - 2, + ); + const tabs = useMemo( () => [ { id: "status", label: "Status" }, @@ -234,13 +280,23 @@ function AppBody({ store }: AppProps): React.ReactNode { }, }, ]; + if (canPlaySnake) { + bindings.push({ + key: "g", + action: "play snake", + priority: 20, + match: (input) => input === "g", + run: () => setSnakeAlertBaseline(latestAlertId), + }); + } return bindings; - }, [snapshot.requestCancel, tabs.length]); + }, [canPlaySnake, latestAlertId, snapshot.requestCancel, tabs.length]); useInkShortcuts("init-app", appShortcuts, { isActive: snapshot.layout === "workflow" && snapshot.prompt === null && - snapshot.outroState === null, + snapshot.outroState === null && + !showSnake, }); if (snapshot.outroState?.kind === "success") { @@ -308,14 +364,40 @@ function AppBody({ store }: AppProps): React.ReactNode { overflow="hidden" > - {activeTab === 0 ? ( - + {showSnake ? ( + + {snapshot.spinner.active ? ( + + ) : ( + + )} + + + ) : activeTab === 0 ? ( + <> + + {canPlaySnake ? ( + + ) : null} + ) : ( = 0; index--) { + const entry = logs[index]; + if (entry?.severity === "warn" || entry?.severity === "error") { + return entry.id; + } + } + return 0; +} + // ────────────────────────────── Sidebar ─────────────────────────────── function Sidebar({ diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx new file mode 100644 index 000000000..4c62ae6cf --- /dev/null +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -0,0 +1,227 @@ +import { Box, Text } from "ink"; +import { useCallback, useEffect, useMemo, useReducer } from "react"; +import { type ShortcutBinding, useInkShortcuts } from "./ink-shortcuts.js"; +import { + createSnake, + pauseSnake, + renderSnake, + type SnakeDirection, + type SnakeState, + type SnakeStatus, + snakeTickMs, + stepSnake, + togglePause, + turnSnake, +} from "./snake-game.js"; + +type InkKey = Parameters[1]; + +/** + * Lives above the game view so a prompt can unmount the board without + * losing the run in progress. + */ +export type SnakeSession = { state: SnakeState | null; best: number }; + +export function createSnakeSession(): SnakeSession { + return { state: null, best: 0 }; +} + +const MAX_BOARD_COLS = 60; +const MAX_BOARD_TERMINAL_ROWS = 14; +/** Border (2) plus the score line under the board. */ +const BOARD_CHROME_ROWS = 3; + +/** Board size in cells for a pane of `cols` × `rows` terminal cells. */ +export function snakeBoardSize( + cols: number, + rows: number, +): { width: number; height: number } { + const width = Math.max(10, Math.min(MAX_BOARD_COLS, cols - 2)); + const terminalRows = Math.max( + 3, + Math.min(MAX_BOARD_TERMINAL_ROWS, rows - BOARD_CHROME_ROWS), + ); + return { width, height: terminalRows * 2 }; +} + +function steerDirection(input: string, key: InkKey): SnakeDirection | null { + if (key.upArrow || input === "w" || input === "k") { + return "up"; + } + if (key.downArrow || input === "s" || input === "j") { + return "down"; + } + if (key.leftArrow || input === "a" || input === "h") { + return "left"; + } + if (key.rightArrow || input === "d" || input === "l") { + return "right"; + } + return null; +} + +const STATUS_HINT: Record = { + ready: "Press an arrow key to start", + playing: "", + paused: "Paused · p to resume", + over: "Game over · r to retry", +}; + +export function SnakeGame({ + accent, + height, + muted, + onCancel, + onExit, + session, + width, +}: { + accent: string; + height: number; + muted: string; + onCancel: () => void; + onExit: () => void; + session: SnakeSession; + width: number; +}): React.ReactNode { + const [, redraw] = useReducer((frame: number) => frame + 1, 0); + if ( + !session.state || + session.state.width !== width || + session.state.height !== height + ) { + session.state = createSnake(width, height); + } + const state = session.state; + + const apply = useCallback( + (update: (current: SnakeState) => SnakeState) => { + if (!session.state) { + return; + } + const next = update(session.state); + if (next === session.state) { + return; + } + session.state = next; + session.best = Math.max(session.best, next.score); + redraw(); + }, + [session], + ); + + useEffect(() => { + if (state.status !== "playing") { + return; + } + const timer = setTimeout(() => apply(stepSnake), snakeTickMs(state)); + return () => clearTimeout(timer); + }, [apply, state]); + + useEffect( + () => () => { + if (session.state) { + session.state = pauseSnake(session.state); + } + }, + [session], + ); + + const bindings = useMemo( + () => [ + { + key: "ctrl+c", + action: "cancel", + priority: 0, + showInFooter: false, + match: (input, key) => key.ctrl && input === "c", + run: onCancel, + }, + { + key: "←↑↓→", + action: "steer", + priority: 10, + match: (input, key) => steerDirection(input, key) !== null, + run: (input, key) => { + const direction = steerDirection(input, key); + if (direction) { + apply((current) => turnSnake(current, direction)); + } + }, + }, + { + key: "p", + action: "pause", + priority: 20, + match: (input) => input === "p" || input === " ", + run: () => apply(togglePause), + }, + { + key: "r", + action: "restart", + priority: 30, + match: (input) => input === "r", + run: () => apply(() => createSnake(width, height)), + }, + { + key: "esc", + action: "back to setup", + priority: 40, + match: (input, key) => key.escape || input === "q", + run: onExit, + }, + ], + [apply, height, onCancel, onExit, width], + ); + useInkShortcuts("snake-game", bindings); + + const board = useMemo(() => renderSnake(state).join("\n"), [state]); + + return ( + + + {board} + + + Bugs squashed {state.score} + Best {session.best} + {STATUS_HINT[state.status]} + + + ); +} + +export function SnakeInvite({ + accent, + muted, + session, +}: { + accent: string; + muted: string; + session: SnakeSession; +}): React.ReactNode { + const paused = session.state?.status === "paused"; + return ( + + + {paused + ? `Your snake is waiting (${session.state?.score ?? 0} bugs squashed).` + : "Got a minute? Squash some bugs while we work."} + + + Press{" "} + + g + {" "} + to {paused ? "resume" : "play"} Snake {"→"} + + + ); +} diff --git a/packages/cli/src/lib/init/ui/snake-game.ts b/packages/cli/src/lib/init/ui/snake-game.ts new file mode 100644 index 000000000..f16a9bd60 --- /dev/null +++ b/packages/cli/src/lib/init/ui/snake-game.ts @@ -0,0 +1,270 @@ +/** + * Snake engine for the `sentry init` waiting screen. + * + * Pure state transitions plus a string renderer so the Ink component + * only owns timing and input. The board uses half-block glyphs: one + * terminal row holds two board rows, which makes cells roughly square + * and keeps the whole frame a single `` node for Ink. + */ + +export type SnakeDirection = "up" | "down" | "left" | "right"; + +export type SnakeStatus = "ready" | "playing" | "paused" | "over"; + +export type SnakePoint = { x: number; y: number }; + +export type SnakeState = { + width: number; + height: number; + /** Head first. */ + body: SnakePoint[]; + direction: SnakeDirection; + /** Turns pressed faster than the tick rate, applied one per step. */ + queuedTurns: SnakeDirection[]; + food: SnakePoint | null; + score: number; + status: SnakeStatus; +}; + +export type RandomSource = () => number; + +const MAX_QUEUED_TURNS = 2; +const START_LENGTH = 3; + +const OPPOSITE: Record = { + up: "down", + down: "up", + left: "right", + right: "left", +}; + +const DELTA: Record = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +}; + +export function createSnake( + width: number, + height: number, + random: RandomSource = Math.random, +): SnakeState { + const y = Math.floor(height / 2); + const headX = Math.min(width - 1, Math.floor(width / 3) + START_LENGTH); + const body: SnakePoint[] = []; + for (let i = 0; i < START_LENGTH; i++) { + body.push({ x: Math.max(0, headX - i), y }); + } + const state: SnakeState = { + width, + height, + body, + direction: "right", + queuedTurns: [], + food: null, + score: 0, + status: "ready", + }; + return { ...state, food: placeFood(state, random) }; +} + +/** Milliseconds between steps; the snake speeds up as it grows. */ +export function snakeTickMs(state: SnakeState): number { + return Math.max(55, 110 - state.score * 3); +} + +export function turnSnake( + state: SnakeState, + direction: SnakeDirection, +): SnakeState { + if (state.status === "over") { + return state; + } + // Any steering key also starts a ready game or resumes a paused one. + const started: SnakeState = + state.status === "playing" ? state : { ...state, status: "playing" }; + const last = state.queuedTurns.at(-1) ?? state.direction; + if ( + direction === last || + direction === OPPOSITE[last] || + state.queuedTurns.length >= MAX_QUEUED_TURNS + ) { + return started; + } + return { ...started, queuedTurns: [...state.queuedTurns, direction] }; +} + +export function togglePause(state: SnakeState): SnakeState { + if (state.status === "playing") { + return { ...state, status: "paused" }; + } + if (state.status === "paused") { + return { ...state, status: "playing" }; + } + return state; +} + +export function pauseSnake(state: SnakeState): SnakeState { + return state.status === "playing" ? { ...state, status: "paused" } : state; +} + +export function stepSnake( + state: SnakeState, + random: RandomSource = Math.random, +): SnakeState { + if (state.status !== "playing") { + return state; + } + const [nextTurn, ...queuedTurns] = state.queuedTurns; + const direction = nextTurn ?? state.direction; + const head = state.body[0]; + if (!head) { + return { ...state, status: "over" }; + } + const delta = DELTA[direction]; + const next = { x: head.x + delta.x, y: head.y + delta.y }; + const eats = state.food !== null && samePoint(next, state.food); + // The tail moves away this step unless the snake grows, so the head may + // enter the cell the tail is leaving. + const blockers = eats ? state.body : state.body.slice(0, -1); + if ( + next.x < 0 || + next.y < 0 || + next.x >= state.width || + next.y >= state.height || + blockers.some((part) => samePoint(part, next)) + ) { + return { ...state, direction, queuedTurns, status: "over" }; + } + const body = [next, ...blockers]; + const moved: SnakeState = { ...state, body, direction, queuedTurns }; + if (!eats) { + return moved; + } + const grown = { ...moved, score: state.score + 1 }; + const food = placeFood(grown, random); + return food ? { ...grown, food } : { ...grown, food, status: "over" }; +} + +function placeFood(state: SnakeState, random: RandomSource): SnakePoint | null { + const occupied = new Set(state.body.map((part) => pointKey(part))); + const free = state.width * state.height - occupied.size; + if (free <= 0) { + return null; + } + let pick = Math.floor(random() * free); + for (let y = 0; y < state.height; y++) { + for (let x = 0; x < state.width; x++) { + if (occupied.has(pointKey({ x, y }))) { + continue; + } + if (pick === 0) { + return { x, y }; + } + pick -= 1; + } + } + return null; +} + +function samePoint(a: SnakePoint, b: SnakePoint): boolean { + return a.x === b.x && a.y === b.y; +} + +function pointKey(point: SnakePoint): string { + return `${point.x},${point.y}`; +} + +// ────────────────────────────── Rendering ───────────────────────────── + +const EMPTY = 0; +const BODY = 1; +const HEAD = 2; +const FOOD = 3; + +type CellKind = typeof EMPTY | typeof BODY | typeof HEAD | typeof FOOD; + +export type SnakePalette = Record< + Exclude, + [number, number, number] +>; + +export const DEFAULT_SNAKE_PALETTE: SnakePalette = { + [BODY]: [139, 106, 200], + [HEAD]: [196, 176, 255], + [FOOD]: [255, 90, 122], +}; + +/** Terminal rows needed to draw a board of `height` cells. */ +export function snakeTerminalRows(height: number): number { + return Math.ceil(height / 2); +} + +/** + * Draw the board as `snakeTerminalRows(height)` lines, each exactly + * `width` columns wide. Colors are 24-bit SGR sequences that change + * only between runs of different styles. + */ +export function renderSnake( + state: SnakeState, + palette: SnakePalette = DEFAULT_SNAKE_PALETTE, +): string[] { + const cells = new Uint8Array(state.width * state.height); + if (state.food) { + cells[state.food.y * state.width + state.food.x] = FOOD; + } + state.body.forEach((part, index) => { + cells[part.y * state.width + part.x] = index === 0 ? HEAD : BODY; + }); + + const lines: string[] = []; + for (let row = 0; row < snakeTerminalRows(state.height); row++) { + let line = ""; + let activeStyle = ""; + for (let x = 0; x < state.width; x++) { + const top = cells[row * 2 * state.width + x] as CellKind; + const bottomY = row * 2 + 1; + const bottom = + bottomY < state.height + ? (cells[bottomY * state.width + x] as CellKind) + : EMPTY; + const [style, glyph] = halfBlock(top, bottom, palette); + if (style !== activeStyle) { + line += activeStyle ? `\x1b[0m${style}` : style; + activeStyle = style; + } + line += glyph; + } + lines.push(activeStyle ? `${line}\x1b[0m` : line); + } + return lines; +} + +function halfBlock( + top: CellKind, + bottom: CellKind, + palette: SnakePalette, +): [string, string] { + if (top === EMPTY && bottom === EMPTY) { + return ["", " "]; + } + if (bottom === EMPTY) { + return [fg(palette[top as Exclude]), "▀"]; + } + if (top === EMPTY) { + return [fg(palette[bottom]), "▄"]; + } + if (top === bottom) { + return [fg(palette[top]), "█"]; + } + return [`${fg(palette[top])}${bg(palette[bottom])}`, "▀"]; +} + +function fg([r, g, b]: [number, number, number]): string { + return `\x1b[38;2;${r};${g};${b}m`; +} + +function bg([r, g, b]: [number, number, number]): string { + return `\x1b[48;2;${r};${g};${b}m`; +} diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 547895f2e..a204be696 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -130,6 +130,7 @@ async function renderApp( instance.unmount(); // waitUntilExit() hangs in CI — race with a short unref'd timeout. await Promise.race([ + // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. instance.waitUntilExit().catch(() => { // Ink may reject on unmount — ignore. }), @@ -1383,3 +1384,43 @@ describe("completion screen", () => { expect(text).toContain("Opened Sentry in your browser."); }); }); + +describe("snake waiting game", () => { + async function settledFrame(out: CaptureStream): Promise { + await sleep(FRAME_SETTLE_MS); + out.settledOutput = out.allOutput(); + return stripAnsi(out.latestFrame()); + } + + test("a new warning pauses the game and shows the activity log", async () => { + const store = new WizardStore(); + store.setLayout("workflow"); + store.startSpinner("Verifying setup..."); + const out = new CaptureStream(110, 32); + const stdin = makeStdin(); + const instance = render(createElement(App, { store }), { + stdout: out as unknown as NodeJS.WriteStream, + stderr: out as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + exitOnCtrlC: false, + }); + + try { + expect(await settledFrame(out)).toContain("to play Snake"); + + stdin.push("g"); + await sleep(20); + stdin.push("\u001B[A"); + expect(await settledFrame(out)).toContain("Bugs squashed"); + + store.appendLog("warn", "Could not verify setup: app failed to start"); + const frame = await settledFrame(out); + expect(frame).toContain("Could not verify setup"); + expect(frame).not.toContain("Bugs squashed"); + expect(frame).toContain("to resume Snake"); + } finally { + instance.unmount(); + } + }); +}); diff --git a/packages/cli/test/lib/init/ui/snake-game.test.ts b/packages/cli/test/lib/init/ui/snake-game.test.ts new file mode 100644 index 000000000..77edd9ab1 --- /dev/null +++ b/packages/cli/test/lib/init/ui/snake-game.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, test } from "vitest"; +import { stripAnsi } from "../../../../src/lib/formatters/plain-detect.js"; +import { + createSnake, + pauseSnake, + renderSnake, + type SnakeState, + snakeTerminalRows, + stepSnake, + togglePause, + turnSnake, +} from "../../../../src/lib/init/ui/snake-game.js"; + +const firstFreeCell = () => 0; + +function playing(overrides: Partial = {}): SnakeState { + return { + ...createSnake(10, 6, firstFreeCell), + status: "playing", + ...overrides, + }; +} + +describe("snake engine", () => { + test("starts ready and moves only once a turn starts it", () => { + const ready = createSnake(10, 6, firstFreeCell); + expect(ready.status).toBe("ready"); + expect(stepSnake(ready)).toBe(ready); + + const started = turnSnake(ready, "up"); + expect(started.status).toBe("playing"); + expect(stepSnake(started).body[0]).toEqual({ + x: ready.body[0]?.x, + y: (ready.body[0]?.y ?? 0) - 1, + }); + }); + + test("ignores a reversal into its own neck", () => { + const state = playing(); + expect(turnSnake(state, "left")).toBe(state); + }); + + test("queues quick turns and applies one per step", () => { + const state = turnSnake(turnSnake(playing(), "up"), "left"); + expect(state.queuedTurns).toEqual(["up", "left"]); + const once = stepSnake(state); + expect(once.direction).toBe("up"); + expect(stepSnake(once).direction).toBe("left"); + }); + + test("grows and scores when it eats food", () => { + const state = playing(); + const head = state.body[0]; + const ate = stepSnake({ + ...state, + food: { x: (head?.x ?? 0) + 1, y: head?.y ?? 0 }, + }); + expect(ate.score).toBe(1); + expect(ate.body).toHaveLength(state.body.length + 1); + expect(ate.food).not.toBeNull(); + }); + + test("ends the game at a wall", () => { + const state = playing({ + body: [ + { x: 9, y: 2 }, + { x: 8, y: 2 }, + ], + food: { x: 0, y: 0 }, + }); + expect(stepSnake(state).status).toBe("over"); + }); + + test("may follow its tail into the cell the tail leaves", () => { + const loop = playing({ + body: [ + { x: 1, y: 1 }, + { x: 2, y: 1 }, + { x: 2, y: 2 }, + { x: 1, y: 2 }, + ], + direction: "left", + queuedTurns: ["down"], + food: { x: 9, y: 5 }, + }); + expect(stepSnake(loop).status).toBe("playing"); + }); + + test("pause stops steps and a turn resumes", () => { + const paused = togglePause(playing()); + expect(paused.status).toBe("paused"); + expect(stepSnake(paused)).toBe(paused); + expect(pauseSnake(paused)).toBe(paused); + expect(turnSnake(paused, "up").status).toBe("playing"); + }); +}); + +describe("renderSnake", () => { + test("packs two board rows into each terminal row at a fixed width", () => { + const state = createSnake(12, 7, firstFreeCell); + const lines = renderSnake(state); + expect(lines).toHaveLength(snakeTerminalRows(7)); + for (const line of lines) { + expect(stripAnsi(line)).toHaveLength(12); + } + }); + + test("draws stacked cells with half and full blocks", () => { + const state: SnakeState = { + ...createSnake(3, 2, firstFreeCell), + body: [ + { x: 0, y: 0 }, + { x: 0, y: 1 }, + ], + food: { x: 2, y: 1 }, + }; + expect(stripAnsi(renderSnake(state)[0] ?? "")).toBe("▀ ▄"); + }); +}); From aff64ae7d4ff660382317af9f359f26cebfe2de6 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 6 Oct 2026 18:21:57 -0400 Subject: [PATCH 2/4] feat(cli): Add an off switch and keep the Snake run on resize Hide the waiting-screen game with SENTRY_INIT_GAME=0 and when an AI agent runs the CLI. Measure the game pane with useBoxMetrics instead of calculating it from the frame layout. A terminal resize now keeps the run, pauses it, and moves the snake inside the new board. The run starts over only when the snake cannot fit. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/lib/env-registry.ts | 6 ++ packages/cli/src/lib/init/ui/ink-app.tsx | 19 +----- packages/cli/src/lib/init/ui/ink-snake.tsx | 64 ++++++++++++------- packages/cli/src/lib/init/ui/ink-ui.ts | 8 +++ packages/cli/src/lib/init/ui/snake-game.ts | 45 +++++++++++++ packages/cli/src/lib/init/ui/wizard-store.ts | 3 + .../lib/init/ui/ink-app.snapshot.test.tsx | 13 +++- .../cli/test/lib/init/ui/ink-ui-snake.test.ts | 17 +++++ .../cli/test/lib/init/ui/snake-game.test.ts | 49 ++++++++++++++ 9 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 packages/cli/test/lib/init/ui/ink-ui-snake.test.ts diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index e0983fa81..96b5dc901 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -183,6 +183,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ "Control the TUI (terminal user interface) for `sentry init`. Set to `0` to disable the interactive TUI and use plain text logging output instead. Useful in CI/CD pipelines or environments without full terminal support.", example: "0", }, + { + name: "SENTRY_INIT_GAME", + description: + "Control the optional Snake game that `sentry init` offers while it waits for setup to finish. Set to `0` to hide it. The game is always hidden when an AI agent runs the CLI.", + example: "0", + }, // -- TLS / Certificates -- { name: "NODE_EXTRA_CA_CERTS", diff --git a/packages/cli/src/lib/init/ui/ink-app.tsx b/packages/cli/src/lib/init/ui/ink-app.tsx index ea7b10b32..54541642a 100644 --- a/packages/cli/src/lib/init/ui/ink-app.tsx +++ b/packages/cli/src/lib/init/ui/ink-app.tsx @@ -62,12 +62,7 @@ import { ShortcutHintProvider, useInkShortcuts, } from "./ink-shortcuts.js"; -import { - createSnakeSession, - SnakeGame, - SnakeInvite, - snakeBoardSize, -} from "./ink-snake.js"; +import { createSnakeSession, SnakeGame, SnakeInvite } from "./ink-snake.js"; import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js"; import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js"; import type { @@ -105,8 +100,6 @@ const COLOR_SUCCESS = "#83da90"; /** Below this height the invite and board crowd out the activity log. */ const MIN_SNAKE_ROWS = 20; -/** Tab bar, shortcut hints, and feedback banner under the panes. */ -const SNAKE_FOOTER_ROWS = 3; const ACTIVE_TASK_PULSE_INTERVAL_MS = 600; @@ -219,6 +212,7 @@ function AppBody({ store }: AppProps): React.ReactNode { const isWide = width >= 80; const canPlaySnake = + snapshot.snakeEnabled && snapshot.layout === "workflow" && snapshot.prompt === null && snapshot.outroState === null && @@ -240,12 +234,6 @@ function AppBody({ store }: AppProps): React.ReactNode { const closeSnake = useCallback(() => setSnakeAlertBaseline(null), []); const requestCancel = snapshot.requestCancel; const cancelFromSnake = useCallback(() => requestCancel?.(), [requestCancel]); - // Mirrors the frame layout: sidebar is 40% plus a 1-col gap; the spinner - // row (2) is always reserved so the board keeps its size between steps. - const snakeBoard = snakeBoardSize( - isWide ? width - Math.round(width * 0.4) - 1 : width, - contentHeight - SNAKE_FOOTER_ROWS - 2, - ); const tabs = useMemo( () => [ @@ -369,16 +357,15 @@ function AppBody({ store }: AppProps): React.ReactNode { {snapshot.spinner.active ? ( ) : ( + // Holds the spinner rows so the board keeps its size between steps. )} ) : activeTab === 0 ? ( diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx index 4c62ae6cf..c72183ee8 100644 --- a/packages/cli/src/lib/init/ui/ink-snake.tsx +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -1,10 +1,11 @@ -import { Box, Text } from "ink"; -import { useCallback, useEffect, useMemo, useReducer } from "react"; +import { Box, type DOMElement, Text, useBoxMetrics } from "ink"; +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; import { type ShortcutBinding, useInkShortcuts } from "./ink-shortcuts.js"; import { createSnake, pauseSnake, renderSnake, + resizeSnake, type SnakeDirection, type SnakeState, type SnakeStatus, @@ -32,7 +33,7 @@ const MAX_BOARD_TERMINAL_ROWS = 14; const BOARD_CHROME_ROWS = 3; /** Board size in cells for a pane of `cols` × `rows` terminal cells. */ -export function snakeBoardSize( +function snakeBoardSize( cols: number, rows: number, ): { width: number; height: number } { @@ -67,7 +68,38 @@ const STATUS_HINT: Record = { over: "Game over · r to retry", }; -export function SnakeGame({ +type SnakeGameProps = { + accent: string; + muted: string; + onCancel: () => void; + onExit: () => void; + session: SnakeSession; +}; + +/** Fills the space it is given and sizes the board from the measured box. */ +export function SnakeGame(props: SnakeGameProps): React.ReactNode { + const ref = useRef(null); + const pane = useBoxMetrics(ref); + const board = pane.hasMeasured + ? snakeBoardSize(pane.width, pane.height) + : null; + return ( + + {board ? ( + + ) : null} + + ); +} + +function SnakeBoard({ accent, height, muted, @@ -75,23 +107,11 @@ export function SnakeGame({ onExit, session, width, -}: { - accent: string; - height: number; - muted: string; - onCancel: () => void; - onExit: () => void; - session: SnakeSession; - width: number; -}): React.ReactNode { +}: SnakeGameProps & { height: number; width: number }): React.ReactNode { const [, redraw] = useReducer((frame: number) => frame + 1, 0); - if ( - !session.state || - session.state.width !== width || - session.state.height !== height - ) { - session.state = createSnake(width, height); - } + session.state = session.state + ? resizeSnake(session.state, width, height) + : createSnake(width, height); const state = session.state; const apply = useCallback( @@ -175,12 +195,12 @@ export function SnakeGame({ ); useInkShortcuts("snake-game", bindings); - const board = useMemo(() => renderSnake(state).join("\n"), [state]); + const frame = useMemo(() => renderSnake(state).join("\n"), [state]); return ( - {board} + {frame} Bugs squashed {state.score} diff --git a/packages/cli/src/lib/init/ui/ink-ui.ts b/packages/cli/src/lib/init/ui/ink-ui.ts index 7109657db..9f1501027 100644 --- a/packages/cli/src/lib/init/ui/ink-ui.ts +++ b/packages/cli/src/lib/init/ui/ink-ui.ts @@ -57,6 +57,8 @@ import { addBreadcrumb, setTag } from "@sentry/node-core/light"; import { FULL_BANNER_LINES } from "../../banner.js"; import { openBrowser } from "../../browser.js"; import { CLI_VERSION } from "../../constants.js"; +import { detectAgent } from "../../detect-agent.js"; +import { getEnv } from "../../env.js"; import { stripAnsi } from "../../formatters/plain-detect.js"; import { createWizardPromptTelemetry, @@ -326,6 +328,7 @@ export async function createInkUI( // Seed with the full banner; IntroScreen re-fits it to the live terminal // width on every render (shrinking or growing) so it never wraps. bannerRows: FULL_BANNER_LINES, + snakeEnabled: isSnakeEnabled(), }); const initialWelcome = opts.initialWelcome ? createPendingWelcome() @@ -378,6 +381,11 @@ export async function createInkUI( } } +/** Exported for the test suite. */ +export function isSnakeEnabled(): boolean { + return getEnv().SENTRY_INIT_GAME !== "0" && !detectAgent(); +} + /** * Subset of the Ink `Instance` type we actually use. * diff --git a/packages/cli/src/lib/init/ui/snake-game.ts b/packages/cli/src/lib/init/ui/snake-game.ts index f16a9bd60..a9c01ae6f 100644 --- a/packages/cli/src/lib/init/ui/snake-game.ts +++ b/packages/cli/src/lib/init/ui/snake-game.ts @@ -147,6 +147,51 @@ export function stepSnake( return food ? { ...grown, food } : { ...grown, food, status: "over" }; } +/** + * Fit a run to a new board size. The snake keeps its shape and moves only + * as far as it must to stay on the board. A playing run pauses so the new + * walls do not end it by surprise. A snake too long for the board starts over. + */ +export function resizeSnake( + state: SnakeState, + width: number, + height: number, + random: RandomSource = Math.random, +): SnakeState { + if (state.width === width && state.height === height) { + return state; + } + const xs = state.body.map((part) => part.x); + const ys = state.body.map((part) => part.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + if (maxX - minX >= width || maxY - minY >= height) { + return createSnake(width, height, random); + } + const dx = Math.min(0, width - 1 - maxX); + const dy = Math.min(0, height - 1 - maxY); + const shift = (point: SnakePoint): SnakePoint => ({ + x: point.x + dx, + y: point.y + dy, + }); + const resized: SnakeState = { + ...pauseSnake(state), + width, + height, + body: state.body.map(shift), + }; + const food = state.food ? shift(state.food) : null; + if (food && food.x >= 0 && food.y >= 0 && food.x < width && food.y < height) { + return { ...resized, food }; + } + const placed = placeFood(resized, random); + return placed + ? { ...resized, food: placed } + : { ...resized, food: null, status: "over" }; +} + function placeFood(state: SnakeState, random: RandomSource): SnakePoint | null { const occupied = new Set(state.body.map((part) => pointKey(part))); const free = state.width * state.height - occupied.size; diff --git a/packages/cli/src/lib/init/ui/wizard-store.ts b/packages/cli/src/lib/init/ui/wizard-store.ts index 2f0584dd7..d8a4bd3fc 100644 --- a/packages/cli/src/lib/init/ui/wizard-store.ts +++ b/packages/cli/src/lib/init/ui/wizard-store.ts @@ -204,6 +204,8 @@ export type WizardSnapshot = { prompt: ActivePrompt | null; /** Index of the currently-displayed Sentry tip in the sidebar. */ tipIndex: number; + /** Offer the waiting-screen Snake game. Off for agents and `SENTRY_INIT_GAME=0`. */ + snakeEnabled: boolean; /** Final structured summary, rendered after the workflow completes. */ summary: WizardSummary | null; /** @@ -281,6 +283,7 @@ function baseSnapshot(): WizardSnapshot { spinner: { active: false, frame: 0, message: "" }, prompt: null, tipIndex: 0, + snakeEnabled: false, summary: null, filesRead: [], steps: defaultChecklistSteps(), diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index a204be696..0c81b50f5 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -1392,9 +1392,18 @@ describe("snake waiting game", () => { return stripAnsi(out.latestFrame()); } - test("a new warning pauses the game and shows the activity log", async () => { + test("stays hidden when the game is disabled", async () => { const store = new WizardStore(); - store.setLayout("workflow"); + store.startSpinner("Verifying setup..."); + const out = await renderApp(store, 110, { rows: 32, input: ["g"] }); + const frame = stripAnsi(out.latestFrame()); + expect(frame).toContain("Verifying setup..."); + expect(frame).not.toContain("Snake"); + expect(frame).not.toContain("Bugs squashed"); + }); + + test("a new warning pauses the game and shows the activity log", async () => { + const store = new WizardStore({ snakeEnabled: true }); store.startSpinner("Verifying setup..."); const out = new CaptureStream(110, 32); const stdin = makeStdin(); diff --git a/packages/cli/test/lib/init/ui/ink-ui-snake.test.ts b/packages/cli/test/lib/init/ui/ink-ui-snake.test.ts new file mode 100644 index 000000000..a62df5008 --- /dev/null +++ b/packages/cli/test/lib/init/ui/ink-ui-snake.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; +import { withEnv } from "../../../../src/lib/env.js"; +import { isSnakeEnabled } from "../../../../src/lib/init/ui/ink-ui.js"; + +describe("isSnakeEnabled", () => { + test("offers the game to a person at a terminal", () => { + expect(withEnv({}, isSnakeEnabled)).toBe(true); + }); + + test("SENTRY_INIT_GAME=0 turns the game off", () => { + expect(withEnv({ SENTRY_INIT_GAME: "0" }, isSnakeEnabled)).toBe(false); + }); + + test("hides the game when an AI agent runs the CLI", () => { + expect(withEnv({ AI_AGENT: "claude-code" }, isSnakeEnabled)).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/init/ui/snake-game.test.ts b/packages/cli/test/lib/init/ui/snake-game.test.ts index 77edd9ab1..1e53c697c 100644 --- a/packages/cli/test/lib/init/ui/snake-game.test.ts +++ b/packages/cli/test/lib/init/ui/snake-game.test.ts @@ -4,6 +4,7 @@ import { createSnake, pauseSnake, renderSnake, + resizeSnake, type SnakeState, snakeTerminalRows, stepSnake, @@ -117,3 +118,51 @@ describe("renderSnake", () => { expect(stripAnsi(renderSnake(state)[0] ?? "")).toBe("▀ ▄"); }); }); + +describe("resizeSnake", () => { + const snake = playing({ + body: [ + { x: 8, y: 4 }, + { x: 7, y: 4 }, + { x: 6, y: 4 }, + ], + food: { x: 9, y: 5 }, + score: 4, + }); + + test("keeps the run on a larger board and pauses it", () => { + const resized = resizeSnake(snake, 20, 10, firstFreeCell); + expect(resized).toMatchObject({ + width: 20, + height: 10, + body: snake.body, + food: snake.food, + score: 4, + status: "paused", + }); + }); + + test("shifts the snake inside a smaller board", () => { + const resized = resizeSnake(snake, 6, 4, firstFreeCell); + expect(resized.body).toEqual([ + { x: 5, y: 3 }, + { x: 4, y: 3 }, + { x: 3, y: 3 }, + ]); + expect(resized.score).toBe(4); + expect(resized.food).not.toBeNull(); + const food = resized.food as { x: number; y: number }; + expect(food.x).toBeLessThan(6); + expect(food.y).toBeLessThan(4); + }); + + test("starts over when the snake no longer fits", () => { + const resized = resizeSnake(snake, 2, 4, firstFreeCell); + expect(resized.score).toBe(0); + expect(resized.status).toBe("ready"); + }); + + test("returns the same state when the size does not change", () => { + expect(resizeSnake(snake, snake.width, snake.height)).toBe(snake); + }); +}); From 4521f2f673e0b6ebee5a43c0537edcdd68b8eb98 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 6 Oct 2026 18:28:08 -0400 Subject: [PATCH 3/4] ref(cli): Remove a comment that restates the code Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/lib/init/ui/ink-snake.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx index c72183ee8..c86ed6a95 100644 --- a/packages/cli/src/lib/init/ui/ink-snake.tsx +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -76,7 +76,6 @@ type SnakeGameProps = { session: SnakeSession; }; -/** Fills the space it is given and sizes the board from the measured box. */ export function SnakeGame(props: SnakeGameProps): React.ReactNode { const ref = useRef(null); const pane = useBoxMetrics(ref); From c89d5859ebf465bb535a3bad8c23d0de32c564b9 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Tue, 6 Oct 2026 18:42:44 -0400 Subject: [PATCH 4/4] fix(cli): Pause Snake on esc and render live frames in CI tests Esc paused the run only in the board's unmount effect, after the invite had rendered, so the invite kept the play copy. Pause before closing. Ink writes no intermediate frames when it detects CI, so the live snake tests read an empty frame there. Force interactive mode in those tests. Co-Authored-By: Claude Opus 5.5 --- packages/cli/src/lib/init/ui/ink-snake.tsx | 6 ++- .../lib/init/ui/ink-app.snapshot.test.tsx | 45 ++++++++++++++----- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/lib/init/ui/ink-snake.tsx b/packages/cli/src/lib/init/ui/ink-snake.tsx index c86ed6a95..c72a1cb6a 100644 --- a/packages/cli/src/lib/init/ui/ink-snake.tsx +++ b/packages/cli/src/lib/init/ui/ink-snake.tsx @@ -187,7 +187,11 @@ function SnakeBoard({ action: "back to setup", priority: 40, match: (input, key) => key.escape || input === "q", - run: onExit, + run: () => { + // Pause before the board unmounts so the invite renders the resume copy. + apply(pauseSnake); + onExit(); + }, }, ], [apply, height, onCancel, onExit, width], diff --git a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx index 0c81b50f5..f514be6d8 100644 --- a/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx +++ b/packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx @@ -1402,9 +1402,7 @@ describe("snake waiting game", () => { expect(frame).not.toContain("Bugs squashed"); }); - test("a new warning pauses the game and shows the activity log", async () => { - const store = new WizardStore({ snakeEnabled: true }); - store.startSpinner("Verifying setup..."); + function renderLive(store: WizardStore) { const out = new CaptureStream(110, 32); const stdin = makeStdin(); const instance = render(createElement(App, { store }), { @@ -1413,16 +1411,31 @@ describe("snake waiting game", () => { stdin: stdin as unknown as NodeJS.ReadStream, patchConsole: false, exitOnCtrlC: false, + // Ink writes no intermediate frames in CI unless forced interactive. + interactive: true, }); + return { instance, out, stdin }; + } - try { - expect(await settledFrame(out)).toContain("to play Snake"); - - stdin.push("g"); - await sleep(20); - stdin.push("\u001B[A"); - expect(await settledFrame(out)).toContain("Bugs squashed"); + async function startGame(): Promise< + ReturnType & { + store: WizardStore; + } + > { + const store = new WizardStore({ snakeEnabled: true }); + store.startSpinner("Verifying setup..."); + const live = renderLive(store); + expect(await settledFrame(live.out)).toContain("to play Snake"); + live.stdin.push("g"); + await sleep(20); + live.stdin.push("\u001B[A"); + expect(await settledFrame(live.out)).toContain("Bugs squashed"); + return { ...live, store }; + } + test("a new warning pauses the game and shows the activity log", async () => { + const { instance, out, store } = await startGame(); + try { store.appendLog("warn", "Could not verify setup: app failed to start"); const frame = await settledFrame(out); expect(frame).toContain("Could not verify setup"); @@ -1432,4 +1445,16 @@ describe("snake waiting game", () => { instance.unmount(); } }); + + test("esc pauses the game and offers to resume it", async () => { + const { instance, out, stdin } = await startGame(); + try { + stdin.push("\u001B"); + const frame = await settledFrame(out); + expect(frame).not.toContain("Bugs squashed"); + expect(frame).toContain("to resume Snake"); + } finally { + instance.unmount(); + } + }); });