Skip to content

Commit aff64ae

Browse files
sergicalclaude
andcommitted
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 <noreply@anthropic.com>
1 parent e6aea13 commit aff64ae

9 files changed

Lines changed: 184 additions & 40 deletions

File tree

‎packages/cli/src/lib/env-registry.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [
183183
"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.",
184184
example: "0",
185185
},
186+
{
187+
name: "SENTRY_INIT_GAME",
188+
description:
189+
"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.",
190+
example: "0",
191+
},
186192
// -- TLS / Certificates --
187193
{
188194
name: "NODE_EXTRA_CA_CERTS",

‎packages/cli/src/lib/init/ui/ink-app.tsx‎

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,7 @@ import {
6262
ShortcutHintProvider,
6363
useInkShortcuts,
6464
} from "./ink-shortcuts.js";
65-
import {
66-
createSnakeSession,
67-
SnakeGame,
68-
SnakeInvite,
69-
snakeBoardSize,
70-
} from "./ink-snake.js";
65+
import { createSnakeSession, SnakeGame, SnakeInvite } from "./ink-snake.js";
7166
import { BLOCK_LINE_COUNT, LEARN_SEQUENCE } from "./learn-content.js";
7267
import { SENTRY_TIPS, type SentryTip } from "./sentry-tips.js";
7368
import type {
@@ -105,8 +100,6 @@ const COLOR_SUCCESS = "#83da90";
105100

106101
/** Below this height the invite and board crowd out the activity log. */
107102
const MIN_SNAKE_ROWS = 20;
108-
/** Tab bar, shortcut hints, and feedback banner under the panes. */
109-
const SNAKE_FOOTER_ROWS = 3;
110103

111104
const ACTIVE_TASK_PULSE_INTERVAL_MS = 600;
112105

@@ -219,6 +212,7 @@ function AppBody({ store }: AppProps): React.ReactNode {
219212
const isWide = width >= 80;
220213

221214
const canPlaySnake =
215+
snapshot.snakeEnabled &&
222216
snapshot.layout === "workflow" &&
223217
snapshot.prompt === null &&
224218
snapshot.outroState === null &&
@@ -240,12 +234,6 @@ function AppBody({ store }: AppProps): React.ReactNode {
240234
const closeSnake = useCallback(() => setSnakeAlertBaseline(null), []);
241235
const requestCancel = snapshot.requestCancel;
242236
const cancelFromSnake = useCallback(() => requestCancel?.(), [requestCancel]);
243-
// Mirrors the frame layout: sidebar is 40% plus a 1-col gap; the spinner
244-
// row (2) is always reserved so the board keeps its size between steps.
245-
const snakeBoard = snakeBoardSize(
246-
isWide ? width - Math.round(width * 0.4) - 1 : width,
247-
contentHeight - SNAKE_FOOTER_ROWS - 2,
248-
);
249237

250238
const tabs = useMemo<FrameTab[]>(
251239
() => [
@@ -369,16 +357,15 @@ function AppBody({ store }: AppProps): React.ReactNode {
369357
{snapshot.spinner.active ? (
370358
<SpinnerRow state={snapshot.spinner} />
371359
) : (
360+
// Holds the spinner rows so the board keeps its size between steps.
372361
<Box height={2} />
373362
)}
374363
<SnakeGame
375364
accent={ACCENT}
376-
height={snakeBoard.height}
377365
muted={MUTED_DIM}
378366
onCancel={cancelFromSnake}
379367
onExit={closeSnake}
380368
session={snakeSession}
381-
width={snakeBoard.width}
382369
/>
383370
</Box>
384371
) : activeTab === 0 ? (

‎packages/cli/src/lib/init/ui/ink-snake.tsx‎

Lines changed: 42 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { Box, Text } from "ink";
2-
import { useCallback, useEffect, useMemo, useReducer } from "react";
1+
import { Box, type DOMElement, Text, useBoxMetrics } from "ink";
2+
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
33
import { type ShortcutBinding, useInkShortcuts } from "./ink-shortcuts.js";
44
import {
55
createSnake,
66
pauseSnake,
77
renderSnake,
8+
resizeSnake,
89
type SnakeDirection,
910
type SnakeState,
1011
type SnakeStatus,
@@ -32,7 +33,7 @@ const MAX_BOARD_TERMINAL_ROWS = 14;
3233
const BOARD_CHROME_ROWS = 3;
3334

3435
/** Board size in cells for a pane of `cols` × `rows` terminal cells. */
35-
export function snakeBoardSize(
36+
function snakeBoardSize(
3637
cols: number,
3738
rows: number,
3839
): { width: number; height: number } {
@@ -67,31 +68,50 @@ const STATUS_HINT: Record<SnakeStatus, string> = {
6768
over: "Game over · r to retry",
6869
};
6970

70-
export function SnakeGame({
71+
type SnakeGameProps = {
72+
accent: string;
73+
muted: string;
74+
onCancel: () => void;
75+
onExit: () => void;
76+
session: SnakeSession;
77+
};
78+
79+
/** Fills the space it is given and sizes the board from the measured box. */
80+
export function SnakeGame(props: SnakeGameProps): React.ReactNode {
81+
const ref = useRef<DOMElement>(null);
82+
const pane = useBoxMetrics(ref);
83+
const board = pane.hasMeasured
84+
? snakeBoardSize(pane.width, pane.height)
85+
: null;
86+
return (
87+
<Box
88+
flexDirection="column"
89+
flexGrow={1}
90+
flexShrink={1}
91+
minHeight={0}
92+
overflow="hidden"
93+
ref={ref}
94+
>
95+
{board ? (
96+
<SnakeBoard {...props} height={board.height} width={board.width} />
97+
) : null}
98+
</Box>
99+
);
100+
}
101+
102+
function SnakeBoard({
71103
accent,
72104
height,
73105
muted,
74106
onCancel,
75107
onExit,
76108
session,
77109
width,
78-
}: {
79-
accent: string;
80-
height: number;
81-
muted: string;
82-
onCancel: () => void;
83-
onExit: () => void;
84-
session: SnakeSession;
85-
width: number;
86-
}): React.ReactNode {
110+
}: SnakeGameProps & { height: number; width: number }): React.ReactNode {
87111
const [, redraw] = useReducer((frame: number) => frame + 1, 0);
88-
if (
89-
!session.state ||
90-
session.state.width !== width ||
91-
session.state.height !== height
92-
) {
93-
session.state = createSnake(width, height);
94-
}
112+
session.state = session.state
113+
? resizeSnake(session.state, width, height)
114+
: createSnake(width, height);
95115
const state = session.state;
96116

97117
const apply = useCallback(
@@ -175,12 +195,12 @@ export function SnakeGame({
175195
);
176196
useInkShortcuts("snake-game", bindings);
177197

178-
const board = useMemo(() => renderSnake(state).join("\n"), [state]);
198+
const frame = useMemo(() => renderSnake(state).join("\n"), [state]);
179199

180200
return (
181201
<Box flexDirection="column" flexShrink={0}>
182202
<Box borderColor={muted} borderStyle="round" width={width + 2}>
183-
<Text>{board}</Text>
203+
<Text>{frame}</Text>
184204
</Box>
185205
<Box gap={2} paddingX={1}>
186206
<Text color={accent}>Bugs squashed {state.score}</Text>

‎packages/cli/src/lib/init/ui/ink-ui.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ import { addBreadcrumb, setTag } from "@sentry/node-core/light";
5757
import { FULL_BANNER_LINES } from "../../banner.js";
5858
import { openBrowser } from "../../browser.js";
5959
import { CLI_VERSION } from "../../constants.js";
60+
import { detectAgent } from "../../detect-agent.js";
61+
import { getEnv } from "../../env.js";
6062
import { stripAnsi } from "../../formatters/plain-detect.js";
6163
import {
6264
createWizardPromptTelemetry,
@@ -326,6 +328,7 @@ export async function createInkUI(
326328
// Seed with the full banner; IntroScreen re-fits it to the live terminal
327329
// width on every render (shrinking or growing) so it never wraps.
328330
bannerRows: FULL_BANNER_LINES,
331+
snakeEnabled: isSnakeEnabled(),
329332
});
330333
const initialWelcome = opts.initialWelcome
331334
? createPendingWelcome()
@@ -378,6 +381,11 @@ export async function createInkUI(
378381
}
379382
}
380383

384+
/** Exported for the test suite. */
385+
export function isSnakeEnabled(): boolean {
386+
return getEnv().SENTRY_INIT_GAME !== "0" && !detectAgent();
387+
}
388+
381389
/**
382390
* Subset of the Ink `Instance` type we actually use.
383391
*

‎packages/cli/src/lib/init/ui/snake-game.ts‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,51 @@ export function stepSnake(
147147
return food ? { ...grown, food } : { ...grown, food, status: "over" };
148148
}
149149

150+
/**
151+
* Fit a run to a new board size. The snake keeps its shape and moves only
152+
* as far as it must to stay on the board. A playing run pauses so the new
153+
* walls do not end it by surprise. A snake too long for the board starts over.
154+
*/
155+
export function resizeSnake(
156+
state: SnakeState,
157+
width: number,
158+
height: number,
159+
random: RandomSource = Math.random,
160+
): SnakeState {
161+
if (state.width === width && state.height === height) {
162+
return state;
163+
}
164+
const xs = state.body.map((part) => part.x);
165+
const ys = state.body.map((part) => part.y);
166+
const minX = Math.min(...xs);
167+
const minY = Math.min(...ys);
168+
const maxX = Math.max(...xs);
169+
const maxY = Math.max(...ys);
170+
if (maxX - minX >= width || maxY - minY >= height) {
171+
return createSnake(width, height, random);
172+
}
173+
const dx = Math.min(0, width - 1 - maxX);
174+
const dy = Math.min(0, height - 1 - maxY);
175+
const shift = (point: SnakePoint): SnakePoint => ({
176+
x: point.x + dx,
177+
y: point.y + dy,
178+
});
179+
const resized: SnakeState = {
180+
...pauseSnake(state),
181+
width,
182+
height,
183+
body: state.body.map(shift),
184+
};
185+
const food = state.food ? shift(state.food) : null;
186+
if (food && food.x >= 0 && food.y >= 0 && food.x < width && food.y < height) {
187+
return { ...resized, food };
188+
}
189+
const placed = placeFood(resized, random);
190+
return placed
191+
? { ...resized, food: placed }
192+
: { ...resized, food: null, status: "over" };
193+
}
194+
150195
function placeFood(state: SnakeState, random: RandomSource): SnakePoint | null {
151196
const occupied = new Set(state.body.map((part) => pointKey(part)));
152197
const free = state.width * state.height - occupied.size;

‎packages/cli/src/lib/init/ui/wizard-store.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,8 @@ export type WizardSnapshot = {
204204
prompt: ActivePrompt | null;
205205
/** Index of the currently-displayed Sentry tip in the sidebar. */
206206
tipIndex: number;
207+
/** Offer the waiting-screen Snake game. Off for agents and `SENTRY_INIT_GAME=0`. */
208+
snakeEnabled: boolean;
207209
/** Final structured summary, rendered after the workflow completes. */
208210
summary: WizardSummary | null;
209211
/**
@@ -281,6 +283,7 @@ function baseSnapshot(): WizardSnapshot {
281283
spinner: { active: false, frame: 0, message: "" },
282284
prompt: null,
283285
tipIndex: 0,
286+
snakeEnabled: false,
284287
summary: null,
285288
filesRead: [],
286289
steps: defaultChecklistSteps(),

‎packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,9 +1392,18 @@ describe("snake waiting game", () => {
13921392
return stripAnsi(out.latestFrame());
13931393
}
13941394

1395-
test("a new warning pauses the game and shows the activity log", async () => {
1395+
test("stays hidden when the game is disabled", async () => {
13961396
const store = new WizardStore();
1397-
store.setLayout("workflow");
1397+
store.startSpinner("Verifying setup...");
1398+
const out = await renderApp(store, 110, { rows: 32, input: ["g"] });
1399+
const frame = stripAnsi(out.latestFrame());
1400+
expect(frame).toContain("Verifying setup...");
1401+
expect(frame).not.toContain("Snake");
1402+
expect(frame).not.toContain("Bugs squashed");
1403+
});
1404+
1405+
test("a new warning pauses the game and shows the activity log", async () => {
1406+
const store = new WizardStore({ snakeEnabled: true });
13981407
store.startSpinner("Verifying setup...");
13991408
const out = new CaptureStream(110, 32);
14001409
const stdin = makeStdin();
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, test } from "vitest";
2+
import { withEnv } from "../../../../src/lib/env.js";
3+
import { isSnakeEnabled } from "../../../../src/lib/init/ui/ink-ui.js";
4+
5+
describe("isSnakeEnabled", () => {
6+
test("offers the game to a person at a terminal", () => {
7+
expect(withEnv({}, isSnakeEnabled)).toBe(true);
8+
});
9+
10+
test("SENTRY_INIT_GAME=0 turns the game off", () => {
11+
expect(withEnv({ SENTRY_INIT_GAME: "0" }, isSnakeEnabled)).toBe(false);
12+
});
13+
14+
test("hides the game when an AI agent runs the CLI", () => {
15+
expect(withEnv({ AI_AGENT: "claude-code" }, isSnakeEnabled)).toBe(false);
16+
});
17+
});

‎packages/cli/test/lib/init/ui/snake-game.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createSnake,
55
pauseSnake,
66
renderSnake,
7+
resizeSnake,
78
type SnakeState,
89
snakeTerminalRows,
910
stepSnake,
@@ -117,3 +118,51 @@ describe("renderSnake", () => {
117118
expect(stripAnsi(renderSnake(state)[0] ?? "")).toBe("▀ ▄");
118119
});
119120
});
121+
122+
describe("resizeSnake", () => {
123+
const snake = playing({
124+
body: [
125+
{ x: 8, y: 4 },
126+
{ x: 7, y: 4 },
127+
{ x: 6, y: 4 },
128+
],
129+
food: { x: 9, y: 5 },
130+
score: 4,
131+
});
132+
133+
test("keeps the run on a larger board and pauses it", () => {
134+
const resized = resizeSnake(snake, 20, 10, firstFreeCell);
135+
expect(resized).toMatchObject({
136+
width: 20,
137+
height: 10,
138+
body: snake.body,
139+
food: snake.food,
140+
score: 4,
141+
status: "paused",
142+
});
143+
});
144+
145+
test("shifts the snake inside a smaller board", () => {
146+
const resized = resizeSnake(snake, 6, 4, firstFreeCell);
147+
expect(resized.body).toEqual([
148+
{ x: 5, y: 3 },
149+
{ x: 4, y: 3 },
150+
{ x: 3, y: 3 },
151+
]);
152+
expect(resized.score).toBe(4);
153+
expect(resized.food).not.toBeNull();
154+
const food = resized.food as { x: number; y: number };
155+
expect(food.x).toBeLessThan(6);
156+
expect(food.y).toBeLessThan(4);
157+
});
158+
159+
test("starts over when the snake no longer fits", () => {
160+
const resized = resizeSnake(snake, 2, 4, firstFreeCell);
161+
expect(resized.score).toBe(0);
162+
expect(resized.status).toBe("ready");
163+
});
164+
165+
test("returns the same state when the size does not change", () => {
166+
expect(resizeSnake(snake, snake.width, snake.height)).toBe(snake);
167+
});
168+
});

0 commit comments

Comments
 (0)