nou-cli 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +48 -9
  2. package/dist/index.js +9 -99
  3. package/dist/src/agent/index.js +199 -33
  4. package/dist/src/cli.js +117 -0
  5. package/dist/src/commands/index.js +233 -25
  6. package/dist/src/config/index.js +14 -0
  7. package/dist/src/context/index.js +33 -10
  8. package/dist/src/conversations/external.js +165 -0
  9. package/dist/src/conversations/index.js +71 -0
  10. package/dist/src/engines/claude.js +10 -0
  11. package/dist/src/engines/codex.js +60 -5
  12. package/dist/src/games/index.js +217 -0
  13. package/dist/src/mcp/import.js +106 -0
  14. package/dist/src/mcp/index.js +184 -0
  15. package/dist/src/providers/anthropic.js +23 -3
  16. package/dist/src/providers/index.js +13 -0
  17. package/dist/src/providers/openai.js +6 -2
  18. package/dist/src/syntax/index.js +10 -20
  19. package/dist/src/syntax/themes.js +57 -0
  20. package/dist/src/terminal/index.js +127 -0
  21. package/dist/src/tools/index.js +86 -2
  22. package/dist/src/ui/App.js +900 -118
  23. package/dist/src/ui/Chat.js +143 -22
  24. package/dist/src/ui/ChatInput.js +9 -2
  25. package/dist/src/ui/CodeView.js +111 -20
  26. package/dist/src/ui/Editor.js +13 -6
  27. package/dist/src/ui/FileTree.js +6 -5
  28. package/dist/src/ui/GamePanel.js +99 -0
  29. package/dist/src/ui/Header.js +1 -1
  30. package/dist/src/ui/QuickOpen.js +51 -12
  31. package/dist/src/ui/Settings.js +58 -13
  32. package/dist/src/ui/Tabs.js +56 -0
  33. package/dist/src/ui/Terminal.js +84 -0
  34. package/dist/src/ui/clipboard.js +9 -7
  35. package/dist/src/ui/commands.js +36 -0
  36. package/dist/src/ui/definition.js +135 -0
  37. package/dist/src/ui/fast-width.js +62 -0
  38. package/dist/src/ui/mouse.js +4 -1
  39. package/dist/src/ui/render-options.js +4 -0
  40. package/dist/src/ui/text.js +132 -12
  41. package/dist/src/ui/width-shim.js +7 -0
  42. package/package.json +2 -1
