diff --git a/docs/arcade.md b/docs/arcade.md new file mode 100644 index 0000000..2ca7ec6 --- /dev/null +++ b/docs/arcade.md @@ -0,0 +1,45 @@ +# Waiting Arcade + +Waiting Arcade lets you play small terminal games while Logicsrc runs long tasks. + +```bash +logicsrc arcade list +logicsrc arcade play hangman +logicsrc --yolo --arcade=hangman +logicsrc agentswarm --yolo --arcade=random +logicsrc agentswarm --yolo --no-arcade +``` + +Built-in games: + +- `hangman` +- `snake` +- `moon-runner` +- `minefield` +- `agent-swarm` + +Use `random` to pick a built-in game. + +## Task Mode + +When arcade mode is launched from `--yolo` or `agentswarm --yolo`, the task continues in the background. The game shows a compact task overlay with status, phase, progress, last message, and cost when available. + +The arcade pauses immediately when the task completes, fails, needs approval, or needs expense approval. Expense approval requires an explicit modal action; the game never silently approves cost. + +## Shortcuts + +| Key | Action | +| --- | --- | +| `L` / `Ctrl+G` | Toggle task logs | +| `Esc` | Pause game | +| `Q` | Quit arcade | +| `Ctrl+C` | Open stop prompt in interactive mode | +| `Arrow` / `WASD` | Move in supported games | + +## Non-Interactive Terminals + +In CI or redirected output, Waiting Arcade does not enter raw interactive mode. It prints a static arcade snapshot and returns without changing terminal state. + +## Safety + +The runtime restores raw mode, cursor visibility, and the alternate screen on normal exit or `Ctrl+C`. Quitting the arcade does not imply task approval. diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..2062c29 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,51 @@ +# Config + +Logicsrc stores user config at: + +```text +$HOME/.logicsrc/config.json +``` + +Read and write values with dot paths: + +```bash +logicsrc config get waiting.arcade.enabled +logicsrc config set waiting.arcade.enabled true +logicsrc config set waiting.arcade.defaultGame hangman +logicsrc config set waiting.arcade.autoStartAfterSeconds 15 +``` + +Default Waiting Arcade config: + +```json +{ + "waiting": { + "arcade": { + "enabled": true, + "defaultGame": "hangman", + "random": false, + "autoStartAfterSeconds": 0, + "interruptOnApproval": true, + "interruptOnDone": true, + "interruptOnError": true, + "showTaskStatusOverlay": true + } + } +} +``` + +Precedence for arcade launch behavior is: + +1. CLI flags +2. Environment variables +3. User config +4. Defaults + +Environment variables: + +```bash +LOGICSRC_ARCADE=1 +LOGICSRC_ARCADE_GAME=hangman +LOGICSRC_NO_ARCADE=1 +LOGICSRC_ARCADE_AUTOSTART_SECONDS=15 +``` diff --git a/docs/tui.md b/docs/tui.md new file mode 100644 index 0000000..90e600c --- /dev/null +++ b/docs/tui.md @@ -0,0 +1,13 @@ +# TUI + +The Logicsrc TUI package contains terminal rendering helpers for the static dashboard and Waiting Arcade. + +```bash +logicsrc tui +logicsrc arcade +logicsrc arcade play snake +``` + +Waiting Arcade is dependency-free and runs inside the terminal. It uses keyboard input only and falls back to a static snapshot when stdout or stdin is not a TTY. + +Task integrations should emit progress, log, approval, expense, completion, and error events into the arcade runtime so attention events can pause the game and show an actionable modal. diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts new file mode 100644 index 0000000..2b023cb --- /dev/null +++ b/packages/cli/src/config.ts @@ -0,0 +1,87 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; + +export type JsonObject = Record; + +export const defaultConfig: JsonObject = { + waiting: { + arcade: { + enabled: true, + defaultGame: "hangman", + random: false, + autoStartAfterSeconds: 0, + interruptOnApproval: true, + interruptOnDone: true, + interruptOnError: true, + showTaskStatusOverlay: true + } + } +}; + +export function configPath() { + return join(homedir(), ".logicsrc", "config.json"); +} + +export function readConfig() { + const file = configPath(); + if (!existsSync(file)) { + return structuredClone(defaultConfig); + } + const parsed = JSON.parse(readFileSync(file, "utf8")) as JsonObject; + return mergeConfig(structuredClone(defaultConfig), parsed); +} + +export function writeConfig(config: JsonObject) { + const file = configPath(); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(config, null, 2) + "\n"); + return file; +} + +export function getConfigValue(path: string, config = readConfig()) { + return path.split(".").reduce((current, key) => (isObject(current) ? current[key] : undefined), config); +} + +export function setConfigValue(path: string, rawValue: string, config = readConfig()) { + const parts = path.split(".").filter(Boolean); + if (parts.length === 0) { + throw new Error("Config path cannot be empty."); + } + let current: JsonObject = config; + for (const part of parts.slice(0, -1)) { + if (!isObject(current[part])) { + current[part] = {}; + } + current = current[part] as JsonObject; + } + current[parts[parts.length - 1] as string] = parseConfigValue(rawValue); + return config; +} + +export function parseConfigValue(value: string): unknown { + if (value === "true") return true; + if (value === "false") return false; + if (value === "null") return null; + if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value); + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function mergeConfig(base: JsonObject, override: JsonObject): JsonObject { + for (const [key, value] of Object.entries(override)) { + if (isObject(value) && isObject(base[key])) { + base[key] = mergeConfig(base[key] as JsonObject, value); + } else { + base[key] = value; + } + } + return base; +} + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 6f365d5..45bd4c8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,22 +1,61 @@ #!/usr/bin/env node import { readFileSync } from "node:fs"; import { Command } from "commander"; -import { renderPluginStatus, renderTui } from "@logicsrc/tui"; +import { renderArcadeList, renderPluginStatus, renderTui, runArcadeSession, type TaskSnapshot } from "@logicsrc/tui"; import { assertSchemaKind, parseDocument, validate } from "@logicsrc/validators"; +import { getConfigValue, readConfig, setConfigValue, writeConfig } from "./config.js"; import { boards, tasks } from "./fixtures.js"; import { print, type OutputFormat } from "./format.js"; import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js"; import { defaultPluginRegistry } from "./registry.js"; +process.stdout.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") { + process.exit(0); + } + throw error; +}); + const program = new Command(); +program.enablePositionalOptions(); program .name("logicsrc") .description("LogicSRC OpenSpec CLI for schemas, boards, tasks, agents, payments, plugins, and TUI.") .option("--openspec", "Enable OpenSpec.dev-compatible repo-local specs, proposals, tasks, and deltas where supported.") .option("--openspec-only", "Restrict workflows to LogicSRC OpenSpec schemas, SDKs, MCP, CLI, TUI, and PWA contracts.") + .option("--yolo", "Start the default AgentSwarm YOLO flow.") + .option("--arcade [game]", "Launch Waiting Arcade while a long-running task executes.") + .option("--waiting-arcade", "Alias for --arcade.") + .option("--waiting-game ", "Alias for --arcade=.") + .option("--no-arcade", "Disable Waiting Arcade.") .version("0.1.0"); +program.action(async (options) => { + if (!options.yolo) { + program.outputHelp(); + return; + } + + const arcadeGame = resolveArcadeGame(options); + if (arcadeGame) { + await runYoloArcade(arcadeGame); + return; + } + + print( + { + type: "logicsrc.agentswarm.session", + status: "opening", + mode: "yolo", + master_agent: "agentswarm-master", + slave_agents: ["reproduce", "patch", "review"], + arcade: false + }, + "json" + ); +}); + program .command("login") .option("--did ", "CoinPay DID") @@ -124,16 +163,59 @@ program.command("events").description("Listen to LogicSRC event stream.").argume console.log(JSON.stringify({ type: "logicsrc.event", event: "task.created", resource_id: "task_789" })); }); +const arcade = program.command("arcade").description("Play Waiting Arcade games."); + +arcade.action(async () => { + await runArcadeSession({ game: process.env.LOGICSRC_ARCADE_GAME || "hangman", standalone: true }); +}); + +arcade.command("list").description("List built-in Waiting Arcade games.").action(() => { + console.log(renderArcadeList()); +}); + +arcade + .command("play") + .argument("[game]", "Game id or random", "hangman") + .description("Play a standalone Waiting Arcade game.") + .action(async (game) => { + await runArcadeSession({ game, standalone: true }); + }); + +const config = program.command("config").description("Read and write LogicSRC config."); + +config + .command("get") + .argument("", "Dot-path config key") + .description("Print a config value.") + .action((path) => { + console.log(JSON.stringify(getConfigValue(path), null, 2)); + }); + +config + .command("set") + .argument("", "Dot-path config key") + .argument("", "JSON, boolean, number, or string value") + .description("Set a config value.") + .action((path, value) => { + const next = setConfigValue(path, value); + const file = writeConfig(next); + console.log(`Set ${path} in ${file}`); + }); + program .command("agentswarm") .alias("agent-swarm") .description("Open an AgentSwarm master agent session.") .option("--yolo", "Start the master agent with autonomous execution enabled.") + .option("--arcade [game]", "Launch Waiting Arcade while AgentSwarm runs.") + .option("--waiting-arcade", "Alias for --arcade.") + .option("--waiting-game ", "Alias for --arcade=.") + .option("--no-arcade", "Disable Waiting Arcade.") .option("--repo ", "Target repository, for example profullstack/logicsrc") .option("--agents ", "Comma-separated slave agent roles", "reproduce,patch,review") .option("--change ", "OpenSpec-compatible change id", "agentswarm-yolo") .option("--out ", "OpenSpec-compatible output directory") - .action((options) => { + .action(async (options) => { if (!options.yolo) { console.log("AgentSwarm is coming soon. Run `logicsrc agentswarm --yolo` to open the master agent flow."); return; @@ -157,6 +239,12 @@ program }) : null; + const arcadeGame = resolveArcadeGame(options); + if (arcadeGame) { + await runYoloArcade(arcadeGame, options.repo); + return; + } + print({ type: "logicsrc.agentswarm.session", status: "opening", @@ -284,6 +372,49 @@ function toTaskSchema(item: (typeof tasks)[number]) { }; } +function resolveArcadeGame(options: { arcade?: string | boolean; waitingArcade?: boolean; waitingGame?: string }) { + if (options.arcade === false || process.env.LOGICSRC_NO_ARCADE === "1") { + return undefined; + } + + if (options.waitingGame) { + return options.waitingGame; + } + + if (typeof options.arcade === "string") { + return options.arcade; + } + + const config = readConfig(); + const configuredDefault = String(getConfigValue("waiting.arcade.defaultGame", config) ?? "hangman"); + + if (options.arcade === true || options.waitingArcade || process.env.LOGICSRC_ARCADE === "1") { + return process.env.LOGICSRC_ARCADE_GAME || configuredDefault; + } + + return undefined; +} + +async function runYoloArcade(game: string, repo?: string) { + const task: TaskSnapshot = { + id: "agentswarm_yolo", + title: repo ? `AgentSwarm YOLO on ${repo}` : "AgentSwarm YOLO session", + status: "running", + phase: "starting", + progress: 0.05, + costUsd: 0, + lastMessage: "launching Waiting Arcade" + }; + + await runArcadeSession({ + game, + standalone: false, + task, + simulateTask: true, + logs: ["AgentSwarm master session started.", "Task continues while Waiting Arcade is active."] + }); +} + program.parseAsync(process.argv).catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; diff --git a/packages/tui/src/arcade/games/agent-swarm.ts b/packages/tui/src/arcade/games/agent-swarm.ts new file mode 100644 index 0000000..513810e --- /dev/null +++ b/packages/tui/src/arcade/games/agent-swarm.ts @@ -0,0 +1,49 @@ +import type { GameAction, KeyEvent, TerminalFrame, WaitingGame } from "../types.js"; + +export class AgentSwarmGame implements WaitingGame { + readonly id = "agent-swarm"; + readonly title = "Agent Swarm"; + readonly description = "Route agents around blockers and collect approvals."; + readonly controls = [ + { key: "Arrow/WASD", action: "move lead agent" }, + { key: "L/Ctrl+G", action: "logs" }, + { key: "Esc", action: "pause" }, + { key: "Q", action: "quit" } + ]; + + private x = 18; + private y = 8; + private tickCount = 0; + + init() { + return; + } + + tick() { + this.tickCount += 1; + } + + input(event: KeyEvent): GameAction | void { + if (event.ctrl && event.name === "g") return { type: "toggle_logs" }; + if (event.name === "escape") return { type: "pause" }; + if (event.name === "q") return { type: "quit" }; + if (event.name === "l") return { type: "toggle_logs" }; + if (["up", "w", "k"].includes(event.name)) this.y = Math.max(2, this.y - 1); + if (["down", "s", "j"].includes(event.name)) this.y = Math.min(13, this.y + 1); + if (["left", "a", "h"].includes(event.name)) this.x = Math.max(4, this.x - 1); + if (["right", "d"].includes(event.name)) this.x = Math.min(50, this.x + 1); + } + + render(frame: TerminalFrame) { + frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase()); + frame.write(2, 4, "Guide the lead agent. Avoid blockers. Grab approvals."); + frame.write(6, 12, "B"); + frame.write(10, 28, "B"); + frame.write(5, 42, "$"); + frame.write(12, 48, "A"); + frame.write(this.y, this.x, "@"); + frame.write(this.y + 1, Math.max(4, this.x - 4), "a a a"); + frame.write(15, 4, `Swarm pulse: ${this.tickCount % 100}`); + frame.write(16, 4, "[Arrows/WASD] Move [L/Ctrl+G] Logs [Esc] Pause [Q] Quit"); + } +} diff --git a/packages/tui/src/arcade/games/hangman.ts b/packages/tui/src/arcade/games/hangman.ts new file mode 100644 index 0000000..1e66c51 --- /dev/null +++ b/packages/tui/src/arcade/games/hangman.ts @@ -0,0 +1,77 @@ +import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js"; +import { fit } from "../renderer.js"; + +const WORDS = ["LOGICSRC", "COMMAND BOARD", "AGENT SWARM", "PULL REQUEST", "TERMINAL", "SUPABASE", "COINPAY", "CRAWLPROOF", "UGIG", "SHIPT"]; +const BAD_IDEAS = ["JIRA", "WATERFALL", "WEBPACK", "XML CONFIG", "VENDOR LOCK-IN", "MEETING", "BIG REWRITE", "GLOBAL STATE"]; + +export class HangmanGame implements WaitingGame { + readonly id = "hangman"; + readonly title = "Logicsrc Hangman"; + readonly description = "Guess terminal, agent, and Logicsrc words while a task runs."; + readonly controls = [ + { key: "A-Z", action: "guess" }, + { key: "L/Ctrl+G", action: "logs" }, + { key: "Esc", action: "pause" }, + { key: "Q", action: "quit" } + ]; + + private word = WORDS[0] ?? "LOGICSRC"; + private guesses = new Set(); + private mistakes = 0; + + init(ctx: GameContext) { + this.word = WORDS[Math.floor(ctx.random() * WORDS.length)] ?? "LOGICSRC"; + } + + tick() { + return; + } + + input(event: KeyEvent): GameAction | void { + if (event.ctrl && event.name === "g") { + return { type: "toggle_logs" }; + } + if (event.name === "escape") { + return { type: "pause" }; + } + if (event.name === "q") { + return { type: "quit" }; + } + if (event.name === "l") { + return { type: "toggle_logs" }; + } + if (/^[a-z]$/i.test(event.name)) { + const guess = event.name.toUpperCase(); + if (!this.guesses.has(guess)) { + this.guesses.add(guess); + if (!this.word.includes(guess)) { + this.mistakes += 1; + } + } + } + } + + render(frame: TerminalFrame) { + frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase()); + frame.write(2, 4, "Phrase:"); + frame.write(4, 4, this.maskedWord()); + frame.write(6, 4, `Wrong guesses: ${this.mistakes}/6`); + frame.write(7, 4, BAD_IDEAS.slice(0, this.mistakes).join(" ") || "none yet"); + frame.write(9, 4, fit(`Guessed: ${Array.from(this.guesses).sort().join(" ") || "none"}`, frame.width - 8)); + + const state = this.isWon() ? "solved" : this.mistakes >= 6 ? "reset with R or keep guessing" : "running"; + frame.write(11, 4, `Round: ${state}`); + frame.write(13, 4, "[A-Z] Guess [L/Ctrl+G] Logs [Esc] Pause [Q] Quit"); + } + + private maskedWord() { + return this.word + .split("") + .map((char) => (char === " " ? " " : this.guesses.has(char) ? `${char} ` : "_ ")) + .join(""); + } + + private isWon() { + return this.word.split("").every((char) => char === " " || this.guesses.has(char)); + } +} diff --git a/packages/tui/src/arcade/games/minefield.ts b/packages/tui/src/arcade/games/minefield.ts new file mode 100644 index 0000000..63826a7 --- /dev/null +++ b/packages/tui/src/arcade/games/minefield.ts @@ -0,0 +1,83 @@ +import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js"; + +type Cell = { mine: boolean; open: boolean; flag: boolean }; + +export class MinefieldGame implements WaitingGame { + readonly id = "minefield"; + readonly title = "Deploy Minefield"; + readonly description = "Clear risky deployment cells without triggering incidents."; + readonly controls = [ + { key: "Arrow/WASD", action: "move" }, + { key: "Space/Enter", action: "reveal" }, + { key: "F", action: "flag" }, + { key: "Q", action: "quit" } + ]; + + private width = 8; + private height = 6; + private cells: Cell[] = []; + private cursor = { x: 0, y: 0 }; + private state = "clear risky cells"; + + init(ctx: GameContext) { + this.cells = Array.from({ length: this.width * this.height }, (_, index) => ({ + mine: index > 0 && ctx.random() < 0.16, + open: false, + flag: false + })); + } + + tick() { + return; + } + + input(event: KeyEvent): GameAction | void { + if (event.ctrl && event.name === "g") return { type: "toggle_logs" }; + if (event.name === "escape") return { type: "pause" }; + if (event.name === "q") return { type: "quit" }; + if (event.name === "l") return { type: "toggle_logs" }; + if (["up", "w", "k"].includes(event.name)) this.cursor.y = Math.max(0, this.cursor.y - 1); + if (["down", "s", "j"].includes(event.name)) this.cursor.y = Math.min(this.height - 1, this.cursor.y + 1); + if (["left", "a", "h"].includes(event.name)) this.cursor.x = Math.max(0, this.cursor.x - 1); + if (["right", "d"].includes(event.name)) this.cursor.x = Math.min(this.width - 1, this.cursor.x + 1); + if (event.name === "f") this.cell(this.cursor.x, this.cursor.y).flag = !this.cell(this.cursor.x, this.cursor.y).flag; + if (event.name === "space" || event.name === "return") this.reveal(this.cursor.x, this.cursor.y); + } + + render(frame: TerminalFrame) { + frame.box(0, 0, frame.width, frame.height - 5, this.title.toUpperCase()); + frame.write(2, 4, `Status: ${this.state}`); + for (let y = 0; y < this.height; y += 1) { + for (let x = 0; x < this.width; x += 1) { + const cell = this.cell(x, y); + const selected = this.cursor.x === x && this.cursor.y === y; + const value = cell.flag ? "F" : cell.open ? (cell.mine ? "!" : String(this.neighbors(x, y))) : "#"; + frame.write(4 + y, 6 + x * 4, selected ? `[${value}]` : ` ${value} `); + } + } + frame.write(12, 4, "[Arrows/WASD] Move [Space/Enter] Reveal [F] Flag [Q] Quit"); + } + + private reveal(x: number, y: number) { + const cell = this.cell(x, y); + if (cell.flag) return; + cell.open = true; + this.state = cell.mine ? "incident triggered; keep mapping the blast radius" : "cell cleared"; + } + + private cell(x: number, y: number) { + return this.cells[y * this.width + x] ?? { mine: false, open: false, flag: false }; + } + + private neighbors(x: number, y: number) { + let count = 0; + for (let dy = -1; dy <= 1; dy += 1) { + for (let dx = -1; dx <= 1; dx += 1) { + if (dx !== 0 || dy !== 0) { + count += this.cell(x + dx, y + dy).mine ? 1 : 0; + } + } + } + return count; + } +} diff --git a/packages/tui/src/arcade/games/moon-runner.ts b/packages/tui/src/arcade/games/moon-runner.ts new file mode 100644 index 0000000..840fa75 --- /dev/null +++ b/packages/tui/src/arcade/games/moon-runner.ts @@ -0,0 +1,62 @@ +import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js"; + +export class MoonRunnerGame implements WaitingGame { + readonly id = "moon-runner"; + readonly title = "Moon Runner"; + readonly description = "Jump over failed builds and collect green checks."; + readonly controls = [ + { key: "Space/Up", action: "jump" }, + { key: "L/Ctrl+G", action: "logs" }, + { key: "Esc", action: "pause" }, + { key: "Q", action: "quit" } + ]; + + private runnerY = 0; + private velocity = 0; + private obstacleX = 46; + private score = 0; + private random = Math.random; + + init(ctx: GameContext) { + this.random = ctx.random; + } + + tick(deltaMs: number) { + const step = Math.max(1, Math.round(deltaMs / 60)); + this.obstacleX -= step; + if (this.obstacleX < 4) { + this.obstacleX = 42 + Math.floor(this.random() * 24); + this.score += 1; + } + this.velocity -= 0.08 * step; + this.runnerY = Math.max(0, this.runnerY + this.velocity); + if (this.runnerY === 0 && this.velocity < 0) { + this.velocity = 0; + } + if (this.obstacleX >= 8 && this.obstacleX <= 10 && this.runnerY < 1) { + this.score = 0; + this.obstacleX = 46; + } + } + + input(event: KeyEvent): GameAction | void { + if (event.ctrl && event.name === "g") return { type: "toggle_logs" }; + if (event.name === "escape") return { type: "pause" }; + if (event.name === "q") return { type: "quit" }; + if (event.name === "l") return { type: "toggle_logs" }; + if (["space", "up", "w"].includes(event.name) && this.runnerY === 0) { + this.velocity = 1.15; + } + } + + render(frame: TerminalFrame) { + const ground = Math.max(7, frame.height - 9); + frame.box(0, 0, frame.width, frame.height - 5, `${this.title.toUpperCase()} CHECKS ${this.score}`); + frame.write(ground + 1, 4, "_".repeat(Math.max(10, frame.width - 8))); + frame.write(ground - Math.round(this.runnerY), 9, "@"); + frame.write(ground, this.obstacleX, "X"); + frame.write(ground - 2, Math.max(18, this.obstacleX + 10), "+"); + frame.write(2, 4, "Low gravity. High uptime."); + frame.write(ground + 3, 4, "[Space/Up] Jump [L/Ctrl+G] Logs [Esc] Pause [Q] Quit"); + } +} diff --git a/packages/tui/src/arcade/games/snake.ts b/packages/tui/src/arcade/games/snake.ts new file mode 100644 index 0000000..6ea674d --- /dev/null +++ b/packages/tui/src/arcade/games/snake.ts @@ -0,0 +1,78 @@ +import type { GameAction, GameContext, KeyEvent, TerminalFrame, WaitingGame } from "../types.js"; + +type Point = { x: number; y: number }; + +export class SnakeGame implements WaitingGame { + readonly id = "snake"; + readonly title = "Token Snake"; + readonly description = "Eat task tokens, grow the trail, and avoid blockers."; + readonly controls = [ + { key: "Arrow/WASD", action: "move" }, + { key: "L/Ctrl+G", action: "logs" }, + { key: "Esc", action: "pause" }, + { key: "Q", action: "quit" } + ]; + + private snake: Point[] = [{ x: 12, y: 6 }]; + private direction: Point = { x: 1, y: 0 }; + private token: Point = { x: 20, y: 6 }; + private elapsed = 0; + private score = 0; + private random = Math.random; + + init(ctx: GameContext) { + this.random = ctx.random; + } + + tick(deltaMs: number) { + this.elapsed += deltaMs; + if (this.elapsed < 150) { + return; + } + this.elapsed = 0; + const head = this.snake[0] ?? { x: 12, y: 6 }; + const next = { x: head.x + this.direction.x, y: head.y + this.direction.y }; + const maxX = 38; + const maxY = 12; + + if (next.x < 1 || next.x > maxX || next.y < 1 || next.y > maxY || this.snake.some((point) => point.x === next.x && point.y === next.y)) { + this.snake = [{ x: 12, y: 6 }]; + this.direction = { x: 1, y: 0 }; + this.score = 0; + return; + } + + this.snake.unshift(next); + if (next.x === this.token.x && next.y === this.token.y) { + this.score += 1; + this.token = { x: 1 + Math.floor(this.random() * maxX), y: 1 + Math.floor(this.random() * maxY) }; + } else { + this.snake.pop(); + } + } + + input(event: KeyEvent): GameAction | void { + if (event.ctrl && event.name === "g") return { type: "toggle_logs" }; + if (event.name === "escape") return { type: "pause" }; + if (event.name === "q") return { type: "quit" }; + if (event.name === "l") return { type: "toggle_logs" }; + if (["up", "w", "k"].includes(event.name) && this.direction.y !== 1) this.direction = { x: 0, y: -1 }; + if (["down", "s", "j"].includes(event.name) && this.direction.y !== -1) this.direction = { x: 0, y: 1 }; + if (["left", "a", "h"].includes(event.name) && this.direction.x !== 1) this.direction = { x: -1, y: 0 }; + if (["right", "d", "l"].includes(event.name) && this.direction.x !== -1) this.direction = { x: 1, y: 0 }; + } + + render(frame: TerminalFrame) { + frame.box(0, 0, frame.width, frame.height - 5, `${this.title.toUpperCase()} SCORE ${this.score}`); + for (let y = 1; y <= 12; y += 1) { + for (let x = 1; x <= 38; x += 1) { + frame.write(y + 2, x + 4, "."); + } + } + frame.write(this.token.y + 2, this.token.x + 4, "$"); + for (const [index, point] of this.snake.entries()) { + frame.write(point.y + 2, point.x + 4, index === 0 ? "@" : "o"); + } + frame.write(16, 4, "[Arrows/WASD] Move [L/Ctrl+G] Logs [Esc] Pause [Q] Quit"); + } +} diff --git a/packages/tui/src/arcade/index.ts b/packages/tui/src/arcade/index.ts new file mode 100644 index 0000000..a311417 --- /dev/null +++ b/packages/tui/src/arcade/index.ts @@ -0,0 +1,3 @@ +export { createDefaultArcadeRegistry, renderArcadeList, ArcadeRegistry } from "./registry.js"; +export { renderArcadeSnapshot, runArcadeSession } from "./runtime.js"; +export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./types.js"; diff --git a/packages/tui/src/arcade/registry.ts b/packages/tui/src/arcade/registry.ts new file mode 100644 index 0000000..d178d41 --- /dev/null +++ b/packages/tui/src/arcade/registry.ts @@ -0,0 +1,75 @@ +import type { WaitingGame } from "./types.js"; +import { AgentSwarmGame } from "./games/agent-swarm.js"; +import { HangmanGame } from "./games/hangman.js"; +import { MinefieldGame } from "./games/minefield.js"; +import { MoonRunnerGame } from "./games/moon-runner.js"; +import { SnakeGame } from "./games/snake.js"; + +export type GameFactory = () => WaitingGame; + +export class ArcadeRegistry { + private readonly games = new Map(); + + add(factory: GameFactory) { + const game = factory(); + if (this.games.has(game.id)) { + throw new Error(`Arcade game already registered: ${game.id}`); + } + this.games.set(game.id, factory); + } + + create(id: string) { + const factory = this.games.get(id); + if (!factory) { + throw new Error(`Unknown arcade game "${id}". Run "logicsrc arcade list" to see available games.`); + } + return factory(); + } + + list() { + return Array.from(this.games.values()).map((factory) => { + const game = factory(); + return { + id: game.id, + title: game.title, + description: game.description, + controls: game.controls + }; + }); + } + + choose(id: string | undefined, random = Math.random) { + if (!id || id === "default") { + return "hangman"; + } + if (id !== "random") { + return id; + } + const games = this.list(); + return games[Math.floor(random() * games.length)]?.id ?? "hangman"; + } +} + +export function createDefaultArcadeRegistry() { + const registry = new ArcadeRegistry(); + registry.add(() => new HangmanGame()); + registry.add(() => new SnakeGame()); + registry.add(() => new MoonRunnerGame()); + registry.add(() => new MinefieldGame()); + registry.add(() => new AgentSwarmGame()); + return registry; +} + +export function renderArcadeList(registry = createDefaultArcadeRegistry()) { + const rows = registry.list(); + const idWidth = Math.max(...rows.map((row) => row.id.length), 2); + const titleWidth = Math.max(...rows.map((row) => row.title.length), 5); + + return [ + "Waiting Arcade games", + "", + `${"id".padEnd(idWidth)} ${"title".padEnd(titleWidth)} description`, + `${"-".repeat(idWidth)} ${"-".repeat(titleWidth)} ${"-".repeat(40)}`, + ...rows.map((row) => `${row.id.padEnd(idWidth)} ${row.title.padEnd(titleWidth)} ${row.description}`) + ].join("\n"); +} diff --git a/packages/tui/src/arcade/renderer.ts b/packages/tui/src/arcade/renderer.ts new file mode 100644 index 0000000..947af2d --- /dev/null +++ b/packages/tui/src/arcade/renderer.ts @@ -0,0 +1,98 @@ +import type { TaskSnapshot, TerminalFrame } from "./types.js"; + +export class TextFrame implements TerminalFrame { + readonly width: number; + readonly height: number; + private readonly cells: string[][]; + + constructor(width: number, height: number) { + this.width = Math.max(40, width); + this.height = Math.max(16, height); + this.cells = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => " ")); + } + + write(row: number, col: number, text: string) { + if (row < 0 || row >= this.height || col >= this.width) { + return; + } + + for (let index = 0; index < text.length && col + index < this.width; index += 1) { + if (col + index >= 0) { + this.cells[row][col + index] = text[index] ?? " "; + } + } + } + + box(row: number, col: number, width: number, height: number, title?: string) { + const right = Math.min(this.width - 1, col + width - 1); + const bottom = Math.min(this.height - 1, row + height - 1); + const left = Math.max(0, col); + const top = Math.max(0, row); + + for (let x = left; x <= right; x += 1) { + this.write(top, x, x === left || x === right ? "+" : "-"); + this.write(bottom, x, x === left || x === right ? "+" : "-"); + } + for (let y = top + 1; y < bottom; y += 1) { + this.write(y, left, "|"); + this.write(y, right, "|"); + } + if (title) { + this.write(top, left + 2, ` ${fit(title, Math.max(0, width - 6))} `); + } + } + + toString() { + return this.cells.map((line) => line.join("").trimEnd()).join("\n").trimEnd(); + } +} + +export function fit(value: string, width: number) { + if (width <= 0) { + return ""; + } + return value.length > width ? value.slice(0, Math.max(0, width - 1)) + "." : value.padEnd(width); +} + +export function center(value: string, width: number) { + if (value.length >= width) { + return value.slice(0, width); + } + const left = Math.floor((width - value.length) / 2); + return `${" ".repeat(left)}${value}`; +} + +export function renderTaskOverlay(frame: TerminalFrame, task: TaskSnapshot | undefined, standalone: boolean) { + const top = frame.height - 5; + frame.box(top, 0, frame.width, 5, standalone ? "STANDALONE MODE" : "TASK STATUS"); + + if (!task) { + frame.write(top + 1, 2, "No active task. Play mode only."); + frame.write(top + 2, 2, "[L/Ctrl+G] logs [Esc] pause [Q] quit"); + return; + } + + const cost = typeof task.costUsd === "number" ? ` Cost: $${task.costUsd.toFixed(2)}` : ""; + const progress = typeof task.progress === "number" ? ` ${Math.round(task.progress * 100)}%` : ""; + frame.write(top + 1, 2, fit(`Task: ${task.title}`, frame.width - 4)); + frame.write(top + 2, 2, fit(`Status: ${task.status}${progress} Phase: ${task.phase ?? "unknown"}${cost}`, frame.width - 4)); + frame.write(top + 3, 2, fit(`Last: ${task.lastMessage ?? "waiting for task events"} [L/Ctrl+G] logs [Esc] pause [Q] quit`, frame.width - 4)); +} + +export function renderModal(title: string, body: string[], actions: string[], width = 58) { + const inner = width - 4; + const lines = [ + `+${"=".repeat(width - 2)}+`, + `| ${fit(title, inner)} |`, + `|${" ".repeat(width - 2)}|` + ]; + + for (const line of body) { + lines.push(`| ${fit(line, inner)} |`); + } + + lines.push(`|${" ".repeat(width - 2)}|`); + lines.push(`| ${fit(actions.join(" "), inner)} |`); + lines.push(`+${"=".repeat(width - 2)}+`); + return lines.join("\n"); +} diff --git a/packages/tui/src/arcade/runtime.ts b/packages/tui/src/arcade/runtime.ts new file mode 100644 index 0000000..00ce21c --- /dev/null +++ b/packages/tui/src/arcade/runtime.ts @@ -0,0 +1,265 @@ +import { createDefaultArcadeRegistry, type ArcadeRegistry } from "./registry.js"; +import { fit, renderModal, renderTaskOverlay, TextFrame } from "./renderer.js"; +import type { KeyEvent, TaskEvent, TaskSnapshot, WaitingGame } from "./types.js"; + +export interface ArcadeSessionOptions { + game?: string | boolean; + standalone?: boolean; + task?: TaskSnapshot; + logs?: string[]; + registry?: ArcadeRegistry; + input?: NodeJS.ReadStream; + output?: NodeJS.WriteStream; + simulateTask?: boolean; +} + +type Modal = + | { kind: "pause"; title: string; body: string[]; actions: string[] } + | { kind: "complete"; title: string; body: string[]; actions: string[] } + | { kind: "approval"; title: string; body: string[]; actions: string[] } + | { kind: "error"; title: string; body: string[]; actions: string[] }; + +export function renderArcadeSnapshot(options: ArcadeSessionOptions = {}) { + const registry = options.registry ?? createDefaultArcadeRegistry(); + const gameId = registry.choose(typeof options.game === "string" ? options.game : undefined); + const game = registry.create(gameId); + const task = options.task; + const frame = new TextFrame(80, 28); + game.init({ width: frame.width, height: frame.height, random: Math.random, task, emit: () => undefined }); + game.render(frame); + renderTaskOverlay(frame, task, options.standalone ?? true); + return frame.toString(); +} + +export async function runArcadeSession(options: ArcadeSessionOptions = {}) { + const input = options.input ?? process.stdin; + const output = options.output ?? process.stdout; + const registry = options.registry ?? createDefaultArcadeRegistry(); + const gameId = registry.choose(typeof options.game === "string" ? options.game : undefined); + const game = registry.create(gameId); + let task = options.task; + const logs = [...(options.logs ?? [])]; + + if (!input.isTTY || !output.isTTY) { + output.write(renderArcadeSnapshot({ ...options, game: gameId, task, logs }) + "\n"); + if (!options.standalone) { + output.write("\nWaiting Arcade interactive mode is disabled outside an interactive terminal.\n"); + } + return; + } + + await game.init({ width: output.columns ?? 80, height: output.rows ?? 28, random: Math.random, task, emit: () => undefined }); + + let modal: Modal | undefined; + let showLogs = false; + let closed = false; + let lastFrameAt = Date.now(); + let cleanupComplete = false; + const previousRawMode = input.isRaw; + + const cleanup = () => { + if (cleanupComplete) { + return; + } + cleanupComplete = true; + clearInterval(loop); + for (const timer of timers) { + clearTimeout(timer); + } + input.off("data", onData); + process.off("SIGINT", onSigint); + if (input.isTTY) { + input.setRawMode(previousRawMode ?? false); + } + input.pause(); + output.write("\x1b[?25h\x1b[?1049l"); + }; + + const onSigint = () => { + closed = true; + cleanup(); + }; + + const onData = (data: Buffer) => { + const event = parseKey(data); + if (event.ctrl && event.name === "c") { + modal = { kind: "pause", title: "STOP TASK?", body: ["Ctrl+C received.", "Press Q to quit arcade, or Esc to continue."], actions: ["[Q] Quit", "[Esc] Continue"] }; + return; + } + + if (showLogs) { + if (event.name === "escape" || event.name === "l" || (event.ctrl && event.name === "g")) { + showLogs = false; + } + return; + } + + if (modal) { + if (event.name === "q") { + closed = true; + cleanup(); + } else if (event.name === "l" || (event.ctrl && event.name === "g")) { + showLogs = true; + } else if (event.name === "escape" || event.name === "c" || event.name === "a" || event.name === "d" || event.name === "r") { + modal = undefined; + game.resume?.(); + } + return; + } + + const action = game.input(event); + if (action?.type === "quit") { + closed = true; + cleanup(); + } else if (action?.type === "pause") { + game.pause?.(); + modal = { kind: "pause", title: "ARCADE PAUSED", body: ["Task continues in the background."], actions: ["[Esc/C] Continue", "[L] Logs", "[Q] Quit"] }; + } else if (action?.type === "toggle_logs") { + showLogs = true; + } else if (action?.type === "redraw") { + render(); + } + }; + + const applyTaskEvent = (event: TaskEvent) => { + if ("task" in event) { + task = event.task; + } + if (event.type === "task:log") { + logs.push(`[${event.timestamp}] ${event.line}`); + } + if (event.type === "task:approval_required" || event.type === "task:expense_required" || event.type === "task:done" || event.type === "task:error") { + game.pause?.(); + } + if (event.type === "task:approval_required") { + modal = { kind: "approval", title: "APPROVAL REQUIRED", body: [event.reason], actions: ["[A] Approve", "[D] Deny", "[L] Logs", "[P] Pause Job"] }; + } + if (event.type === "task:expense_required") { + modal = { kind: "approval", title: "EXPENSE APPROVAL", body: [`Provider: ${event.provider}`, `Cost: $${event.amountUsd.toFixed(2)}`, event.reason], actions: ["[A] Approve", "[D] Deny", "[L] Logs"] }; + } + if (event.type === "task:done") { + modal = { kind: "complete", title: "TASK COMPLETE", body: [event.result, `Cost: $${event.task.costUsd?.toFixed(2) ?? "0.00"}`], actions: ["[R] Review", "[L] Logs", "[C] Continue", "[Q] Quit"] }; + } + if (event.type === "task:error") { + modal = { kind: "error", title: "TASK FAILED", body: [event.error], actions: ["[R] Retry", "[L] Logs", "[Q] Quit"] }; + } + }; + + const timers = options.simulateTask ? createDemoTaskEvents(applyTaskEvent) : []; + + input.setRawMode(true); + input.resume(); + input.on("data", onData); + process.on("SIGINT", onSigint); + output.write("\x1b[?1049h\x1b[?25l"); + + const render = () => { + if (closed) { + return; + } + const width = output.columns ?? 80; + const height = output.rows ?? 28; + const frame = new TextFrame(width, height); + if (showLogs) { + renderLogs(frame, logs); + } else { + game.render(frame); + renderTaskOverlay(frame, task, options.standalone ?? true); + } + output.write(`\x1b[H\x1b[2J${frame.toString()}`); + if (modal) { + output.write("\n\n" + renderModal(modal.title, modal.body, modal.actions)); + } + }; + + const loop = setInterval(() => { + const now = Date.now(); + if (!modal && !showLogs) { + game.tick(now - lastFrameAt); + } + lastFrameAt = now; + render(); + }, 66); + + render(); + + await new Promise((resolve) => { + const done = setInterval(() => { + if (closed) { + clearInterval(done); + resolve(); + } + }, 50); + }); +} + +function renderLogs(frame: TextFrame, logs: string[]) { + frame.box(0, 0, frame.width, frame.height, "TASK LOGS"); + const visible = logs.slice(-(frame.height - 4)); + if (visible.length === 0) { + frame.write(2, 2, "No logs yet."); + } + for (const [index, line] of visible.entries()) { + frame.write(2 + index, 2, fit(line, frame.width - 4)); + } + frame.write(frame.height - 2, 2, "[Esc/L/Ctrl+G] close logs"); +} + +function parseKey(data: Buffer): KeyEvent { + const sequence = data.toString("utf8"); + if (sequence === "\u0003") return { name: "c", sequence, ctrl: true }; + if (sequence === "\u0007") return { name: "g", sequence, ctrl: true }; + if (sequence === "\u001b") return { name: "escape", sequence }; + if (sequence === "\r") return { name: "return", sequence }; + if (sequence === " ") return { name: "space", sequence }; + if (sequence === "\u001b[A") return { name: "up", sequence }; + if (sequence === "\u001b[B") return { name: "down", sequence }; + if (sequence === "\u001b[C") return { name: "right", sequence }; + if (sequence === "\u001b[D") return { name: "left", sequence }; + return { name: sequence.toLowerCase(), sequence }; +} + +function createDemoTaskEvents(apply: (event: TaskEvent) => void) { + const started: TaskSnapshot = { + id: "demo_yolo", + title: "AgentSwarm YOLO session", + status: "running", + phase: "inspecting repo", + progress: 0.2, + costUsd: 0.08, + lastMessage: "cloned repo" + }; + + return [ + setTimeout(() => apply({ type: "task:started", task: started }), 250), + setTimeout(() => apply({ type: "task:log", timestamp: new Date().toISOString(), line: "agent swarm inspected issue context" }), 900), + setTimeout( + () => + apply({ + type: "task:progress", + task: { ...started, phase: "running tests", progress: 0.58, costUsd: 0.21, lastMessage: "generated patch" } + }), + 2200 + ), + setTimeout( + () => + apply({ + type: "task:expense_required", + task: { ...started, status: "approval_required", phase: "browser QA", progress: 0.72, costUsd: 0.21, lastMessage: "approval required" }, + provider: "Browserless", + amountUsd: 0.18, + reason: "Verify checkout flow before opening the PR." + }), + 5200 + ), + setTimeout( + () => + apply({ + type: "task:done", + task: { ...started, status: "done", phase: "complete", progress: 1, costUsd: 0.72, lastMessage: "PR ready for review" }, + result: "PR #184 is ready for review. Tests passed." + }), + 11000 + ) + ]; +} diff --git a/packages/tui/src/arcade/types.ts b/packages/tui/src/arcade/types.ts new file mode 100644 index 0000000..68c5c62 --- /dev/null +++ b/packages/tui/src/arcade/types.ts @@ -0,0 +1,71 @@ +export interface GameControl { + key: string; + action: string; +} + +export interface KeyEvent { + name: string; + sequence: string; + ctrl?: boolean; +} + +export type GameAction = + | { type: "quit" } + | { type: "pause" } + | { type: "toggle_logs" } + | { type: "redraw" } + | { type: "message"; message: string }; + +export interface TaskSnapshot { + id: string; + title: string; + status: "queued" | "running" | "paused" | "approval_required" | "done" | "error" | "cancelled"; + phase?: string; + progress?: number; + costUsd?: number; + lastMessage?: string; +} + +export type TaskEvent = + | { type: "task:started"; task: TaskSnapshot } + | { type: "task:progress"; task: TaskSnapshot } + | { type: "task:log"; line: string; timestamp: string } + | { type: "task:approval_required"; task: TaskSnapshot; reason: string } + | { type: "task:expense_required"; task: TaskSnapshot; provider: string; amountUsd: number; reason: string } + | { type: "task:done"; task: TaskSnapshot; result: string } + | { type: "task:error"; task: TaskSnapshot; error: string } + | { type: "task:cancelled"; task: TaskSnapshot }; + +export type ArcadeEvent = { type: "game:score"; score: number } | { type: "game:message"; message: string }; + +export interface GameContext { + width: number; + height: number; + random: () => number; + task?: TaskSnapshot; + emit: (event: ArcadeEvent) => void; +} + +export interface WaitingGame { + id: string; + title: string; + description: string; + controls: GameControl[]; + + init(ctx: GameContext): void | Promise; + tick(deltaMs: number): void; + input(event: KeyEvent): GameAction | void; + render(frame: TerminalFrame): void; + + pause?(): void; + resume?(): void; + stop?(): void; +} + +export interface TerminalFrame { + width: number; + height: number; + write(row: number, col: number, text: string): void; + box(row: number, col: number, width: number, height: number, title?: string): void; + toString(): string; +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index ba7cd74..4540f7e 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -1,6 +1,8 @@ import { createPluginRegistry } from "@logicsrc/plugin-core"; import { coinPayPlugin } from "@logicsrc/plugin-coinpay"; import { uGigPlugin } from "@logicsrc/plugin-ugig"; +export { ArcadeRegistry, createDefaultArcadeRegistry, renderArcadeList, renderArcadeSnapshot, runArcadeSession } from "./arcade/index.js"; +export type { ArcadeEvent, GameAction, GameContext, GameControl, KeyEvent, TaskEvent, TaskSnapshot, TerminalFrame, WaitingGame } from "./arcade/index.js"; export interface TuiState { did: string;