chore: clean up

This commit is contained in:
hypercross 2026-04-04 13:26:40 +08:00
parent 2984d8b20d
commit 5fd2c3d208
8 changed files with 0 additions and 968 deletions

View File

@ -1,286 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InputMapper, createInputMapper, type InputMapperOptions } from './InputMapper.js';
import Phaser from 'phaser';
// Mock Phaser
function createMockScene() {
const inputEvents = new Map<string, Function[]>();
const mockScene = {
input: {
on: vi.fn((event: string, callback: Function) => {
if (!inputEvents.has(event)) {
inputEvents.set(event, []);
}
inputEvents.get(event)!.push(callback);
}),
off: vi.fn((event: string, callback: Function) => {
const handlers = inputEvents.get(event);
if (handlers) {
const index = handlers.indexOf(callback);
if (index > -1) {
handlers.splice(index, 1);
}
}
}),
_emit: (event: string, ...args: any[]) => {
const handlers = inputEvents.get(event);
if (handlers) {
handlers.forEach(handler => handler(...args));
}
},
},
} as unknown as Phaser.Scene;
return {
mockScene,
inputEvents,
emitPointerDown: (pointer: Partial<Phaser.Input.Pointer>) => {
inputEvents.get('pointerdown')?.forEach(handler => handler(pointer));
},
};
}
function createMockGameObject() {
const events = new Map<string, Function[]>();
const mockObj = {
setInteractive: vi.fn(),
on: vi.fn((event: string, handler: Function) => {
if (!events.has(event)) {
events.set(event, []);
}
events.get(event)!.push(handler);
}),
off: vi.fn((event: string, handler: Function) => {
const handlers = events.get(event);
if (handlers) {
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
}
}),
_emit: (event: string, ...args: any[]) => {
events.get(event)?.forEach(handler => handler(...args));
},
} as unknown as Phaser.GameObjects.GameObject;
return {
mockObj,
emitPointerDown: () => events.get('pointerdown')?.forEach(handler => handler()),
};
}
describe('InputMapper', () => {
let options: InputMapperOptions;
let onSubmit: ReturnType<typeof vi.fn>;
beforeEach(() => {
onSubmit = vi.fn();
options = {
onSubmit,
};
});
describe('constructor', () => {
it('should create instance with options', () => {
const { mockScene } = createMockScene();
const mapper = new InputMapper(mockScene, options);
expect(mapper).toBeDefined();
});
});
describe('createInputMapper', () => {
it('should create instance via factory', () => {
const { mockScene } = createMockScene();
const mapper = createInputMapper(mockScene, options);
expect(mapper).toBeDefined();
});
});
describe('mapGridClick', () => {
it('should register pointerdown listener', () => {
const { mockScene, inputEvents } = createMockScene();
const mapper = new InputMapper(mockScene, options);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
vi.fn(() => null),
);
expect(mockScene.input.on).toHaveBeenCalledWith('pointerdown', expect.any(Function));
});
it('should call onSubmit with command from onCellClick', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn((col, row) => `place ${col} ${row}`);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
// Simulate pointer at position (120, 130)
// Local: (120-100, 130-100) = (20, 30)
// Cell: (floor(20/50), floor(30/50)) = (0, 0)
emitPointerDown({ x: 120, y: 130 });
expect(onCellClick).toHaveBeenCalledWith(0, 0);
expect(onSubmit).toHaveBeenCalledWith('place 0 0');
});
it('should ignore clicks outside grid', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn(() => 'click');
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
// Click outside grid (negative local coordinates)
emitPointerDown({ x: 50, y: 50 });
expect(onCellClick).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
it('should ignore clicks outside grid bounds', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn(() => 'click');
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
// Click at col 5 (beyond cols 0-2)
// Local X: 300-100 = 200, col = floor(200/50) = 4
emitPointerDown({ x: 300, y: 120 });
expect(onCellClick).not.toHaveBeenCalled();
});
it('should not call onSubmit when onCellClick returns null', () => {
const { mockScene, emitPointerDown } = createMockScene();
const mapper = new InputMapper(mockScene, options);
const onCellClick = vi.fn(() => null);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
onCellClick,
);
emitPointerDown({ x: 120, y: 130 });
expect(onCellClick).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
});
describe('mapObjectClick', () => {
it('should set game objects as interactive', () => {
const { mockScene } = createMockScene();
const { mockObj: obj1 } = createMockGameObject();
const { mockObj: obj2 } = createMockGameObject();
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([obj1, obj2], vi.fn(() => null));
expect(obj1.setInteractive).toHaveBeenCalledWith({ useHandCursor: true });
expect(obj2.setInteractive).toHaveBeenCalledWith({ useHandCursor: true });
});
it('should call onSubmit when object is clicked', () => {
const { mockScene } = createMockScene();
const { mockObj, emitPointerDown } = createMockGameObject();
const onClick = vi.fn((obj) => `clicked ${obj}`);
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([mockObj], onClick);
emitPointerDown();
expect(onClick).toHaveBeenCalledWith(mockObj);
expect(onSubmit).toHaveBeenCalledWith(`clicked ${mockObj}`);
});
it('should skip objects without setInteractive', () => {
const { mockScene } = createMockScene();
const nonInteractiveObj = {} as Phaser.GameObjects.GameObject;
const mapper = new InputMapper(mockScene, options);
// Should not throw
expect(() => {
mapper.mapObjectClick([nonInteractiveObj], vi.fn());
}).not.toThrow();
});
it('should not call onSubmit when onClick returns null', () => {
const { mockScene } = createMockScene();
const { mockObj, emitPointerDown } = createMockGameObject();
const onClick = vi.fn(() => null);
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([mockObj], onClick);
emitPointerDown();
expect(onClick).toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
});
describe('destroy', () => {
it('should remove pointerdown listener from mapGridClick', () => {
const { mockScene } = createMockScene();
const mapper = new InputMapper(mockScene, options);
mapper.mapGridClick(
{ x: 50, y: 50 },
{ x: 100, y: 100 },
{ cols: 3, rows: 3 },
vi.fn(() => null),
);
mapper.destroy();
expect(mockScene.input.off).toHaveBeenCalledWith('pointerdown', expect.any(Function));
});
it('should remove object listeners from mapObjectClick', () => {
const { mockScene } = createMockScene();
const { mockObj } = createMockGameObject();
const mapper = new InputMapper(mockScene, options);
mapper.mapObjectClick([mockObj], vi.fn(() => null));
mapper.destroy();
expect(mockObj.off).toHaveBeenCalledWith('pointerdown', expect.any(Function));
});
it('should be safe to call without any mappings', () => {
const { mockScene } = createMockScene();
const mapper = new InputMapper(mockScene, options);
expect(() => mapper.destroy()).not.toThrow();
});
});
});

View File

@ -1,93 +0,0 @@
import Phaser from 'phaser';
export interface InputMapperOptions {
onSubmit: (input: string) => string | null;
}
export class InputMapper {
private scene: Phaser.Scene;
private onSubmit: (input: string) => string | null;
private pointerDownCallback: ((pointer: Phaser.Input.Pointer) => void) | null = null;
/** Track interactive objects registered via mapObjectClick for cleanup */
private trackedObjects: Array<{
obj: Phaser.GameObjects.GameObject;
handler: () => void;
}> = [];
constructor(scene: Phaser.Scene, options: InputMapperOptions) {
this.scene = scene;
this.onSubmit = options.onSubmit;
}
mapGridClick(
cellSize: { x: number; y: number },
offset: { x: number; y: number },
gridDimensions: { cols: number; rows: number },
onCellClick: (col: number, row: number) => string | null,
): void {
const pointerDown = (pointer: Phaser.Input.Pointer) => {
const localX = pointer.x - offset.x;
const localY = pointer.y - offset.y;
if (localX < 0 || localY < 0) return;
const col = Math.floor(localX / cellSize.x);
const row = Math.floor(localY / cellSize.y);
if (col < 0 || col >= gridDimensions.cols || row < 0 || row >= gridDimensions.rows) return;
const cmd = onCellClick(col, row);
if (cmd) {
this.onSubmit(cmd);
}
};
this.pointerDownCallback = pointerDown;
this.scene.input.on('pointerdown', pointerDown);
}
mapObjectClick<T>(
gameObjects: Phaser.GameObjects.GameObject[],
onClick: (obj: T) => string | null,
): void {
for (const obj of gameObjects) {
if ('setInteractive' in obj && typeof (obj as any).setInteractive === 'function') {
const interactiveObj = obj as any;
interactiveObj.setInteractive({ useHandCursor: true });
const handler = () => {
const cmd = onClick(obj as unknown as T);
if (cmd) {
this.onSubmit(cmd);
}
};
interactiveObj.on('pointerdown', handler);
this.trackedObjects.push({ obj, handler });
}
}
}
destroy(): void {
// Remove global pointerdown listener from mapGridClick
if (this.pointerDownCallback) {
this.scene.input.off('pointerdown', this.pointerDownCallback);
this.pointerDownCallback = null;
}
// Remove per-object pointerdown listeners from mapObjectClick
for (const { obj, handler } of this.trackedObjects) {
if ('off' in obj && typeof (obj as any).off === 'function') {
(obj as any).off('pointerdown', handler);
}
}
this.trackedObjects = [];
}
}
export function createInputMapper(
scene: Phaser.Scene,
options: InputMapperOptions,
): InputMapper {
return new InputMapper(scene, options);
}

View File

@ -1,270 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PromptHandler, type PromptHandlerOptions } from './PromptHandler.js';
import type { PromptEvent } from 'boardgame-core';
// 内联 AsyncQueue 实现用于测试(避免导入 boardgame-core 内部模块)
class AsyncQueue<T> {
private items: T[] = [];
private resolvers: ((value: T) => void)[] = [];
push(item: T): void {
if (this.resolvers.length > 0) {
const resolve = this.resolvers.shift()!;
resolve(item);
} else {
this.items.push(item);
}
}
pushAll(items: Iterable<T>): void {
for (const item of items) {
this.push(item);
}
}
async pop(): Promise<T> {
if (this.items.length > 0) {
return this.items.shift()!;
}
return new Promise<T>((resolve) => {
this.resolvers.push(resolve);
});
}
get length(): number {
return this.items.length - this.resolvers.length;
}
}
// Mock types
interface MockCommandContext {
commands: {
promptQueue: AsyncQueue<PromptEvent>;
};
}
function createMockPromptEvent(
schema: any = { name: 'test', params: [] },
tryCommitImpl?: (input: string | any) => string | null,
): PromptEvent {
return {
schema,
tryCommit: tryCommitImpl ?? vi.fn(() => null),
cancel: vi.fn(),
};
}
function createMockCommands(): MockCommandContext['commands'] {
return {
promptQueue: new AsyncQueue<PromptEvent>(),
};
}
describe('PromptHandler', () => {
let options: PromptHandlerOptions;
let mockCommands: MockCommandContext['commands'];
let onPrompt: ReturnType<typeof vi.fn>;
let onCancel: ReturnType<typeof vi.fn>;
beforeEach(() => {
onPrompt = vi.fn();
onCancel = vi.fn();
mockCommands = createMockCommands();
options = {
commands: mockCommands as any,
onPrompt,
onCancel,
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create instance with options', () => {
const handler = new PromptHandler(options);
expect(handler).toBeDefined();
});
});
describe('createPromptHandler', () => {
it('should create instance via factory', async () => {
const { createPromptHandler } = await import('./PromptHandler.js');
const handler = createPromptHandler(options);
expect(handler).toBeDefined();
});
});
describe('start', () => {
it('should listen for prompts when started', async () => {
const handler = new PromptHandler(options);
handler.start();
const mockEvent = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent);
// Wait for async operation
await new Promise(resolve => setTimeout(resolve, 10));
expect(onPrompt).toHaveBeenCalledWith(mockEvent);
});
it('should not listen when not started', async () => {
const handler = new PromptHandler(options);
// Don't call start()
const mockEvent = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
expect(onPrompt).not.toHaveBeenCalled();
});
});
describe('submit', () => {
it('should submit to active prompt successfully', async () => {
const handler = new PromptHandler(options);
handler.start();
const mockEvent = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
const result = handler.submit('test input');
expect(mockEvent.tryCommit).toHaveBeenCalledWith('test input');
expect(result).toBeNull();
});
it('should return error when submit fails', async () => {
const mockEvent = createMockPromptEvent(
{ name: 'test', params: [] },
vi.fn(() => 'Invalid input'),
);
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
const result = handler.submit('bad input');
expect(result).toBe('Invalid input');
expect(onPrompt).toHaveBeenCalledWith(mockEvent);
});
it('should store input as pending when no active prompt', () => {
const handler = new PromptHandler(options);
// Don't start, so no active prompt
const result = handler.submit('pending input');
expect(result).toBeNull();
});
it('should auto-submit pending input when new prompt arrives', async () => {
const tryCommitFn = vi.fn(() => null);
const mockEvent = createMockPromptEvent(
{ name: 'test', params: [] },
tryCommitFn,
);
const handler = new PromptHandler(options);
// Submit before starting (pending input)
handler.submit('pending input');
// Start and push event
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
expect(tryCommitFn).toHaveBeenCalledWith('pending input');
expect(onPrompt).not.toHaveBeenCalled(); // Auto-submitted, no UI needed
});
});
describe('cancel', () => {
it('should cancel active prompt', async () => {
const mockEvent = createMockPromptEvent();
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
handler.cancel('user cancelled');
expect(mockEvent.cancel).toHaveBeenCalledWith('user cancelled');
expect(onCancel).toHaveBeenCalledWith('user cancelled');
});
it('should call onCancel without active prompt', () => {
const handler = new PromptHandler(options);
handler.cancel('no prompt');
expect(onCancel).toHaveBeenCalledWith('no prompt');
});
});
describe('stop', () => {
it('should stop listening for new prompts after stop', async () => {
const handler = new PromptHandler(options);
handler.start();
// Push first event, it should be handled
const mockEvent1 = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent1);
await new Promise(resolve => setTimeout(resolve, 10));
expect(onPrompt).toHaveBeenCalledWith(mockEvent1);
onPrompt.mockClear();
// Now stop
handler.stop();
// Push another event, should not be handled
const mockEvent2 = createMockPromptEvent();
mockCommands.promptQueue.push(mockEvent2);
await new Promise(resolve => setTimeout(resolve, 10));
// The second event should not trigger onPrompt
// (Note: due to async nature, we check it wasn't called AFTER stop)
expect(onPrompt).not.toHaveBeenCalledWith(mockEvent2);
});
it('should cancel active prompt on stop', async () => {
const mockEvent = createMockPromptEvent();
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
handler.stop();
expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped');
});
});
describe('destroy', () => {
it('should call stop', async () => {
const mockEvent = createMockPromptEvent();
const handler = new PromptHandler(options);
handler.start();
mockCommands.promptQueue.push(mockEvent);
await new Promise(resolve => setTimeout(resolve, 10));
handler.destroy();
expect(mockEvent.cancel).toHaveBeenCalledWith('Handler stopped');
});
});
});