@@ -0,0 +1,165 @@
1
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ /** Files looked at per tool: the newest ones. */
5
+ const SCAN = 300;
6
+ const claudeDir = (home) => process.env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
7
+ const codexDir = (home) => process.env.CODEX_HOME ?? path.join(home, ".codex");
8
+ function jsonLines(file, limit = Infinity) {
9
+ const out = [];
10
+ let text;
11
+ try {
12
+ text = readFileSync(file, "utf-8");
13
+ }
14
+ catch {
15
+ return out;
16
+ }
17
+ for (const line of text.split("\n")) {
18
+ if (out.length >= limit)
19
+ break;
20
+ if (!line.trim())
21
+ continue;
22
+ try {
23
+ out.push(JSON.parse(line));
24
+ }
25
+ catch {
26
+ // a line being written
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+ /** The start of a file, for the line that says which folder a Codex session ran in. */
32
+ function head(file, bytes = 8192) {
33
+ const fd = openSync(file, "r");
34
+ try {
35
+ const buf = Buffer.alloc(bytes);
36
+ return buf.subarray(0, readSync(fd, buf, 0, bytes, 0)).toString("utf-8");
37
+ }
38
+ finally {
39
+ closeSync(fd);
40
+ }
41
+ }
42
+ /** Plain text from a message's content (a string, or parts with text). */
43
+ function textOf(content) {
44
+ if (typeof content === "string")
45
+ return content;
46
+ if (!Array.isArray(content))
47
+ return "";
48
+ return content.filter((p) => typeof p?.text === "string" && /^(text|input_text|output_text)$/.test(p.type ?? "text")).map((p) => p.text).join("\n");
49
+ }
50
+ /** What the tools add on their own (context blocks, command echoes): not something you typed. */
51
+ const injected = (text) => /^\s*<(environment_context|user_instructions|command-|local-command|system-reminder)/.test(text) || text.startsWith("Caveat:");
52
+ const titleOf = (text) => text.replace(/\s+/g, " ").trim().slice(0, 80);
53
+ /** Claude Code's sessions for this folder. */
54
+ function claudeSessions(home, cwd) {
55
+ const dir = path.join(claudeDir(home), "projects", cwd.replace(/[^A-Za-z0-9]/g, "-"));
56
+ if (!existsSync(dir))
57
+ return [];
58
+ const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => path.join(dir, f));
59
+ const out = [];
60
+ for (const file of newest(files)) {
61
+ const said = claudeEntries(file);
62
+ const first = said.find((e) => e.kind === "user");
63
+ if (!first)
64
+ continue;
65
+ out.push({ id: path.basename(file, ".jsonl"), engine: "claude-code", title: titleOf(first.text), updated: statSync(file).mtimeMs, messages: said.filter((e) => e.kind === "user").length, file });
66
+ }
67
+ return out;
68
+ }
69
+ function claudeEntries(file) {
70
+ const out = [];
71
+ for (const line of jsonLines(file)) {
72
+ if (line.isSidechain || line.isMeta)
73
+ continue;
74
+ if (line.type === "user" && line.message?.role === "user") {
75
+ const text = textOf(line.message.content);
76
+ if (text.trim() && !injected(text))
77
+ out.push({ kind: "user", text: text.trim() });
78
+ }
79
+ else if (line.type === "assistant") {
80
+ const text = textOf(line.message?.content);
81
+ if (text.trim())
82
+ out.push({ kind: "assistant", text: text.trim() });
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+ /** Codex's sessions for this folder (it keeps them all in one tree, by date). */
88
+ function codexSessions(home, cwd) {
89
+ const root = path.join(codexDir(home), "sessions");
90
+ if (!existsSync(root))
91
+ return [];
92
+ const files = [];
93
+ const walk = (dir, depth) => {
94
+ for (const name of readdirSync(dir).sort().reverse()) {
95
+ if (files.length >= SCAN)
96
+ return;
97
+ const full = path.join(dir, name);
98
+ if (depth < 3)
99
+ walk(full, depth + 1);
100
+ else if (name.startsWith("rollout-") && name.endsWith(".jsonl"))
101
+ files.push(full);
102
+ }
103
+ };
104
+ try {
105
+ walk(root, 0);
106
+ }
107
+ catch {
108
+ return [];
109
+ }
110
+ const out = [];
111
+ for (const file of files) {
112
+ let meta;
113
+ try {
114
+ meta = JSON.parse(head(file).split("\n")[0]);
115
+ }
116
+ catch {
117
+ continue;
118
+ }
119
+ const payload = meta?.type === "session_meta" ? meta.payload : null;
120
+ if (!payload?.id || path.resolve(payload.cwd ?? "") !== path.resolve(cwd))
121
+ continue;
122
+ const said = codexEntries(file);
123
+ const first = said.find((e) => e.kind === "user");
124
+ if (!first)
125
+ continue;
126
+ out.push({ id: payload.id, engine: "codex", title: titleOf(first.text), updated: statSync(file).mtimeMs, messages: said.filter((e) => e.kind === "user").length, file });
127
+ }
128
+ return out;
129
+ }
130
+ function codexEntries(file) {
131
+ const out = [];
132
+ for (const line of jsonLines(file)) {
133
+ const p = line.type === "response_item" ? line.payload : null;
134
+ if (p?.type !== "message")
135
+ continue;
136
+ const text = textOf(p.content).trim();
137
+ if (!text || injected(text))
138
+ continue;
139
+ if (p.role === "user")
140
+ out.push({ kind: "user", text });
141
+ else if (p.role === "assistant")
142
+ out.push({ kind: "assistant", text });
143
+ }
144
+ return out;
145
+ }
146
+ function newest(files) {
147
+ return files.map((f) => ({ f, t: statSync(f).mtimeMs })).sort((a, b) => b.t - a.t).slice(0, SCAN).map((x) => x.f);
148
+ }
149
+ /** Claude Code's and Codex's own sessions in this folder, latest first. */
150
+ export function externalSessions(home = os.homedir(), cwd = process.cwd()) {
151
+ const out = [];
152
+ for (const read of [claudeSessions, codexSessions]) {
153
+ try {
154
+ out.push(...read(home, cwd));
155
+ }
156
+ catch {
157
+ // a tool's files we can't read: skip them
158
+ }
159
+ }
160
+ return out.sort((a, b) => b.updated - a.updated);
161
+ }
162
+ /** The chat of an external session, to show when it's resumed. */
163
+ export function externalEntries(session) {
164
+ return session.engine === "claude-code" ? claudeEntries(session.file) : codexEntries(session.file);
165
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from "crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
3
+ import path from "path";
4
+ import { NOU_HOME } from "../config/index.js";
5
+ /** Kept per project: the oldest go once there are more. */
6
+ const KEEP = 50;
7
+ /** Chat entries saved per conversation (the latest ones). */
8
+ const MAX_ENTRIES = 500;
9
+ function folder(cwd = process.cwd()) {
10
+ const name = path.basename(cwd).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40);
11
+ return path.join(NOU_HOME, "conversations", `${name}-${createHash("sha1").update(cwd).digest("hex").slice(0, 10)}`);
12
+ }
13
+ export function saveConversation(conversation) {
14
+ const dir = folder();
15
+ try {
16
+ if (!existsSync(dir))
17
+ mkdirSync(dir, { recursive: true });
18
+ const entries = conversation.entries.slice(-MAX_ENTRIES);
19
+ writeFileSync(path.join(dir, `${conversation.id}.json`), JSON.stringify({ ...conversation, entries }), { mode: 0o600 });
20
+ const old = listConversations().slice(KEEP);
21
+ for (const c of old)
22
+ unlinkSync(path.join(dir, `${c.id}.json`));
23
+ }
24
+ catch {
25
+ // Saving is a convenience: a read-only home shouldn't break the chat.
26
+ }
27
+ }
28
+ /** This project's conversations, latest first. */
29
+ export function listConversations() {
30
+ const dir = folder();
31
+ if (!existsSync(dir))
32
+ return [];
33
+ const out = [];
34
+ for (const file of readdirSync(dir)) {
35
+ if (!file.endsWith(".json"))
36
+ continue;
37
+ try {
38
+ const c = JSON.parse(readFileSync(path.join(dir, file), "utf-8"));
39
+ const state = c.state;
40
+ const ref = state?.claudeSession ?? state?.codexThread;
41
+ out.push({ id: c.id, title: c.title, updated: c.updated, engine: c.engine, messages: c.entries.filter((e) => e.kind === "user").length, ...(typeof ref === "string" ? { ref } : {}) });
42
+ }
43
+ catch {
44
+ // a damaged file is skipped
45
+ }
46
+ }
47
+ return out.sort((a, b) => b.updated - a.updated);
48
+ }
49
+ export function loadConversation(id) {
50
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
51
+ return null;
52
+ try {
53
+ return JSON.parse(readFileSync(path.join(folder(), `${id}.json`), "utf-8"));
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /** "5m ago", "yesterday"... */
60
+ export function ago(time, now = Date.now()) {
61
+ const s = Math.max(0, Math.round((now - time) / 1000));
62
+ if (s < 60)
63
+ return "just now";
64
+ if (s < 3600)
65
+ return `${Math.floor(s / 60)}m ago`;
66
+ if (s < 86_400)
67
+ return `${Math.floor(s / 3600)}h ago`;
68
+ if (s < 172_800)
69
+ return "yesterday";
70
+ return `${Math.floor(s / 86_400)}d ago`;
71
+ }
@@ -50,6 +50,16 @@ export class ClaudeCodeEngine extends ExternalEngine {
50
50
  resetConversation() {
51
51
  this.claudeSession = undefined;
52
52
  }
53
+ snapshot() {
54
+ return this.claudeSession ? { claudeSession: this.claudeSession } : undefined;
55
+ }
56
+ /** Claude Code keeps its sessions on disk, so a saved one resumes in a later run too. */
57
+ restore(state) {
58
+ if (typeof state?.claudeSession !== "string")
59
+ return false;
60
+ this.claudeSession = state.claudeSession;
61
+ return true;
62
+ }
53
63
  /** Claude Code can resume its session with another model, so the conversation carries over. */
54
64
  setModel(choice) {
55
65
  this.choice = choice;
@@ -1,11 +1,14 @@
1
1
  import { execFileSync } from "child_process";
2
2
  import { statSync } from "fs";
3
+ import { transientMessage } from "../agent/index.js";
3
4
  import { listAllFiles } from "../ui/QuickOpen.js";
4
5
  import { ExternalEngine } from "./external.js";
5
6
  import { loadPackage } from "./index.js";
6
7
  const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
7
8
  const MAX_FILES = 5000;
8
9
  const MAX_BYTES = 512 * 1024;
10
+ /** Times a turn that failed for a passing reason (a dropped stream, a failing server) is picked up again. */
11
+ const RESUMES = 5;
9
12
  /** Project files small enough to keep a copy of (git's list when there is one, so ignored files are skipped). */
10
13
  function projectFiles() {
11
14
  let files;
@@ -42,34 +45,82 @@ export class CodexEngine extends ExternalEngine {
42
45
  id = "codex";
43
46
  thread = null;
44
47
  introduced = false;
48
+ /** A saved thread to pick back up (/resume): Codex keeps its threads on disk. */
49
+ resumeId;
45
50
  constructor(choice, hooks) {
46
51
  super(choice, hooks, "Codex");
47
52
  }
48
53
  resetConversation() {
49
54
  this.thread = null;
50
55
  this.introduced = false;
56
+ this.resumeId = undefined;
57
+ }
58
+ snapshot() {
59
+ const id = this.thread?.id ?? this.resumeId;
60
+ return id ? { codexThread: id } : undefined;
61
+ }
62
+ restore(state) {
63
+ if (typeof state?.codexThread !== "string")
64
+ return false;
65
+ this.thread = null;
66
+ this.resumeId = state.codexThread;
67
+ this.introduced = true; // it already has the project context
68
+ return true;
51
69
  }
52
70
  async run(prompt, signal) {
53
71
  const { Codex } = await loadPackage("@openai/codex-sdk");
54
72
  // Codex uses its own login (`codex login`); an OpenAI key from Nou's setup works too.
55
73
  const apiKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY;
56
- this.thread ??= new Codex(apiKey ? { apiKey } : {}).startThread({
74
+ const options = {
57
75
  workingDirectory: process.cwd(),
58
76
  skipGitRepoCheck: true,
59
77
  sandboxMode: "workspace-write",
78
+ // Its sandbox blocks the network by default, so npm/pip installs and tests that fetch fail ("can't
79
+ // resolve registry.npmjs.org"); Nou's other engines can reach it, so Codex can too.
80
+ networkAccessEnabled: true,
60
81
  approvalPolicy: "never",
61
82
  ...(this.choice.provider.id === "openai" ? { model: this.choice.model } : {}),
62
83
  ...(this.effort && EFFORTS.has(this.effort) ? { modelReasoningEffort: this.effort } : {}),
63
- });
84
+ };
85
+ const codex = new Codex(apiKey ? { apiKey } : {});
86
+ this.thread ??= this.resumeId ? codex.resumeThread(this.resumeId, options) : codex.startThread(options);
64
87
  // Codex has no system prompt option; Nou's project context goes with the first message.
65
88
  const input = !this.introduced && this.extras.length ? `${this.extras.join("\n\n")}\n\n${prompt}` : prompt;
66
89
  this.introduced = true;
67
90
  this.tracker.remember(projectFiles());
91
+ // Codex retries a dropped stream itself ("Reconnecting... 2/5"); when it gives up, Nou picks the turn up
92
+ // again on the same thread, waiting longer each time, before calling it failed.
93
+ for (let attempt = 0;; attempt++) {
94
+ const failure = await this.runTurn(attempt ? "The connection dropped before you finished. Carry on where you left off." : input, signal);
95
+ if (!failure)
96
+ return;
97
+ if (attempt >= RESUMES || !transientMessage(failure))
98
+ throw new Error(explain(failure));
99
+ const wait = Math.min(30_000, 2000 * 2 ** attempt);
100
+ this.hooks.onStream("");
101
+ this.hooks.onBusy(`Codex: ${failure.split("\n")[0].slice(0, 70)}. Retrying in ${wait / 1000}s (${attempt + 1}/${RESUMES})...`);
102
+ await new Promise((resolve) => {
103
+ const timer = setTimeout(resolve, wait);
104
+ signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
105
+ });
106
+ if (signal.aborted)
107
+ throw new Error("aborted");
108
+ }
109
+ }
110
+ /** Runs one Codex turn; returns why it failed, if it did. */
111
+ async runTurn(input, signal) {
68
112
  let failure;
69
113
  try {
70
114
  const { events } = await this.thread.runStreamed(input, { signal });
71
- for await (const event of events)
115
+ for await (const event of events) {
116
+ if (event.type === "turn.completed") {
117
+ // Its turn is over (it may have just asked you something): done now, rather than waiting for the
118
+ // codex process to wind down, which kept "Codex is working..." up under its question.
119
+ failure = undefined; // and it recovered from anything that went wrong on the way
120
+ break;
121
+ }
72
122
  failure = (await this.handle(event)) ?? failure;
123
+ }
73
124
  }
74
125
  catch (error) {
75
126
  // The codex process exiting with an error ends the stream; its first line says why.
@@ -79,8 +130,7 @@ export class CodexEngine extends ExternalEngine {
79
130
  }
80
131
  if (signal.aborted)
81
132
  throw new Error("aborted");
82
- if (failure)
83
- throw new Error(explain(failure));
133
+ return failure;
84
134
  }
85
135
  /** Shows one Codex event; returns the error message if the turn failed. */
86
136
  async handle(event) {
@@ -124,6 +174,11 @@ export class CodexEngine extends ExternalEngine {
124
174
  return event.error.message;
125
175
  }
126
176
  else if (event.type === "error") {
177
+ // "Reconnecting... 1/5 (stream disconnected ...)": Codex is retrying, so it's progress, not the end.
178
+ if (/^Reconnecting\b/i.test(event.message)) {
179
+ this.hooks.onBusy(`Codex: ${event.message.split("\n")[0].slice(0, 100)}`);
180
+ return undefined;
181
+ }
127
182
  return event.message;
128
183
  }
129
184
  return undefined;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Little games for the Games panel, drawn with text. Each one is plain state and three functions, so the
3
+ * panel can run any of them and the tests can play them without a terminal.
4
+ */
5
+ /** A small seeded random number generator, so games can be replayed in tests. */
6
+ function rng(seed) {
7
+ let s = seed >>> 0 || 1;
8
+ return () => {
9
+ s ^= s << 13;
10
+ s ^= s >>> 17;
11
+ s ^= s << 5;
12
+ return (s >>> 0) / 4294967296;
13
+ };
14
+ }
15
+ const blank = (w, h) => Array.from({ length: h }, () => Array(w).fill(" "));
16
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
17
+ const PADDLE = 3;
18
+ const PONG_WIN = 7;
19
+ function serve(s, toward) {
20
+ const vy = (s.random() - 0.5) * 1.2;
21
+ return { ...s, bx: s.w / 2, by: s.h / 2, vx: toward * 0.9, vy: Math.abs(vy) < 0.2 ? 0.4 : vy };
22
+ }
23
+ export const pong = {
24
+ id: "pong",
25
+ title: "Pong",
26
+ blurb: "The classic: you on the left, the computer on the right. First to 7.",
27
+ controls: "↑↓ or w/s move",
28
+ tickMs: 50,
29
+ colors: { "█": "#61afef", "●": "#e5c07b", "┊": "#5c6370" },
30
+ create(w, h, seed = Date.now()) {
31
+ const s = { w, h, you: Math.floor((h - PADDLE) / 2), them: Math.floor((h - PADDLE) / 2), bx: 0, by: 0, vx: 0, vy: 0, scores: [0, 0], random: rng(seed) };
32
+ return serve(s, -1);
33
+ },
34
+ tick(s, inputs) {
35
+ if (s.scores[0] >= PONG_WIN || s.scores[1] >= PONG_WIN)
36
+ return s;
37
+ let you = s.you;
38
+ for (const i of inputs)
39
+ you += i === "up" ? -1 : i === "down" ? 1 : 0;
40
+ you = clamp(you, 0, s.h - PADDLE);
41
+ // The computer follows the ball, a bit slower than it moves, so it can be beaten.
42
+ const target = s.by - PADDLE / 2;
43
+ const them = clamp(s.them + clamp(target - s.them, -0.55, 0.55), 0, s.h - PADDLE);
44
+ let { bx, by, vx, vy } = s;
45
+ bx += vx;
46
+ by += vy;
47
+ if (by < 0)
48
+ [by, vy] = [-by, -vy];
49
+ if (by > s.h - 1)
50
+ [by, vy] = [2 * (s.h - 1) - by, -vy];
51
+ // Paddles at x=1 and x=w-2; where the ball hits the paddle sets its angle.
52
+ if (vx < 0 && bx <= 1.5 && bx >= 0.5 && by >= you - 0.5 && by <= you + PADDLE - 0.5) {
53
+ [bx, vx, vy] = [1.5, Math.min(1.4, -vx * 1.05), (by - (you + PADDLE / 2 - 0.5)) * 0.5];
54
+ }
55
+ if (vx > 0 && bx >= s.w - 2.5 && bx <= s.w - 1.5 && by >= them - 0.5 && by <= them + PADDLE - 0.5) {
56
+ [bx, vx, vy] = [s.w - 2.5, -Math.min(1.4, vx * 1.05), (by - (them + PADDLE / 2 - 0.5)) * 0.5];
57
+ }
58
+ const next = { ...s, you, them, bx, by, vx, vy };
59
+ if (bx < 0)
60
+ return serve({ ...next, scores: [s.scores[0], s.scores[1] + 1] }, -1);
61
+ if (bx > s.w - 1)
62
+ return serve({ ...next, scores: [s.scores[0] + 1, s.scores[1]] }, 1);
63
+ return next;
64
+ },
65
+ view(s) {
66
+ const grid = blank(s.w, s.h);
67
+ for (let y = 0; y < s.h; y += 2)
68
+ grid[y][Math.floor(s.w / 2)] = "┊";
69
+ for (let k = 0; k < PADDLE; k++) {
70
+ grid[s.you + k][1] = "█";
71
+ grid[Math.round(s.them) + k][s.w - 2] = "█";
72
+ }
73
+ const bx = Math.round(s.bx);
74
+ const by = Math.round(s.by);
75
+ if (grid[by]?.[bx] !== undefined)
76
+ grid[by][bx] = "●";
77
+ const [a, b] = s.scores;
78
+ return {
79
+ lines: grid.map((r) => r.join("")),
80
+ status: `you ${a} · ${b} computer ↑↓ move`,
81
+ ...(a >= PONG_WIN ? { over: "You win! ✦" } : b >= PONG_WIN ? { over: "The computer wins." } : {}),
82
+ };
83
+ },
84
+ };
85
+ const DIRS = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } };
86
+ const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" };
87
+ function placeFood(s) {
88
+ for (let tries = 0; tries < 500; tries++) {
89
+ const p = { x: Math.floor(s.random() * s.w), y: Math.floor(s.random() * s.h) };
90
+ if (!s.body.some((b) => b.x === p.x && b.y === p.y))
91
+ return p;
92
+ }
93
+ return { x: 0, y: 0 };
94
+ }
95
+ export const snake = {
96
+ id: "snake",
97
+ title: "Snake",
98
+ blurb: "Eat, grow, don't bite yourself or the walls.",
99
+ controls: "arrows or wasd steer",
100
+ tickMs: 110,
101
+ colors: { "█": "#98c379", "▓": "#7ec16e", "◆": "#e06c75" },
102
+ create(w, h, seed = Date.now()) {
103
+ const y = Math.floor(h / 2);
104
+ const s = { w, h, body: [{ x: 4, y }, { x: 3, y }, { x: 2, y }], dir: "right", food: { x: 0, y: 0 }, score: 0, dead: false, random: rng(seed) };
105
+ return { ...s, food: placeFood(s) };
106
+ },
107
+ tick(s, inputs) {
108
+ if (s.dead)
109
+ return s;
110
+ let dir = s.dir;
111
+ // One turn per tick (the first that isn't straight back), so quick double taps don't fold it onto itself.
112
+ for (const i of inputs) {
113
+ if (i !== OPPOSITE[dir] && i !== dir) {
114
+ dir = i;
115
+ break;
116
+ }
117
+ }
118
+ const head = { x: s.body[0].x + DIRS[dir].x, y: s.body[0].y + DIRS[dir].y };
119
+ const eats = head.x === s.food.x && head.y === s.food.y;
120
+ const rest = eats ? s.body : s.body.slice(0, -1);
121
+ const dead = head.x < 0 || head.y < 0 || head.x >= s.w || head.y >= s.h || rest.some((b) => b.x === head.x && b.y === head.y);
122
+ if (dead)
123
+ return { ...s, dir, dead: true };
124
+ const next = { ...s, dir, body: [head, ...rest], score: s.score + (eats ? 1 : 0) };
125
+ return eats ? { ...next, food: placeFood(next) } : next;
126
+ },
127
+ view(s) {
128
+ const grid = blank(s.w, s.h);
129
+ grid[s.food.y][s.food.x] = "◆";
130
+ s.body.forEach((b, i) => {
131
+ if (grid[b.y]?.[b.x] !== undefined)
132
+ grid[b.y][b.x] = i === 0 ? "█" : "▓";
133
+ });
134
+ return { lines: grid.map((r) => r.join("")), status: `length ${s.body.length} · score ${s.score}`, ...(s.dead ? { over: `Game over: ${s.score} eaten.` } : {}) };
135
+ },
136
+ };
137
+ const PAD = 7;
138
+ const BRICK_ROWS = 4;
139
+ function launch(s) {
140
+ return { ...s, bx: s.paddle + PAD / 2, by: s.h - 3, vx: (s.random() < 0.5 ? -1 : 1) * 0.6, vy: -0.6 };
141
+ }
142
+ export const breakout = {
143
+ id: "breakout",
144
+ title: "Breakout",
145
+ blurb: "Knock out every brick with the ball. Three lives.",
146
+ controls: "←→ or a/d move",
147
+ tickMs: 45,
148
+ colors: { "▀": "#e06c75", "▄": "#d19a66", "▬": "#61afef", "●": "#e5c07b" },
149
+ create(w, h, seed = Date.now()) {
150
+ const s = {
151
+ w, h, paddle: Math.floor((w - PAD) / 2), bx: 0, by: 0, vx: 0, vy: 0,
152
+ bricks: Array.from({ length: BRICK_ROWS }, () => Array(Math.floor(w / 3)).fill(true)), lives: 3, score: 0, random: rng(seed),
153
+ };
154
+ return launch(s);
155
+ },
156
+ tick(s, inputs) {
157
+ if (s.lives <= 0 || s.bricks.every((r) => r.every((b) => !b)))
158
+ return s;
159
+ let paddle = s.paddle;
160
+ for (const i of inputs)
161
+ paddle += i === "left" ? -2 : i === "right" ? 2 : 0;
162
+ paddle = clamp(paddle, 0, s.w - PAD);
163
+ let { bx, by, vx, vy } = s;
164
+ bx += vx;
165
+ by += vy;
166
+ if (bx < 0 || bx > s.w - 1)
167
+ [bx, vx] = [clamp(bx, 0, s.w - 1), -vx];
168
+ if (by < 0)
169
+ [by, vy] = [0, -vy];
170
+ // Bricks are three columns wide, on rows 1.. so there's a gap at the top.
171
+ const bricks = s.bricks.map((r) => [...r]);
172
+ let score = s.score;
173
+ const row = Math.floor(by) - 1;
174
+ const col = Math.floor(bx / 3);
175
+ if (row >= 0 && row < BRICK_ROWS && bricks[row]?.[col]) {
176
+ bricks[row][col] = false;
177
+ score += (BRICK_ROWS - row) * 10;
178
+ vy = -vy;
179
+ }
180
+ // The paddle sits on the last row; where the ball lands on it sets the angle.
181
+ if (vy > 0 && by >= s.h - 2 && by < s.h - 1 && bx >= paddle - 0.5 && bx <= paddle + PAD - 0.5) {
182
+ vy = -Math.abs(vy);
183
+ vx = clamp((bx - (paddle + PAD / 2 - 0.5)) * 0.25, -1, 1) || 0.3;
184
+ by = s.h - 2;
185
+ }
186
+ const next = { ...s, paddle, bx, by, vx, vy, bricks, score };
187
+ if (by > s.h - 1) {
188
+ const lives = s.lives - 1;
189
+ return lives > 0 ? launch({ ...next, lives }) : { ...next, lives };
190
+ }
191
+ return next;
192
+ },
193
+ view(s) {
194
+ const grid = blank(s.w, s.h);
195
+ s.bricks.forEach((r, y) => r.forEach((alive, x) => {
196
+ if (alive)
197
+ for (let k = 0; k < 3; k++)
198
+ if (x * 3 + k < s.w - 0)
199
+ grid[y + 1][x * 3 + k] = y % 2 ? "▄" : "▀";
200
+ }));
201
+ for (let k = 0; k < PAD; k++)
202
+ grid[s.h - 1][s.paddle + k] = "▬";
203
+ const bx = Math.round(s.bx);
204
+ const by = Math.round(s.by);
205
+ if (s.lives > 0 && grid[by]?.[bx] !== undefined)
206
+ grid[by][bx] = "●";
207
+ const cleared = s.bricks.every((r) => r.every((b) => !b));
208
+ return {
209
+ lines: grid.map((r) => r.join("")),
210
+ status: `score ${s.score} · lives ${"♥".repeat(Math.max(0, s.lives))}`,
211
+ ...(cleared ? { over: `Cleared! ${s.score} points ✦` } : s.lives <= 0 ? { over: `Game over: ${s.score} points.` } : {}),
212
+ };
213
+ },
214
+ };
215
+ /** Every game Nou has, in the order settings lists them. */
216
+ export const GAMES = [pong, snake, breakout];
217
+ export const gameById = (id) => GAMES.find((g) => g.id === id);