mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 11:03:44 +00:00
Add ui.custom() for custom hook components with keyboard focus
- Add custom() to HookUIContext: returns { close, requestRender }
- Component receives keyboard input via handleInput()
- CustomMessageComponent default rendering now limits to 5 lines when collapsed
- Add snake.ts example hook with /snake command
This commit is contained in:
parent
a8866d7a83
commit
14ad8d6228
9 changed files with 315 additions and 2 deletions
251
packages/coding-agent/examples/hooks/snake.ts
Normal file
251
packages/coding-agent/examples/hooks/snake.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Snake game hook - play snake with /snake command
|
||||
*/
|
||||
|
||||
import type { HookAPI } from "../../src/core/hooks/types.js";
|
||||
|
||||
const GAME_WIDTH = 40;
|
||||
const GAME_HEIGHT = 15;
|
||||
const TICK_MS = 100;
|
||||
|
||||
type Direction = "up" | "down" | "left" | "right";
|
||||
type Point = { x: number; y: number };
|
||||
|
||||
interface GameState {
|
||||
snake: Point[];
|
||||
food: Point;
|
||||
direction: Direction;
|
||||
nextDirection: Direction;
|
||||
score: number;
|
||||
gameOver: boolean;
|
||||
highScore: number;
|
||||
}
|
||||
|
||||
function createInitialState(): GameState {
|
||||
const startX = Math.floor(GAME_WIDTH / 2);
|
||||
const startY = Math.floor(GAME_HEIGHT / 2);
|
||||
return {
|
||||
snake: [
|
||||
{ x: startX, y: startY },
|
||||
{ x: startX - 1, y: startY },
|
||||
{ x: startX - 2, y: startY },
|
||||
],
|
||||
food: spawnFood([{ x: startX, y: startY }]),
|
||||
direction: "right",
|
||||
nextDirection: "right",
|
||||
score: 0,
|
||||
gameOver: false,
|
||||
highScore: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function spawnFood(snake: Point[]): Point {
|
||||
let food: Point;
|
||||
do {
|
||||
food = {
|
||||
x: Math.floor(Math.random() * GAME_WIDTH),
|
||||
y: Math.floor(Math.random() * GAME_HEIGHT),
|
||||
};
|
||||
} while (snake.some((s) => s.x === food.x && s.y === food.y));
|
||||
return food;
|
||||
}
|
||||
|
||||
class SnakeComponent {
|
||||
private state: GameState;
|
||||
private interval: ReturnType<typeof setInterval> | null = null;
|
||||
private onClose: () => void;
|
||||
private requestRender: () => void;
|
||||
private cachedLines: string[] = [];
|
||||
private cachedWidth = 0;
|
||||
private version = 0;
|
||||
private cachedVersion = -1;
|
||||
|
||||
constructor(onClose: () => void, requestRender: () => void) {
|
||||
this.state = createInitialState();
|
||||
this.onClose = onClose;
|
||||
this.requestRender = requestRender;
|
||||
this.startGame();
|
||||
}
|
||||
|
||||
private startGame(): void {
|
||||
this.interval = setInterval(() => {
|
||||
if (!this.state.gameOver) {
|
||||
this.tick();
|
||||
this.version++;
|
||||
this.requestRender();
|
||||
}
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
private tick(): void {
|
||||
// Apply queued direction change
|
||||
this.state.direction = this.state.nextDirection;
|
||||
|
||||
// Calculate new head position
|
||||
const head = this.state.snake[0];
|
||||
let newHead: Point;
|
||||
|
||||
switch (this.state.direction) {
|
||||
case "up":
|
||||
newHead = { x: head.x, y: head.y - 1 };
|
||||
break;
|
||||
case "down":
|
||||
newHead = { x: head.x, y: head.y + 1 };
|
||||
break;
|
||||
case "left":
|
||||
newHead = { x: head.x - 1, y: head.y };
|
||||
break;
|
||||
case "right":
|
||||
newHead = { x: head.x + 1, y: head.y };
|
||||
break;
|
||||
}
|
||||
|
||||
// Check wall collision
|
||||
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check self collision
|
||||
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
|
||||
this.state.gameOver = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Move snake
|
||||
this.state.snake.unshift(newHead);
|
||||
|
||||
// Check food collision
|
||||
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
|
||||
this.state.score += 10;
|
||||
if (this.state.score > this.state.highScore) {
|
||||
this.state.highScore = this.state.score;
|
||||
}
|
||||
this.state.food = spawnFood(this.state.snake);
|
||||
} else {
|
||||
this.state.snake.pop();
|
||||
}
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// ESC or q to quit
|
||||
if (data === "\x1b" || data === "q" || data === "Q") {
|
||||
this.dispose();
|
||||
this.onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys
|
||||
if (data === "\x1b[A" || data === "w" || data === "W") {
|
||||
if (this.state.direction !== "down") this.state.nextDirection = "up";
|
||||
} else if (data === "\x1b[B" || data === "s" || data === "S") {
|
||||
if (this.state.direction !== "up") this.state.nextDirection = "down";
|
||||
} else if (data === "\x1b[C" || data === "d" || data === "D") {
|
||||
if (this.state.direction !== "left") this.state.nextDirection = "right";
|
||||
} else if (data === "\x1b[D" || data === "a" || data === "A") {
|
||||
if (this.state.direction !== "right") this.state.nextDirection = "left";
|
||||
}
|
||||
|
||||
// Restart on game over
|
||||
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
||||
const highScore = this.state.highScore;
|
||||
this.state = createInitialState();
|
||||
this.state.highScore = highScore;
|
||||
this.version++;
|
||||
this.requestRender();
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedWidth = 0;
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
||||
return this.cachedLines;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// Clamp game width to available terminal width (leaving space for border)
|
||||
const effectiveWidth = Math.min(GAME_WIDTH, width - 4);
|
||||
const effectiveHeight = GAME_HEIGHT;
|
||||
|
||||
// Header
|
||||
const header = ` SNAKE | Score: ${this.state.score} | High: ${this.state.highScore} `;
|
||||
lines.push(this.padLine(header, width));
|
||||
lines.push(this.padLine(`+${"-".repeat(effectiveWidth)}+`, width));
|
||||
|
||||
// Game grid
|
||||
for (let y = 0; y < effectiveHeight; y++) {
|
||||
let row = "|";
|
||||
for (let x = 0; x < effectiveWidth; x++) {
|
||||
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
||||
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
||||
const isFood = this.state.food.x === x && this.state.food.y === y;
|
||||
|
||||
if (isHead) {
|
||||
row += "\x1b[32m@\x1b[0m"; // Green head
|
||||
} else if (isBody) {
|
||||
row += "\x1b[32mo\x1b[0m"; // Green body
|
||||
} else if (isFood) {
|
||||
row += "\x1b[31m*\x1b[0m"; // Red food
|
||||
} else {
|
||||
row += " ";
|
||||
}
|
||||
}
|
||||
row += "|";
|
||||
lines.push(this.padLine(row, width));
|
||||
}
|
||||
|
||||
lines.push(this.padLine(`+${"-".repeat(effectiveWidth)}+`, width));
|
||||
|
||||
// Footer
|
||||
if (this.state.gameOver) {
|
||||
lines.push(this.padLine("\x1b[31m GAME OVER! \x1b[0m Press R to restart, ESC to quit", width));
|
||||
} else {
|
||||
lines.push(this.padLine(" Arrow keys or WASD to move, ESC to quit", width));
|
||||
}
|
||||
|
||||
this.cachedLines = lines;
|
||||
this.cachedWidth = width;
|
||||
this.cachedVersion = this.version;
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
private padLine(line: string, width: number): string {
|
||||
// Calculate visible length (strip ANSI codes)
|
||||
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
||||
const padding = Math.max(0, width - visibleLen);
|
||||
return line + " ".repeat(padding);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi: HookAPI) {
|
||||
pi.registerCommand("snake", {
|
||||
description: "Play Snake!",
|
||||
handler: async (ctx) => {
|
||||
if (!ctx.hasUI) {
|
||||
ctx.ui.notify("Snake requires interactive mode", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let ui: { close: () => void; requestRender: () => void } | null = null;
|
||||
|
||||
const component = new SnakeComponent(
|
||||
() => ui?.close(),
|
||||
() => ui?.requestRender(),
|
||||
);
|
||||
|
||||
ui = ctx.ui.custom(component);
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue