diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts new file mode 100644 index 00000000..05013e94 --- /dev/null +++ b/packages/coding-agent/examples/hooks/snake.ts @@ -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 | 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); + }, + }); +} diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index e3e7d0d9..f22cb6c9 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -90,6 +90,7 @@ function createNoOpUIContext(): HookUIContext { confirm: async () => false, input: async () => null, notify: () => {}, + custom: () => ({ close: () => {}, requestRender: () => {} }), }; } diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index e16282bb..5df5f5fa 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -13,8 +13,6 @@ export type { AgentEndEvent, AgentStartEvent, BashToolResultEvent, - HookMessageRenderer, - HookMessageRenderOptions, CustomToolResultEvent, EditToolResultEvent, ExecOptions, @@ -28,6 +26,8 @@ export type { HookEventContext, HookFactory, HookMessage, + HookMessageRenderer, + HookMessageRenderOptions, HookUIContext, LsToolResultEvent, ReadToolResultEvent, diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 768ffc86..6af0b77c 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -52,6 +52,7 @@ const noOpUIContext: HookUIContext = { confirm: async () => false, input: async () => null, notify: () => {}, + custom: () => ({ close: () => {}, requestRender: () => {} }), }; /** diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 7b329438..6ca2f11b 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -54,6 +54,15 @@ export interface HookUIContext { * Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; + + /** + * Show a custom component with keyboard focus. + * The component receives keyboard input via handleInput() if implemented. + * + * @param component - Component to display (implement handleInput for keyboard, dispose for cleanup) + * @returns Object with close() to restore normal UI and requestRender() to trigger redraw + */ + custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void }; } /** diff --git a/packages/coding-agent/src/modes/interactive/components/custom-message.ts b/packages/coding-agent/src/modes/interactive/components/custom-message.ts index 5e2d3410..cb1ccf34 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-message.ts @@ -75,6 +75,14 @@ export class CustomMessageComponent extends Container { .join("\n"); } + // Limit lines when collapsed + if (!this._expanded) { + const lines = text.split("\n"); + if (lines.length > 5) { + text = `${lines.slice(0, 5).join("\n")}\n...`; + } + } + this.box.addChild( new Markdown(text, 0, 0, getMarkdownTheme(), { color: (text: string) => theme.fg("customMessageText", text), diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 1c910d37..3f2e7fda 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -443,6 +443,7 @@ export class InteractiveMode { confirm: (title, message) => this.showHookConfirm(title, message), input: (title, placeholder) => this.showHookInput(title, placeholder), notify: (message, type) => this.showHookNotify(message, type), + custom: (component) => this.showHookCustom(component), }; } @@ -539,6 +540,42 @@ export class InteractiveMode { } } + /** + * Show a custom component with keyboard focus. + * Returns a function to call when done. + */ + private showHookCustom(component: Component & { dispose?(): void }): { + close: () => void; + requestRender: () => void; + } { + // Store current editor content + const savedText = this.editor.getText(); + + // Replace editor with custom component + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(component); + this.ui.requestRender(); + + // Return control object + return { + close: () => { + // Call dispose if available + component.dispose?.(); + + // Restore editor + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.editor.setText(savedText); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }, + requestRender: () => { + this.ui.requestRender(); + }, + }; + } + /** * Show a hook error in the UI. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index cae2d5f4..55819d9e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -118,6 +118,11 @@ export async function runRpcMode(session: AgentSession): Promise { notifyType: type, } as RpcHookUIRequest); }, + + custom() { + // Custom UI not supported in RPC mode + return { close: () => {}, requestRender: () => {} }; + }, }); // Load entries once for session start events diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index ab23364b..92b056cd 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { confirm: async () => false, input: async () => null, notify: () => {}, + custom: () => ({ close: () => {}, requestRender: () => {} }), }, false, );