View File

@ -1,102 +0,0 @@
import type { IGameContext, PromptEvent } from 'boardgame-core';
export interface PromptHandlerOptions {
commands: IGameContext<any>['commands'];
onPrompt: (prompt: PromptEvent) => void;
onCancel: (reason?: string) => void;
}
export class PromptHandler {
private commands: IGameContext<any>['commands'];
private onPrompt: (prompt: PromptEvent) => void;
private onCancel: (reason?: string) => void;
private activePrompt: PromptEvent | null = null;
private isListening = false;
private pendingInput: string | null = null;
constructor(options: PromptHandlerOptions) {
this.commands = options.commands;
this.onPrompt = options.onPrompt;
this.onCancel = options.onCancel;
}
start(): void {
this.isListening = true;
this.listenForPrompt();
}
private listenForPrompt(): void {
if (!this.isListening) return;
this.commands.promptQueue.pop()
.then((promptEvent) => {
this.activePrompt = promptEvent;
// 如果有等待的输入,自动提交
if (this.pendingInput) {
const input = this.pendingInput;
this.pendingInput = null;
const error = this.activePrompt.tryCommit(input);
if (error === null) {
this.activePrompt = null;
this.listenForPrompt();
} else {
// 提交失败,把 prompt 交给 UI 显示错误
this.onPrompt(promptEvent);
}
return;
}
this.onPrompt(promptEvent);
})
.catch((reason) => {
this.activePrompt = null;
this.onCancel(reason?.message || 'Cancelled');
});
}
/**
* Submit an input string to the current prompt.
* @returns null on success (input accepted), error string on validation failure
*/
submit(input: string): string | null {
if (!this.activePrompt) {
// 没有活跃 prompt保存为待处理输入
this.pendingInput = input;
return null;
}
const error = this.activePrompt.tryCommit(input);
if (error === null) {
this.activePrompt = null;
this.listenForPrompt();
}
return error;
}
cancel(reason?: string): void {
if (this.activePrompt) {
this.activePrompt.cancel(reason);
this.activePrompt = null;
}
this.onCancel(reason);
}
stop(): void {
this.isListening = false;
if (this.activePrompt) {
this.activePrompt.cancel('Handler stopped');
this.activePrompt = null;
}
}
destroy(): void {
this.stop();
}
}
export function createPromptHandler(
options: PromptHandlerOptions,
): PromptHandler {
return new PromptHandler(options);
}

