mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 08:02:17 +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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -90,6 +90,7 @@ function createNoOpUIContext(): HookUIContext {
|
|||
confirm: async () => false,
|
||||
input: async () => null,
|
||||
notify: () => {},
|
||||
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ const noOpUIContext: HookUIContext = {
|
|||
confirm: async () => false,
|
||||
input: async () => null,
|
||||
notify: () => {},
|
||||
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -118,6 +118,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
notifyType: type,
|
||||
} as RpcHookUIRequest);
|
||||
},
|
||||
|
||||
custom() {
|
||||
// Custom UI not supported in RPC mode
|
||||
return { close: () => {}, requestRender: () => {} };
|
||||
},
|
||||
});
|
||||
|
||||
// Load entries once for session start events
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
|||
confirm: async () => false,
|
||||
input: async () => null,
|
||||
notify: () => {},
|
||||
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue