Skip to content
Open
Show file tree
Hide file tree
Changes from all 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