View File

@ -1,11 +0,0 @@
export {
InputMapper,
createInputMapper,
type InputMapperOptions,
} from './InputMapper.js';
export {
PromptHandler,
createPromptHandler,
type PromptHandlerOptions,
} from './PromptHandler.js';

View File

@ -1,53 +0,0 @@
import Phaser from 'phaser';
import { effect } from '@preact/signals-core';
import type { MutableSignal, IGameContext, CommandResult } from 'boardgame-core';
type DisposeFn = () => void;
export interface ReactiveSceneOptions<TState extends Record<string, unknown>> {
gameContext: IGameContext<TState>;
}
export abstract class ReactiveScene<TState extends Record<string, unknown>> extends Phaser.Scene {
protected gameContext!: IGameContext<TState>;
protected state!: MutableSignal<TState>;
protected commands!: IGameContext<TState>['commands'];
private effects: DisposeFn[] = [];
constructor(key: string) {
super(key);
}
init(data: ReactiveSceneOptions<TState>): void {
this.gameContext = data.gameContext;
this.state = data.gameContext.state;
this.commands = data.gameContext.commands;
}
protected watch(fn: () => void): DisposeFn {
const e = effect(fn);
this.effects.push(e);
return e;
}
protected async runCommand<T = unknown>(input: string): Promise<CommandResult<T>> {
return this.commands.run<T>(input);
}
create(): void {
this.events.on('shutdown', this.cleanupEffects, this);
this.onStateReady(this.state.value);
this.setupBindings();
}
private cleanupEffects(): void {
for (const e of this.effects) {
e();
}
this.effects = [];
}
protected abstract onStateReady(state: TState): void;
protected abstract setupBindings(): void;
}

