mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-14 14:57:28 +00:00
Add waiting arcade MVP
This commit is contained in:
parent
b1d8fe475a
commit
4682b261c0
16 changed files with 1192 additions and 2 deletions
45
docs/arcade.md
Normal file
45
docs/arcade.md
Normal file
|
|
@ -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.
|
||||||
51
docs/config.md
Normal file
51
docs/config.md
Normal file
|
|
@ -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
|
||||||
|
```
|
||||||
13
docs/tui.md
Normal file
13
docs/tui.md
Normal file
|
|
@ -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.
|
||||||
87
packages/cli/src/config.ts
Normal file
87
packages/cli/src/config.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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<unknown>((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);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,61 @@
|
||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { Command } from "commander";
|
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 { assertSchemaKind, parseDocument, validate } from "@logicsrc/validators";
|
||||||
|
import { getConfigValue, readConfig, setConfigValue, writeConfig } from "./config.js";
|
||||||
import { boards, tasks } from "./fixtures.js";
|
import { boards, tasks } from "./fixtures.js";
|
||||||
import { print, type OutputFormat } from "./format.js";
|
import { print, type OutputFormat } from "./format.js";
|
||||||
import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js";
|
import { exportOpenSpecSummary, importOpenSpec, writeOpenSpecChange } from "./openspec.js";
|
||||||
import { defaultPluginRegistry } from "./registry.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();
|
const program = new Command();
|
||||||
|
program.enablePositionalOptions();
|
||||||
|
|
||||||
program
|
program
|
||||||
.name("logicsrc")
|
.name("logicsrc")
|
||||||
.description("LogicSRC OpenSpec CLI for schemas, boards, tasks, agents, payments, plugins, and TUI.")
|
.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", "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("--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 <game>", "Alias for --arcade=<game>.")
|
||||||
|
.option("--no-arcade", "Disable Waiting Arcade.")
|
||||||
.version("0.1.0");
|
.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
|
program
|
||||||
.command("login")
|
.command("login")
|
||||||
.option("--did <did>", "CoinPay DID")
|
.option("--did <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" }));
|
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("<path>", "Dot-path config key")
|
||||||
|
.description("Print a config value.")
|
||||||
|
.action((path) => {
|
||||||
|
console.log(JSON.stringify(getConfigValue(path), null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
config
|
||||||
|
.command("set")
|
||||||
|
.argument("<path>", "Dot-path config key")
|
||||||
|
.argument("<value>", "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
|
program
|
||||||
.command("agentswarm")
|
.command("agentswarm")
|
||||||
.alias("agent-swarm")
|
.alias("agent-swarm")
|
||||||
.description("Open an AgentSwarm master agent session.")
|
.description("Open an AgentSwarm master agent session.")
|
||||||
.option("--yolo", "Start the master agent with autonomous execution enabled.")
|
.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 <game>", "Alias for --arcade=<game>.")
|
||||||
|
.option("--no-arcade", "Disable Waiting Arcade.")
|
||||||
.option("--repo <repo>", "Target repository, for example profullstack/logicsrc")
|
.option("--repo <repo>", "Target repository, for example profullstack/logicsrc")
|
||||||
.option("--agents <agents>", "Comma-separated slave agent roles", "reproduce,patch,review")
|
.option("--agents <agents>", "Comma-separated slave agent roles", "reproduce,patch,review")
|
||||||
.option("--change <id>", "OpenSpec-compatible change id", "agentswarm-yolo")
|
.option("--change <id>", "OpenSpec-compatible change id", "agentswarm-yolo")
|
||||||
.option("--out <dir>", "OpenSpec-compatible output directory")
|
.option("--out <dir>", "OpenSpec-compatible output directory")
|
||||||
.action((options) => {
|
.action(async (options) => {
|
||||||
if (!options.yolo) {
|
if (!options.yolo) {
|
||||||
console.log("AgentSwarm is coming soon. Run `logicsrc agentswarm --yolo` to open the master agent flow.");
|
console.log("AgentSwarm is coming soon. Run `logicsrc agentswarm --yolo` to open the master agent flow.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -157,6 +239,12 @@ program
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const arcadeGame = resolveArcadeGame(options);
|
||||||
|
if (arcadeGame) {
|
||||||
|
await runYoloArcade(arcadeGame, options.repo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
print({
|
print({
|
||||||
type: "logicsrc.agentswarm.session",
|
type: "logicsrc.agentswarm.session",
|
||||||
status: "opening",
|
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) => {
|
program.parseAsync(process.argv).catch((error: unknown) => {
|
||||||
console.error(error instanceof Error ? error.message : String(error));
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
|
|
||||||
49
packages/tui/src/arcade/games/agent-swarm.ts
Normal file
49
packages/tui/src/arcade/games/agent-swarm.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
77
packages/tui/src/arcade/games/hangman.ts
Normal file
77
packages/tui/src/arcade/games/hangman.ts
Normal file
|
|
@ -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<string>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
83
packages/tui/src/arcade/games/minefield.ts
Normal file
83
packages/tui/src/arcade/games/minefield.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/tui/src/arcade/games/moon-runner.ts
Normal file
62
packages/tui/src/arcade/games/moon-runner.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
78
packages/tui/src/arcade/games/snake.ts
Normal file
78
packages/tui/src/arcade/games/snake.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
3
packages/tui/src/arcade/index.ts
Normal file
3
packages/tui/src/arcade/index.ts
Normal file
|
|
@ -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";
|
||||||
75
packages/tui/src/arcade/registry.ts
Normal file
75
packages/tui/src/arcade/registry.ts
Normal file
|
|
@ -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<string, GameFactory>();
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
98
packages/tui/src/arcade/renderer.ts
Normal file
98
packages/tui/src/arcade/renderer.ts
Normal file
|
|
@ -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");
|
||||||
|
}
|
||||||
265
packages/tui/src/arcade/runtime.ts
Normal file
265
packages/tui/src/arcade/runtime.ts
Normal file
|
|
@ -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<void>((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
|
||||||
|
)
|
||||||
|
];
|
||||||
|
}
|
||||||
71
packages/tui/src/arcade/types.ts
Normal file
71
packages/tui/src/arcade/types.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { createPluginRegistry } from "@logicsrc/plugin-core";
|
import { createPluginRegistry } from "@logicsrc/plugin-core";
|
||||||
import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
|
import { coinPayPlugin } from "@logicsrc/plugin-coinpay";
|
||||||
import { uGigPlugin } from "@logicsrc/plugin-ugig";
|
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 {
|
export interface TuiState {
|
||||||
did: string;
|
did: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue