Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/script/prototype-init-snake.ts
Original file line number Diff line number Diff line change
@@ -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]();
6 changes: 6 additions & 0 deletions packages/cli/src/lib/env-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
99 changes: 89 additions & 10 deletions packages/cli/src/lib/init/ui/ink-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
ShortcutHintProvider,
useInkShortcuts,
} from "./ink-shortcuts.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 {
Expand Down Expand Up @@ -96,6 +97,10 @@ 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;

const ACTIVE_TASK_PULSE_INTERVAL_MS = 600;

const ICON_BY_SEVERITY: Record<LogSeverity, { glyph: string; color?: string }> =
Expand Down Expand Up @@ -196,11 +201,40 @@ 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<number | null>(
null,
);

const width = getInkFrameWidth(columns);
const contentHeight = Math.max(5, rows - 4);
const isWide = width >= 80;

const canPlaySnake =
snapshot.snakeEnabled &&
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]);

const tabs = useMemo<FrameTab[]>(
() => [
{ id: "status", label: "Status" },
Expand Down Expand Up @@ -234,13 +268,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") {
Expand Down Expand Up @@ -308,14 +352,39 @@ function AppBody({ store }: AppProps): React.ReactNode {
overflow="hidden"
>
<Box flexDirection="column" flexGrow={1} overflow="hidden">
{activeTab === 0 ? (
<ActivityPane
logs={snapshot.logs}
prompt={snapshot.prompt}
spinner={snapshot.spinner}
summary={snapshot.summary}
terminalRows={rows}
/>
{showSnake ? (
<Box flexDirection="column" flexGrow={1}>
{snapshot.spinner.active ? (
<SpinnerRow state={snapshot.spinner} />
) : (
// Holds the spinner rows so the board keeps its size between steps.
<Box height={2} />
)}
<SnakeGame
accent={ACCENT}
muted={MUTED_DIM}
onCancel={cancelFromSnake}
onExit={closeSnake}
session={snakeSession}
/>
</Box>
) : activeTab === 0 ? (
<>
<ActivityPane
logs={snapshot.logs}
prompt={snapshot.prompt}
spinner={snapshot.spinner}
summary={snapshot.summary}
terminalRows={rows}
/>
{canPlaySnake ? (
<SnakeInvite
accent={ACCENT}
muted={MUTED_DIM}
session={snakeSession}
/>
) : null}
</>
) : (
<FilesScreen
filesRead={snapshot.filesRead}
Expand Down Expand Up @@ -357,6 +426,16 @@ function AppBody({ store }: AppProps): React.ReactNode {
);
}

function latestAlertLogId(logs: LogEntry[]): number {
for (let index = logs.length - 1; index >= 0; index--) {
const entry = logs[index];
if (entry?.severity === "warn" || entry?.severity === "error") {
return entry.id;
}
}
return 0;
}

// ────────────────────────────── Sidebar ───────────────────────────────

function Sidebar({
Expand Down
Loading
Loading