View File

@ -1,33 +0,0 @@
import { h } from 'preact';
import { Signal } from '@preact/signals-core';
interface CommandLogProps {
entries: Signal<Array<{ input: string; result: string; timestamp: number }>>;
maxEntries?: number;
}
export function CommandLog({ entries, maxEntries = 50 }: CommandLogProps) {
const displayEntries = entries.value.slice(-maxEntries).reverse();
return (
<div className="bg-gray-900 text-green-400 font-mono text-xs p-3 rounded-lg overflow-y-auto max-h-48">
{displayEntries.length === 0 ? (
<div className="text-gray-500 italic">No commands yet</div>
) : (
<div className="space-y-1">
{displayEntries.map((entry, i) => (
<div key={entry.timestamp + '-' + i} className="flex gap-2">
<span className="text-gray-500">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="text-yellow-300">&gt; {entry.input}</span>
<span className={entry.result.startsWith('OK') ? 'text-green-400' : 'text-red-400'}>
{entry.result}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@ -1,120 +0,0 @@
import { h } from 'preact';
import { useState, useCallback } from 'preact/hooks';
import type { PromptEvent, CommandSchema, CommandParamSchema } from 'boardgame-core';
interface PromptDialogProps {
prompt: PromptEvent | null;
onSubmit: (input: string) => string | null | void;
onCancel: () => void;
}
function schemaToPlaceholder(schema: CommandSchema): string {
const parts: string[] = [schema.name];
for (const param of schema.params) {
if (param.required) {
parts.push(`<${param.name}>`);
} else {
parts.push(`[${param.name}]`);
}
}
return parts.join(' ');
}
function schemaToFields(schema: CommandSchema): Array<{ param: CommandParamSchema; label: string }> {
return schema.params
.filter(p => p.required)
.map(p => ({ param: p, label: p.name }));
}
export function PromptDialog({ prompt, onSubmit, onCancel }: PromptDialogProps) {
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const handleSubmit = useCallback(() => {
if (!prompt) return;
const fieldValues = schemaToFields(prompt.schema).map(f => values[f.label] || '');
const cmdString = [prompt.schema.name, ...fieldValues].join(' ');
const err = onSubmit(cmdString);
if (err != null) {
setError(err);
} else {
setValues({});
setError(null);
}
}, [prompt, values, onSubmit]);
const handleCancel = useCallback(() => {
onCancel();
setValues({});
setError(null);
}, [onCancel]);
if (!prompt) return null;
const fields = schemaToFields(prompt.schema);
const placeholder = schemaToPlaceholder(prompt.schema);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 min-w-[320px] max-w-md">
<h3 className="text-lg font-semibold mb-4 text-gray-800">{prompt.schema.name}</h3>
<p className="text-sm text-gray-500 mb-4 font-mono">{placeholder}</p>
{fields.length > 0 ? (
<div className="space-y-3 mb-4">
{fields.map(({ param, label }, index) => (
<div key={label}>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label}
</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={values[label] || ''}
onInput={(e) => setValues(prev => ({ ...prev, [label]: (e.target as HTMLInputElement).value }))}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
autoFocus={index === 0}
/>
</div>
))}
</div>
) : (
<div className="mb-4">
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter command..."
onInput={(e) => {
const val = (e.target as HTMLInputElement).value;
setValues({ _raw: val });
}}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit(); }}
autoFocus
/>
</div>
)}
{error && (
<p className="text-sm text-red-600 mb-3">{error}</p>
)}
<div className="flex gap-2 justify-end">
<button
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200"
onClick={handleCancel}
>
Cancel
</button>
<button
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
onClick={handleSubmit}
>
Submit
</button>
</div>
</div>
</div>
);
}