mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +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,
|
confirm: async () => false,
|
||||||
input: async () => null,
|
input: async () => null,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
|
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,6 @@ export type {
|
||||||
AgentEndEvent,
|
AgentEndEvent,
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
HookMessageRenderer,
|
|
||||||
HookMessageRenderOptions,
|
|
||||||
CustomToolResultEvent,
|
CustomToolResultEvent,
|
||||||
EditToolResultEvent,
|
EditToolResultEvent,
|
||||||
ExecOptions,
|
ExecOptions,
|
||||||
|
|
@ -28,6 +26,8 @@ export type {
|
||||||
HookEventContext,
|
HookEventContext,
|
||||||
HookFactory,
|
HookFactory,
|
||||||
HookMessage,
|
HookMessage,
|
||||||
|
HookMessageRenderer,
|
||||||
|
HookMessageRenderOptions,
|
||||||
HookUIContext,
|
HookUIContext,
|
||||||
LsToolResultEvent,
|
LsToolResultEvent,
|
||||||
ReadToolResultEvent,
|
ReadToolResultEvent,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const noOpUIContext: HookUIContext = {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => null,
|
input: async () => null,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
|
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,15 @@ export interface HookUIContext {
|
||||||
* Show a notification to the user.
|
* Show a notification to the user.
|
||||||
*/
|
*/
|
||||||
notify(message: string, type?: "info" | "warning" | "error"): void;
|
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");
|
.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(
|
this.box.addChild(
|
||||||
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
new Markdown(text, 0, 0, getMarkdownTheme(), {
|
||||||
color: (text: string) => theme.fg("customMessageText", text),
|
color: (text: string) => theme.fg("customMessageText", text),
|
||||||
|
|
|
||||||
|
|
@ -443,6 +443,7 @@ export class InteractiveMode {
|
||||||
confirm: (title, message) => this.showHookConfirm(title, message),
|
confirm: (title, message) => this.showHookConfirm(title, message),
|
||||||
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
input: (title, placeholder) => this.showHookInput(title, placeholder),
|
||||||
notify: (message, type) => this.showHookNotify(message, type),
|
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.
|
* Show a hook error in the UI.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,11 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
||||||
notifyType: type,
|
notifyType: type,
|
||||||
} as RpcHookUIRequest);
|
} as RpcHookUIRequest);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
custom() {
|
||||||
|
// Custom UI not supported in RPC mode
|
||||||
|
return { close: () => {}, requestRender: () => {} };
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load entries once for session start events
|
// Load entries once for session start events
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => {
|
||||||
confirm: async () => false,
|
confirm: async () => false,
|
||||||
input: async () => null,
|
input: async () => null,
|
||||||
notify: () => {},
|
notify: () => {},
|
||||||
|
custom: () => ({ close: () => {}, requestRender: () => {} }),
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue