From 433ea41882ea45e1d0a2b2b5d598a6dcf6b3e04f Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Thu, 2 Jul 2026 04:16:43 +0530 Subject: [PATCH 01/27] feat: add command schema types and zero-dep validator (#942) Signed-off-by: rx18-eng --- .../command-registry/command.model.ts | 27 ++++++ .../command-registry/schema-validator.ts | 84 +++++++++++++++++++ .../command-registry/schema-validator.test.ts | 44 ++++++++++ 3 files changed, 155 insertions(+) create mode 100644 packages/phoenix-event-display/src/managers/command-registry/command.model.ts create mode 100644 packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts create mode 100644 packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts diff --git a/packages/phoenix-event-display/src/managers/command-registry/command.model.ts b/packages/phoenix-event-display/src/managers/command-registry/command.model.ts new file mode 100644 index 000000000..e5d81d912 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/command-registry/command.model.ts @@ -0,0 +1,27 @@ +/** JSON-Schema (2020-12 subset) description of one command parameter. */ +export interface CommandProperty { + /** JSON type of the parameter. */ + type: 'string' | 'number' | 'integer' | 'boolean'; + /** Human-readable description shown in the palette and NL prompt. */ + description?: string; + /** Allowed static values. */ + enum?: (string | number)[]; + /** Name of a live source that fills `enum` at runtime, resolved by later phases. */ + enumSource?: 'collections' | 'geometryParts' | 'presetViews' | 'eventKeys'; + /** Inclusive lower bound for numeric parameters. */ + minimum?: number; + /** Inclusive upper bound for numeric parameters. */ + maximum?: number; +} + +/** JSON-Schema (2020-12 subset) for a command's arguments object. */ +export interface CommandParamSchema { + /** Always 'object' for command arguments. */ + type: 'object'; + /** Parameter definitions keyed by parameter name. */ + properties: Record; + /** Names of required parameters. */ + required?: string[]; + /** When false, unknown parameters are rejected. */ + additionalProperties?: false; +} diff --git a/packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts b/packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts new file mode 100644 index 000000000..b4dfccc90 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts @@ -0,0 +1,84 @@ +import type { CommandParamSchema } from './command.model'; + +/** Result of validating command arguments against a schema. */ +export interface ValidationResult { + /** True when the arguments satisfy the schema. */ + valid: boolean; + /** Human-readable reason when invalid. */ + error?: string; +} + +/** + * Validate an arguments object against a command's JSON-Schema subset. + * Supports type (string/number/integer/boolean), required, enum, minimum, + * maximum, and additionalProperties:false. Zero dependencies. + * @param schema The command's input schema. + * @param args The arguments to validate. + * @returns Whether the arguments are valid, with an error message if not. + */ +export function validateArgs( + schema: CommandParamSchema, + args: unknown, +): ValidationResult { + if (typeof args !== 'object' || args === null || Array.isArray(args)) { + return { valid: false, error: 'arguments must be an object' }; + } + const obj = args as Record; + const props = schema.properties ?? {}; + + if (schema.additionalProperties === false) { + for (const key of Object.keys(obj)) { + if (!(key in props)) { + return { valid: false, error: `unknown parameter '${key}'` }; + } + } + } + + for (const name of schema.required ?? []) { + if (obj[name] === undefined) { + return { valid: false, error: `missing required parameter '${name}'` }; + } + } + + for (const [name, prop] of Object.entries(props)) { + const value = obj[name]; + if (value === undefined) continue; + + const typeOk = + prop.type === 'integer' + ? typeof value === 'number' && Number.isInteger(value) + : prop.type === 'number' + ? typeof value === 'number' + : typeof value === prop.type; + if (!typeOk) { + return { + valid: false, + error: `parameter '${name}' must be ${prop.type}`, + }; + } + + if (prop.enum && !prop.enum.includes(value as string | number)) { + return { + valid: false, + error: `parameter '${name}' must be one of: ${prop.enum.join(', ')}`, + }; + } + + if (typeof value === 'number') { + if (prop.minimum !== undefined && value < prop.minimum) { + return { + valid: false, + error: `parameter '${name}' must be >= ${prop.minimum}`, + }; + } + if (prop.maximum !== undefined && value > prop.maximum) { + return { + valid: false, + error: `parameter '${name}' must be <= ${prop.maximum}`, + }; + } + } + } + + return { valid: true }; +} diff --git a/packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts b/packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts new file mode 100644 index 000000000..15d5a2ba1 --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts @@ -0,0 +1,44 @@ +import { validateArgs } from '../../../managers/command-registry/schema-validator'; +import type { CommandParamSchema } from '../../../managers/command-registry/command.model'; + +const schema: CommandParamSchema = { + type: 'object', + properties: { + dark: { type: 'boolean' }, + index: { type: 'integer', minimum: 0 }, + dir: { type: 'string', enum: ['in', 'out'] }, + }, + required: ['dark'], + additionalProperties: false, +}; + +describe('validateArgs', () => { + it('accepts valid args', () => { + expect(validateArgs(schema, { dark: true, index: 2, dir: 'in' })).toEqual({ + valid: true, + }); + }); + it('rejects a missing required param', () => { + expect(validateArgs(schema, { index: 1 }).valid).toBe(false); + }); + it('rejects a wrong type', () => { + expect(validateArgs(schema, { dark: 'yes' }).valid).toBe(false); + }); + it('rejects a non-integer for integer', () => { + expect(validateArgs(schema, { dark: true, index: 1.5 }).valid).toBe(false); + }); + it('rejects a value outside the enum', () => { + expect(validateArgs(schema, { dark: true, dir: 'sideways' }).valid).toBe( + false, + ); + }); + it('rejects a number below minimum', () => { + expect(validateArgs(schema, { dark: true, index: -1 }).valid).toBe(false); + }); + it('rejects unknown params when additionalProperties is false', () => { + expect(validateArgs(schema, { dark: true, bogus: 1 }).valid).toBe(false); + }); + it('rejects non-object args', () => { + expect(validateArgs(schema, 'nope' as unknown).valid).toBe(false); + }); +}); From 3a48e9c533acc8bf0dea0f2a1157812f0fdc8996 Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Thu, 2 Jul 2026 04:18:07 +0530 Subject: [PATCH 02/27] feat: add CommandRegistry with schema-validated execute (#942) Signed-off-by: rx18-eng --- .../managers/command-registry/command-host.ts | 23 +++++ .../command-registry/command-registry.ts | 85 ++++++++++++++++ .../command-registry/command.model.ts | 35 +++++++ .../command-registry/command-registry.test.ts | 98 +++++++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 packages/phoenix-event-display/src/managers/command-registry/command-host.ts create mode 100644 packages/phoenix-event-display/src/managers/command-registry/command-registry.ts create mode 100644 packages/phoenix-event-display/src/tests/managers/command-registry/command-registry.test.ts diff --git a/packages/phoenix-event-display/src/managers/command-registry/command-host.ts b/packages/phoenix-event-display/src/managers/command-registry/command-host.ts new file mode 100644 index 000000000..d41c5977d --- /dev/null +++ b/packages/phoenix-event-display/src/managers/command-registry/command-host.ts @@ -0,0 +1,23 @@ +/** + * Adapter giving command handlers access to the live Phoenix instance. + * Built by EventDisplay.buildCommandHost(), mirroring buildSessionHost(). + */ +export interface CommandHost { + /** The EventDisplay facade. */ + eventDisplay: any; + /** The UI manager (theme, views, geometry visibility, clipping, axis). */ + ui: any; + /** The three.js manager (camera projection, scene). */ + three: any; + /** The state manager (camera/clipping/menu/cut state). */ + state: any; + /** Emit an event on the Phoenix event bus. */ + emit: (eventName: string, data?: any) => void; + /** Resolve an object's uuid from its collection name and row index. */ + resolveObject: ( + collection: string, + index: number, + ) => { uuid: string } | undefined; + /** Names of the geometry parts available for the 'part' enum. */ + listGeometryParts: () => string[]; +} diff --git a/packages/phoenix-event-display/src/managers/command-registry/command-registry.ts b/packages/phoenix-event-display/src/managers/command-registry/command-registry.ts new file mode 100644 index 000000000..30b96e5ff --- /dev/null +++ b/packages/phoenix-event-display/src/managers/command-registry/command-registry.ts @@ -0,0 +1,85 @@ +import type { Command, CommandResult, McpToolShape } from './command.model'; +import type { CommandHost } from './command-host'; +import { validateArgs } from './schema-validator'; + +/** Event name emitted on the Phoenix bus after a command runs. */ +export const COMMAND_EXECUTED_EVENT = 'command-executed'; + +/** + * Registry of named, self-describing commands over the Phoenix API. + * Framework-agnostic and dependency-free. Arguments are validated against + * each command's input schema before running; 'command-executed' is emitted + * on the host bus after a successful run. + */ +export class CommandRegistry { + /** Registered commands keyed by name. */ + private commands = new Map(); + + /** + * @param host Adapter bridging commands to the live Phoenix instance. + */ + constructor(private readonly host: CommandHost) {} + + /** + * Register a command. A later registration with the same name replaces it. + * @param command The command to register. + */ + register(command: Command): void { + this.commands.set(command.name, command); + } + + /** + * Get a command by name. + * @param name The command name. + * @returns The command, or undefined if not registered. + */ + get(name: string): Command | undefined { + return this.commands.get(name); + } + + /** + * List all registered commands in name order (deterministic). + * @returns The registered commands sorted by name. + */ + list(): Command[] { + return [...this.commands.values()].sort((a, b) => + a.name.localeCompare(b.name), + ); + } + + /** + * Describe the registered commands as MCP-style tool shapes. + * @returns One tool shape per command, in name order. + */ + toToolSchemas(): McpToolShape[] { + return this.list().map((c) => ({ + name: c.name, + description: c.description, + inputSchema: c.inputSchema, + })); + } + + /** + * Validate and run a command, emitting 'command-executed' on success. + * @param name The command name. + * @param args The command arguments (defaults to an empty object). + * @returns Ok with an optional result, or an error message. + */ + async execute(name: string, args: unknown = {}): Promise { + const command = this.commands.get(name); + if (!command) { + return { ok: false, error: `unknown command '${name}'` }; + } + const validation = validateArgs(command.inputSchema, args); + if (!validation.valid) { + return { ok: false, error: validation.error ?? 'invalid arguments' }; + } + try { + const result = await command.run(args, this.host); + this.host.emit(COMMAND_EXECUTED_EVENT, { name, args, result }); + return { ok: true, result }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + } +} diff --git a/packages/phoenix-event-display/src/managers/command-registry/command.model.ts b/packages/phoenix-event-display/src/managers/command-registry/command.model.ts index e5d81d912..ba5757379 100644 --- a/packages/phoenix-event-display/src/managers/command-registry/command.model.ts +++ b/packages/phoenix-event-display/src/managers/command-registry/command.model.ts @@ -1,3 +1,5 @@ +import type { CommandHost } from './command-host'; + /** JSON-Schema (2020-12 subset) description of one command parameter. */ export interface CommandProperty { /** JSON type of the parameter. */ @@ -25,3 +27,36 @@ export interface CommandParamSchema { /** When false, unknown parameters are rejected. */ additionalProperties?: false; } + +/** Result of executing a command. */ +export type CommandResult = + | { ok: true; result?: any } + | { ok: false; error: string }; + +/** A named, self-describing action over the Phoenix API. */ +export interface Command { + /** Unique id, MCP charset [A-Za-z0-9_.-], e.g. 'next-event'. */ + name: string; + /** Optional human-readable label for the palette. */ + title?: string; + /** What the command does (palette + NL prompt). */ + description: string; + /** Grouping category, e.g. 'Navigation'. */ + category: string; + /** JSON-Schema for the arguments. */ + inputSchema: CommandParamSchema; + /** True if the command changes the view (needs confirmation); false for read-only queries. */ + mutates: boolean; + /** Perform the command against the live host. */ + run: (args: A, host: CommandHost) => any; +} + +/** MCP-style tool description derived from a Command. */ +export interface McpToolShape { + /** Tool name (the command name). */ + name: string; + /** Human-readable description. */ + description: string; + /** JSON-Schema for the parameters. */ + inputSchema: CommandParamSchema; +} diff --git a/packages/phoenix-event-display/src/tests/managers/command-registry/command-registry.test.ts b/packages/phoenix-event-display/src/tests/managers/command-registry/command-registry.test.ts new file mode 100644 index 000000000..bebfe53cb --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/command-registry/command-registry.test.ts @@ -0,0 +1,98 @@ +import { + CommandRegistry, + COMMAND_EXECUTED_EVENT, +} from '../../../managers/command-registry/command-registry'; +import type { CommandHost } from '../../../managers/command-registry/command-host'; +import type { Command } from '../../../managers/command-registry/command.model'; + +function fakeHost(): CommandHost & { emit: jest.Mock } { + return { + eventDisplay: {}, + ui: {}, + three: {}, + state: {}, + emit: jest.fn(), + resolveObject: () => undefined, + listGeometryParts: () => [], + }; +} + +const ping: Command = { + name: 'ping', + description: 'test', + category: 'Test', + inputSchema: { + type: 'object', + properties: { n: { type: 'integer' } }, + required: ['n'], + additionalProperties: false, + }, + mutates: false, + run: (a: { n: number }) => a.n * 2, +}; + +describe('CommandRegistry', () => { + it('registers, gets, and lists in name order', () => { + const r = new CommandRegistry(fakeHost()); + r.register({ ...ping, name: 'b' }); + r.register({ ...ping, name: 'a' }); + expect(r.get('a')?.name).toBe('a'); + expect(r.list().map((c) => c.name)).toEqual(['a', 'b']); + }); + + it('describes commands as MCP tool shapes', () => { + const r = new CommandRegistry(fakeHost()); + r.register(ping); + expect(r.toToolSchemas()[0]).toEqual({ + name: 'ping', + description: 'test', + inputSchema: ping.inputSchema, + }); + }); + + it('rejects an unknown command', async () => { + const r = new CommandRegistry(fakeHost()); + expect(await r.execute('nope')).toEqual({ + ok: false, + error: "unknown command 'nope'", + }); + }); + + it('rejects invalid args and does not emit', async () => { + const host = fakeHost(); + const r = new CommandRegistry(host); + r.register(ping); + const res = await r.execute('ping', { n: 'x' }); + expect(res.ok).toBe(false); + expect(host.emit).not.toHaveBeenCalled(); + }); + + it('runs a valid command, returns its result, and emits command-executed', async () => { + const host = fakeHost(); + const r = new CommandRegistry(host); + r.register(ping); + const res = await r.execute('ping', { n: 3 }); + expect(res).toEqual({ ok: true, result: 6 }); + expect(host.emit).toHaveBeenCalledWith(COMMAND_EXECUTED_EVENT, { + name: 'ping', + args: { n: 3 }, + result: 6, + }); + }); + + it('returns ok:false when a handler throws', async () => { + const host = fakeHost(); + const r = new CommandRegistry(host); + r.register({ + ...ping, + name: 'boom', + run: () => { + throw new Error('kaboom'); + }, + }); + expect(await r.execute('boom', { n: 1 })).toEqual({ + ok: false, + error: 'kaboom', + }); + }); +}); From 80e6510390436b1de31395d1c9e6a302a40a20dd Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Thu, 2 Jul 2026 04:23:52 +0530 Subject: [PATCH 03/27] feat: add default command set wrapping existing Phoenix actions (#942) Signed-off-by: rx18-eng --- .../command-registry/default-commands.ts | 288 ++++++++++++++++++ .../command-registry/default-commands.test.ts | 115 +++++++ 2 files changed, 403 insertions(+) create mode 100644 packages/phoenix-event-display/src/managers/command-registry/default-commands.ts create mode 100644 packages/phoenix-event-display/src/tests/managers/command-registry/default-commands.test.ts diff --git a/packages/phoenix-event-display/src/managers/command-registry/default-commands.ts b/packages/phoenix-event-display/src/managers/command-registry/default-commands.ts new file mode 100644 index 000000000..25ad62d18 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/command-registry/default-commands.ts @@ -0,0 +1,288 @@ +import type { Command, CommandParamSchema } from './command.model'; +import type { CommandRegistry } from './command-registry'; + +/** No-argument object schema shared by parameterless commands. */ +const NO_ARGS: CommandParamSchema = { + type: 'object', + properties: {}, + additionalProperties: false, +}; + +/** A collection-name + row-index schema for object-addressed commands. */ +const OBJECT_REF: CommandParamSchema = { + type: 'object', + properties: { + collection: { + type: 'string', + description: 'Collection name', + enumSource: 'collections', + }, + index: { + type: 'integer', + description: 'Row index within the collection', + minimum: 0, + }, + }, + required: ['collection', 'index'], + additionalProperties: false, +}; + +/** + * Build the generic, experiment-agnostic v1 command set. Every command wraps + * an existing deterministic Phoenix method; object args use (collection,index) + * and mutating view state goes through UIManager to stay state-synced. + * @returns The default commands. + */ +export function defaultCommands(): Command[] { + return [ + { + name: 'next-event', + title: 'Next event', + description: 'Load the next event in the dataset.', + category: 'Navigation', + inputSchema: { ...NO_ARGS }, + mutates: true, + run: (_a, h) => h.eventDisplay.nextEvent(), + }, + { + name: 'previous-event', + title: 'Previous event', + description: 'Load the previous event in the dataset.', + category: 'Navigation', + inputSchema: { ...NO_ARGS }, + mutates: true, + run: (_a, h) => h.eventDisplay.previousEvent(), + }, + { + name: 'load-event', + title: 'Load event', + description: 'Load a specific event by its key.', + category: 'Navigation', + inputSchema: { + type: 'object', + properties: { + eventKey: { + type: 'string', + description: 'Event key', + enumSource: 'eventKeys', + }, + }, + required: ['eventKey'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => h.eventDisplay.loadEvent(a.eventKey), + }, + + { + name: 'set-theme', + title: 'Set theme', + description: 'Switch between dark and light theme.', + category: 'View', + inputSchema: { + type: 'object', + properties: { + dark: { type: 'boolean', description: 'True for dark theme' }, + }, + required: ['dark'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => h.ui.setDarkTheme(a.dark), + }, + { + name: 'preset-view', + title: 'Preset view', + description: 'Snap the camera to a named preset view.', + category: 'View', + inputSchema: { + type: 'object', + properties: { + view: { + type: 'string', + description: 'Preset view name', + enumSource: 'presetViews', + }, + }, + required: ['view'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => { + const match = (h.ui.getPresetViews?.() ?? []).find( + (v: any) => v.name === a.view, + ); + if (!match) throw new Error(`unknown view '${a.view}'`); + h.ui.displayView(match); + }, + }, + { + name: 'zoom', + title: 'Zoom', + description: 'Zoom the camera in or out.', + category: 'View', + inputSchema: { + type: 'object', + properties: { + direction: { + type: 'string', + enum: ['in', 'out'], + description: 'Zoom direction', + }, + }, + required: ['direction'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => + h.eventDisplay.zoomTo(a.direction === 'in' ? 1 / 1.2 : 1.2, 100), + }, + { + name: 'toggle-auto-rotate', + title: 'Auto-rotate', + description: 'Turn camera auto-rotation on or off.', + category: 'View', + inputSchema: { + type: 'object', + properties: { on: { type: 'boolean' } }, + required: ['on'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => h.ui.setAutoRotate(a.on), + }, + { + name: 'set-clipping', + title: 'Clipping', + description: 'Enable or disable the geometry clipping planes.', + category: 'View', + inputSchema: { + type: 'object', + properties: { on: { type: 'boolean' } }, + required: ['on'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => h.ui.setClipping(a.on), + }, + { + name: 'show-axis', + title: 'Show axis', + description: 'Show or hide the XYZ axis helper.', + category: 'View', + inputSchema: { + type: 'object', + properties: { show: { type: 'boolean' } }, + required: ['show'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => h.ui.setShowAxis(a.show), + }, + { + name: 'toggle-camera-projection', + title: 'Camera projection', + description: + 'Switch the main camera between perspective and orthographic.', + category: 'View', + inputSchema: { ...NO_ARGS }, + mutates: true, + run: (_a, h) => h.three.revertMainCamera(), + }, + + { + name: 'set-geometry-visibility', + title: 'Show/hide geometry', + description: 'Show or hide a named detector geometry part.', + category: 'Geometry', + inputSchema: { + type: 'object', + properties: { + part: { + type: 'string', + description: 'Geometry part name', + enumSource: 'geometryParts', + }, + visible: { type: 'boolean' }, + }, + required: ['part', 'visible'], + additionalProperties: false, + }, + mutates: true, + run: (a, h) => h.ui.geometryVisibility(a.part, a.visible), + }, + + { + name: 'highlight-object', + title: 'Highlight object', + description: 'Outline an object selected by collection and row index.', + category: 'Selection', + inputSchema: { ...OBJECT_REF }, + mutates: true, + run: (a, h) => { + const obj = h.resolveObject(a.collection, a.index); + if (!obj) throw new Error(`no object at ${a.collection}[${a.index}]`); + h.eventDisplay.highlightObject(obj.uuid); + }, + }, + { + name: 'look-at-object', + title: 'Look at object', + description: + 'Move the camera to an object selected by collection and row index.', + category: 'Selection', + inputSchema: { ...OBJECT_REF }, + mutates: true, + run: (a, h) => { + const obj = h.resolveObject(a.collection, a.index); + if (!obj) throw new Error(`no object at ${a.collection}[${a.index}]`); + h.eventDisplay.lookAtObject(obj.uuid); + }, + }, + + { + name: 'list-collections', + title: 'List collections', + description: 'List the event-data collections grouped by type.', + category: 'Query', + inputSchema: { ...NO_ARGS }, + mutates: false, + run: (_a, h) => h.eventDisplay.getCollections(), + }, + { + name: 'describe-event', + title: 'Describe event', + description: 'Report the current event key and metadata.', + category: 'Query', + inputSchema: { ...NO_ARGS }, + mutates: false, + run: (_a, h) => ({ + eventKey: h.eventDisplay.getCurrentEventKey(), + metadata: h.eventDisplay.getEventMetadata(), + }), + }, + { + name: 'get-object', + title: 'Get object', + description: + 'Return the raw data of one object by collection and row index.', + category: 'Query', + inputSchema: { ...OBJECT_REF }, + mutates: false, + run: (a, h) => { + const arr = h.eventDisplay.getCollection(a.collection); + return arr?.[a.index]; + }, + }, + ]; +} + +/** + * Register the generic v1 command set on a registry. + * @param registry The registry to populate. + */ +export function registerDefaultCommands(registry: CommandRegistry): void { + for (const command of defaultCommands()) { + registry.register(command); + } +} diff --git a/packages/phoenix-event-display/src/tests/managers/command-registry/default-commands.test.ts b/packages/phoenix-event-display/src/tests/managers/command-registry/default-commands.test.ts new file mode 100644 index 000000000..75704924c --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/command-registry/default-commands.test.ts @@ -0,0 +1,115 @@ +import { CommandRegistry } from '../../../managers/command-registry/command-registry'; +import { + registerDefaultCommands, + defaultCommands, +} from '../../../managers/command-registry/default-commands'; +import type { CommandHost } from '../../../managers/command-registry/command-host'; + +function host() { + const h = { + eventDisplay: { + nextEvent: jest.fn(), + previousEvent: jest.fn(), + loadEvent: jest.fn(), + zoomTo: jest.fn(), + highlightObject: jest.fn(), + lookAtObject: jest.fn(), + getCollections: jest.fn(() => ({ Tracks: ['a'] })), + getCollection: jest.fn(() => [{ uuid: 'u0', pt: 5 }]), + getCurrentEventKey: jest.fn(() => 'evt1'), + getEventMetadata: jest.fn(() => [{ run: 1 }]), + }, + ui: { + setDarkTheme: jest.fn(), + setAutoRotate: jest.fn(), + setClipping: jest.fn(), + setShowAxis: jest.fn(), + geometryVisibility: jest.fn(), + getPresetViews: jest.fn(() => [{ name: 'Front' }]), + displayView: jest.fn(), + }, + three: { revertMainCamera: jest.fn() }, + state: {}, + emit: jest.fn(), + resolveObject: jest.fn((_c: string, i: number) => + i === 0 ? { uuid: 'u0' } : undefined, + ), + listGeometryParts: () => ['LAr Barrel'], + }; + return h as unknown as CommandHost & typeof h; +} + +function reg(h: CommandHost) { + const r = new CommandRegistry(h); + registerDefaultCommands(r); + return r; +} + +describe('default commands', () => { + it('registers a non-trivial generic set', () => { + expect(defaultCommands().length).toBeGreaterThanOrEqual(15); + }); + + it('next-event calls EventDisplay.nextEvent', async () => { + const h = host(); + expect((await reg(h).execute('next-event')).ok).toBe(true); + expect(h.eventDisplay.nextEvent).toHaveBeenCalled(); + }); + + it('set-theme routes through UIManager.setDarkTheme', async () => { + const h = host(); + await reg(h).execute('set-theme', { dark: true }); + expect(h.ui.setDarkTheme).toHaveBeenCalledWith(true); + }); + + it('zoom rejects an invalid direction', async () => { + const h = host(); + expect((await reg(h).execute('zoom', { direction: 'sideways' })).ok).toBe( + false, + ); + }); + + it('preset-view resolves the view by name', async () => { + const h = host(); + await reg(h).execute('preset-view', { view: 'Front' }); + expect(h.ui.displayView).toHaveBeenCalledWith({ name: 'Front' }); + }); + + it('highlight-object resolves collection+index to a uuid', async () => { + const h = host(); + await reg(h).execute('highlight-object', { + collection: 'Tracks', + index: 0, + }); + expect(h.eventDisplay.highlightObject).toHaveBeenCalledWith('u0'); + }); + + it('highlight-object fails when the object is missing', async () => { + const h = host(); + expect( + ( + await reg(h).execute('highlight-object', { + collection: 'Tracks', + index: 9, + }) + ).ok, + ).toBe(false); + }); + + it('set-geometry-visibility routes through UIManager.geometryVisibility', async () => { + const h = host(); + await reg(h).execute('set-geometry-visibility', { + part: 'LAr Barrel', + visible: false, + }); + expect(h.ui.geometryVisibility).toHaveBeenCalledWith('LAr Barrel', false); + }); + + it('list-collections returns the grouped collections', async () => { + const h = host(); + expect(await reg(h).execute('list-collections')).toEqual({ + ok: true, + result: { Tracks: ['a'] }, + }); + }); +}); From 285978a85a9306defd09fa960a740c48b3c74409 Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Thu, 2 Jul 2026 04:27:02 +0530 Subject: [PATCH 04/27] feat: expose getCommandRegistry on EventDisplay (#942) Signed-off-by: rx18-eng --- .../src/event-display.ts | 43 +++++++++++++++++ packages/phoenix-event-display/src/index.ts | 1 + .../src/managers/command-registry/index.ts | 5 ++ .../event-display-command-registry.test.ts | 48 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 packages/phoenix-event-display/src/managers/command-registry/index.ts create mode 100644 packages/phoenix-event-display/src/tests/managers/command-registry/event-display-command-registry.test.ts diff --git a/packages/phoenix-event-display/src/event-display.ts b/packages/phoenix-event-display/src/event-display.ts index ef9586e67..7182fbc7f 100644 --- a/packages/phoenix-event-display/src/event-display.ts +++ b/packages/phoenix-event-display/src/event-display.ts @@ -25,6 +25,11 @@ import { SessionManager, type SessionManagerHost, } from './managers/session-manager'; +import { + CommandRegistry, + registerDefaultCommands, + type CommandHost, +} from './managers/command-registry'; declare global { /** @@ -59,6 +64,8 @@ export class EventDisplay { new Set(); /** Session recorder/player coordinator for #883 (lazy initialized). */ private sessionManager: SessionManager | null = null; + /** Command registry (#942), lazily initialized. */ + private commandRegistry: CommandRegistry | null = null; /** Stored keydown handler for session recording shortcut. */ private sessionRecordKeydownHandler: ((e: KeyboardEvent) => void) | null = null; @@ -289,6 +296,42 @@ export class EventDisplay { }; } + /** + * Get the CommandRegistry (#942): named, schema-described actions over the + * Phoenix API. Lazily instantiated and populated with the default set. + * @returns The CommandRegistry singleton for this EventDisplay. + */ + public getCommandRegistry(): CommandRegistry { + if (!this.commandRegistry) { + this.commandRegistry = new CommandRegistry(this.buildCommandHost()); + registerDefaultCommands(this.commandRegistry); + } + return this.commandRegistry; + } + + /** + * Build the host adapter that bridges command handlers to the live + * EventDisplay (managers, bus, object resolution). + * @returns The command host adapter. + */ + private buildCommandHost(): CommandHost { + return { + eventDisplay: this, + ui: this.getUIManager(), + three: this.getThreeManager(), + state: this.getStateManager(), + emit: (name, data) => this.emit(name, data), + resolveObject: (collection, index) => { + const objects = this.getCollection(collection); + const object = objects?.[index]; + return object?.uuid ? { uuid: object.uuid } : undefined; + }, + // Phase 1 returns an empty list; the command palette (a later phase) + // supplies real part names from the live phoenix-menu geometry tree. + listGeometryParts: () => [], + }; + } + /** * Initialize XR. * @param xrSessionType Type of the XR session. Either AR or VR. diff --git a/packages/phoenix-event-display/src/index.ts b/packages/phoenix-event-display/src/index.ts index 33d9c6bea..1d0d41ff2 100644 --- a/packages/phoenix-event-display/src/index.ts +++ b/packages/phoenix-event-display/src/index.ts @@ -59,3 +59,4 @@ export * from './managers/state-manager'; export * from './managers/loading-manager'; export * from './managers/url-options-manager'; export * from './managers/session-manager'; +export * from './managers/command-registry'; diff --git a/packages/phoenix-event-display/src/managers/command-registry/index.ts b/packages/phoenix-event-display/src/managers/command-registry/index.ts new file mode 100644 index 000000000..5c5d8b133 --- /dev/null +++ b/packages/phoenix-event-display/src/managers/command-registry/index.ts @@ -0,0 +1,5 @@ +export * from './command.model'; +export * from './command-host'; +export * from './schema-validator'; +export * from './command-registry'; +export * from './default-commands'; diff --git a/packages/phoenix-event-display/src/tests/managers/command-registry/event-display-command-registry.test.ts b/packages/phoenix-event-display/src/tests/managers/command-registry/event-display-command-registry.test.ts new file mode 100644 index 000000000..0eb7957cd --- /dev/null +++ b/packages/phoenix-event-display/src/tests/managers/command-registry/event-display-command-registry.test.ts @@ -0,0 +1,48 @@ +/** + * @jest-environment jsdom + */ +import { EventDisplay } from '../../../event-display'; +import { + CommandRegistry, + COMMAND_EXECUTED_EVENT, +} from '../../../managers/command-registry'; + +function makeEventDisplay(): EventDisplay { + const ed = Object.create(EventDisplay.prototype) as EventDisplay; + (ed as any).eventBus = new Map(); + (ed as any).eventBusWildcard = new Set(); + (ed as any).commandRegistry = null; + (ed as any).ui = { setDarkTheme: jest.fn() }; + (ed as any).graphicsLibrary = { revertMainCamera: jest.fn() }; + (ed as any).stateManager = {}; + (ed as any).nextEvent = jest.fn(); + (ed as any).getCollection = jest.fn(() => [{ uuid: 'u0' }, { uuid: 'u1' }]); + return ed; +} + +describe('EventDisplay command registry wiring', () => { + it('returns a singleton registry populated with defaults', () => { + const ed = makeEventDisplay(); + const reg = ed.getCommandRegistry(); + expect(reg).toBeInstanceOf(CommandRegistry); + expect(ed.getCommandRegistry()).toBe(reg); + expect(reg.list().length).toBeGreaterThan(10); + }); + + it('emits command-executed on the real bus when a command runs', async () => { + const ed = makeEventDisplay(); + const seen: any[] = []; + ed.on(COMMAND_EXECUTED_EVENT, (d) => seen.push(d)); + await ed.getCommandRegistry().execute('next-event'); + expect((ed as any).nextEvent).toHaveBeenCalled(); + expect(seen).toHaveLength(1); + expect(seen[0].name).toBe('next-event'); + }); + + it('buildCommandHost.resolveObject maps collection+index to uuid', () => { + const ed = makeEventDisplay(); + const host = (ed as any).buildCommandHost(); + expect(host.resolveObject('Tracks', 1)).toEqual({ uuid: 'u1' }); + expect(host.resolveObject('Tracks', 9)).toBeUndefined(); + }); +}); From a7c1a69c7fc864c77dc254862bca934f83c4e32e Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Thu, 2 Jul 2026 04:47:45 +0530 Subject: [PATCH 05/27] docs: document CommandRegistry host field for 100% doc coverage (#942) Signed-off-by: rx18-eng --- .../src/managers/command-registry/command-registry.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/phoenix-event-display/src/managers/command-registry/command-registry.ts b/packages/phoenix-event-display/src/managers/command-registry/command-registry.ts index 30b96e5ff..f57829dd1 100644 --- a/packages/phoenix-event-display/src/managers/command-registry/command-registry.ts +++ b/packages/phoenix-event-display/src/managers/command-registry/command-registry.ts @@ -15,10 +15,16 @@ export class CommandRegistry { /** Registered commands keyed by name. */ private commands = new Map(); + /** Adapter bridging commands to the live Phoenix instance. */ + private readonly host: CommandHost; + /** + * Create a registry bound to a live-Phoenix host. * @param host Adapter bridging commands to the live Phoenix instance. */ - constructor(private readonly host: CommandHost) {} + constructor(host: CommandHost) { + this.host = host; + } /** * Register a command. A later registration with the same name replaces it. From e50aaefdc74754539f6f3216065b5b601d755005 Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Thu, 2 Jul 2026 04:50:14 +0530 Subject: [PATCH 06/27] fix: reject inherited-name keys in command arg validation (#942) Signed-off-by: rx18-eng --- .../src/managers/command-registry/schema-validator.ts | 2 +- .../tests/managers/command-registry/schema-validator.test.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts b/packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts index b4dfccc90..90abb732d 100644 --- a/packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts +++ b/packages/phoenix-event-display/src/managers/command-registry/schema-validator.ts @@ -28,7 +28,7 @@ export function validateArgs( if (schema.additionalProperties === false) { for (const key of Object.keys(obj)) { - if (!(key in props)) { + if (!Object.prototype.hasOwnProperty.call(props, key)) { return { valid: false, error: `unknown parameter '${key}'` }; } } diff --git a/packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts b/packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts index 15d5a2ba1..474eae519 100644 --- a/packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts +++ b/packages/phoenix-event-display/src/tests/managers/command-registry/schema-validator.test.ts @@ -41,4 +41,9 @@ describe('validateArgs', () => { it('rejects non-object args', () => { expect(validateArgs(schema, 'nope' as unknown).valid).toBe(false); }); + it('rejects an inherited-name key (e.g. __proto__) as unknown', () => { + // JSON.parse yields a real own "__proto__" property, as AI/URL input would. + const args = JSON.parse('{"dark":true,"__proto__":1}'); + expect(validateArgs(schema, args).valid).toBe(false); + }); }); From efcf85c1f4709cbf7b0ad5fd25549a80e1fc4c04 Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Thu, 2 Jul 2026 16:24:13 +0530 Subject: [PATCH 07/27] feat: add Ctrl/Cmd+K command palette over the command registry (#942) Signed-off-by: rx18-eng --- .../playground/playground.component.test.ts | 3 + .../trackml/trackml.component.test.ts | 3 + .../command-palette.component.html | 105 ++++++++ .../command-palette.component.scss | 129 ++++++++++ .../command-palette.component.test.ts | 186 ++++++++++++++ .../command-palette.component.ts | 233 ++++++++++++++++++ .../lib/components/phoenix-ui.module.ts | 2 + .../lib/components/ui-menu/index.ts | 1 + .../ui-menu-wrapper.component.html | 1 + .../ui-menu-wrapper.component.ts | 5 +- .../components/ui-menu/ui-menu.component.html | 2 +- .../components/ui-menu/ui-menu.component.ts | 2 + 12 files changed, 670 insertions(+), 2 deletions(-) create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts diff --git a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts index 4bc06c9d0..f0de2f8af 100644 --- a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/playground/playground.component.test.ts @@ -16,6 +16,9 @@ describe('PlaygroundComponent', () => { getSessionManager: jest.fn().mockReturnValue({ state: { value: { kind: 'idle' }, onUpdate: jest.fn(() => jest.fn()) }, }), + getCommandRegistry: jest.fn().mockReturnValue({ + list: jest.fn(() => []), + }), getLoadingManager: jest.fn().mockReturnThis(), addProgressListener: jest.fn().mockImplementation(() => { component.loadingProgress = 100; diff --git a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts index 91d06ee9e..39b4100db 100644 --- a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/trackml/trackml.component.test.ts @@ -20,6 +20,9 @@ describe('TrackmlComponent', () => { getSessionManager: jest.fn().mockReturnValue({ state: { value: { kind: 'idle' }, onUpdate: jest.fn(() => jest.fn()) }, }), + getCommandRegistry: jest.fn().mockReturnValue({ + list: jest.fn(() => []), + }), getStateManager: jest.fn().mockReturnThis(), clippingEnabled: jest.fn().mockReturnThis(), startClippingAngle: jest.fn().mockReturnThis(), diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html new file mode 100644 index 000000000..e990b11eb --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html @@ -0,0 +1,105 @@ + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss new file mode 100644 index 000000000..79cc4760a --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss @@ -0,0 +1,129 @@ +.cmdp-backdrop { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + background: rgba(0, 0, 0, 0.45); +} + +.cmdp-panel { + width: min(640px, 92vw); + max-height: 70vh; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--phoenix-menu-bg, #1e1e24); + color: var(--phoenix-menu-text, #f5f5f5); + border-radius: 8px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); +} + +.cmdp-input { + width: 100%; + box-sizing: border-box; + padding: 14px 16px; + border: none; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + background: transparent; + color: inherit; + font-size: 16px; + outline: none; +} + +.cmdp-list { + list-style: none; + margin: 0; + padding: 4px 0; + overflow-y: auto; +} + +.cmdp-item { + display: grid; + grid-template-columns: 1fr auto; + grid-template-areas: 'title cat' 'desc desc'; + gap: 2px 8px; + padding: 8px 16px; + cursor: pointer; + + &.selected { + background: rgba(120, 160, 255, 0.18); + } +} + +.cmdp-item-title { + grid-area: title; + font-weight: 600; +} + +.cmdp-item-cat { + grid-area: cat; + font-size: 11px; + opacity: 0.6; + align-self: center; +} + +.cmdp-item-desc { + grid-area: desc; + font-size: 12px; + opacity: 0.7; +} + +.cmdp-empty { + padding: 16px; + opacity: 0.6; + text-align: center; +} + +.cmdp-form-title { + padding: 14px 16px 4px; + font-weight: 600; +} + +.cmdp-field { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 16px; +} + +.cmdp-label { + font-size: 12px; + opacity: 0.8; +} + +.cmdp-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; +} + +.cmdp-btn { + padding: 6px 14px; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + color: inherit; + cursor: pointer; + + &.cmdp-btn-run { + background: rgba(120, 160, 255, 0.35); + border-color: transparent; + } +} + +.cmdp-result { + padding: 10px 16px; + font-size: 13px; + + &.ok { + color: #7fd18b; + } + + &.err { + color: #ff8a8a; + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts new file mode 100644 index 000000000..4586dc111 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts @@ -0,0 +1,186 @@ +import { CommandPaletteComponent } from './command-palette.component'; + +function fakeCommand(name: string, extra: any = {}) { + return { + name, + title: extra.title ?? name, + description: extra.description ?? name, + category: extra.category ?? 'View', + inputSchema: extra.inputSchema ?? { + type: 'object', + properties: {}, + additionalProperties: false, + }, + mutates: extra.mutates ?? true, + run: () => undefined, + }; +} + +function make( + commands = [ + fakeCommand('set-theme', { description: 'dark/light' }), + fakeCommand('next-event', { category: 'Navigation' }), + ], +) { + const registry = { + list: jest.fn(() => commands), + execute: jest.fn(async () => ({ ok: true })), + }; + const eventDisplay: any = { + getCommandRegistry: () => registry, + getCollections: () => ({}), + getUIManager: () => ({ getPresetViews: () => [] }), + getEventsData: () => ({}), + }; + const cdr: any = { detectChanges: jest.fn() }; + const c = new CommandPaletteComponent(eventDisplay, cdr); + c.ngOnInit(); + return { c, registry }; +} + +const key = (over: any) => + ({ preventDefault: jest.fn(), ...over }) as unknown as KeyboardEvent; + +describe('CommandPaletteComponent (keyboard + filter)', () => { + it('Ctrl+K opens and prevents default; again closes', () => { + const { c } = make(); + expect(c.open).toBe(false); + const e1 = key({ ctrlKey: true, altKey: false, key: 'k' }); + c.onDocumentKeydown(e1); + expect(c.open).toBe(true); + expect(e1.preventDefault).toHaveBeenCalled(); + c.onDocumentKeydown(key({ ctrlKey: true, altKey: false, key: 'k' })); + expect(c.open).toBe(false); + }); + + it('Cmd+K also toggles (mac)', () => { + const { c } = make(); + c.onDocumentKeydown(key({ metaKey: true, altKey: false, key: 'k' })); + expect(c.open).toBe(true); + }); + + it('does NOT toggle on Ctrl+Alt+K (AltGr safety)', () => { + const { c } = make(); + c.onDocumentKeydown(key({ ctrlKey: true, altKey: true, key: 'k' })); + expect(c.open).toBe(false); + }); + + it('filters commands by query across name/description', () => { + const { c } = make(); + c.openPalette(); + c.updateQuery('theme'); + expect(c.filtered.map((x) => x.name)).toEqual(['set-theme']); + c.updateQuery(''); + expect(c.filtered.length).toBe(2); + }); + + it('Escape closes when open', () => { + const { c } = make(); + c.openPalette(); + c.onDocumentKeydown(key({ key: 'Escape' })); + expect(c.open).toBe(false); + }); + + it('ArrowDown/Up move selection within bounds', () => { + const { c } = make(); + c.openPalette(); + expect(c.selectedIndex).toBe(0); + c.onDocumentKeydown(key({ key: 'ArrowDown' })); + expect(c.selectedIndex).toBe(1); + c.onDocumentKeydown(key({ key: 'ArrowDown' })); + expect(c.selectedIndex).toBe(1); + c.onDocumentKeydown(key({ key: 'ArrowUp' })); + expect(c.selectedIndex).toBe(0); + }); + + it('ignores navigation keys when closed', () => { + const { c } = make(); + c.onDocumentKeydown(key({ key: 'ArrowDown' })); + expect(c.open).toBe(false); + expect(c.selectedIndex).toBe(0); + }); +}); + +describe('CommandPaletteComponent (choose, params, run)', () => { + it('runs a no-arg command immediately on choose', async () => { + const { c, registry } = make(); + c.openPalette(); + const nav = c.filtered.find((x) => x.name === 'next-event')!; + await c.choose(nav); + expect(registry.execute).toHaveBeenCalledWith('next-event', {}); + }); + + it('opens a param form for a command with parameters (no execute yet)', async () => { + const themed = fakeCommand('set-theme', { + inputSchema: { + type: 'object', + properties: { dark: { type: 'boolean' } }, + required: ['dark'], + additionalProperties: false, + }, + }); + const { c, registry } = make([themed]); + c.openPalette(); + await c.choose(themed); + expect(c.activeCommand?.name).toBe('set-theme'); + expect(registry.execute).not.toHaveBeenCalled(); + }); + + it('resolves enumSource options from the live service', () => { + const cmd = fakeCommand('preset-view', { + inputSchema: { + type: 'object', + properties: { view: { type: 'string', enumSource: 'presetViews' } }, + required: ['view'], + additionalProperties: false, + }, + }); + const { c } = make([cmd]); + (c as any).eventDisplay.getUIManager = () => ({ + getPresetViews: () => [{ name: 'Front' }, { name: 'Side' }], + }); + expect(c.optionsFor(cmd.inputSchema.properties.view)).toEqual([ + 'Front', + 'Side', + ]); + (c as any).eventDisplay.getCollections = () => ({ + Tracks: ['T1'], + Hits: ['CSC'], + }); + expect( + c.optionsFor({ type: 'string', enumSource: 'collections' } as any), + ).toEqual(['T1', 'CSC']); + }); + + it('coerces numeric params from text before executing', async () => { + const cmd = fakeCommand('highlight-object', { + inputSchema: { + type: 'object', + properties: { + collection: { type: 'string' }, + index: { type: 'integer' }, + }, + required: ['collection', 'index'], + additionalProperties: false, + }, + }); + const { c, registry } = make([cmd]); + c.openPalette(); + await c.choose(cmd); + c.paramValues = { collection: 'T1', index: '2' }; + await c.submitParams(); + expect(registry.execute).toHaveBeenCalledWith('highlight-object', { + collection: 'T1', + index: 2, + }); + }); + + it('shows an error result when a command fails', async () => { + const { c, registry } = make(); + registry.execute.mockResolvedValueOnce({ ok: false, error: 'boom' }); + c.openPalette(); + await c.choose(c.filtered.find((x) => x.name === 'next-event')!); + expect(c.result?.ok).toBe(false); + expect(c.result?.text).toContain('boom'); + }); +}); diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts new file mode 100644 index 000000000..75b5cf997 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts @@ -0,0 +1,233 @@ +import { + ChangeDetectorRef, + Component, + HostListener, + type OnInit, +} from '@angular/core'; +import type { + Command, + CommandProperty, + CommandRegistry, +} from 'phoenix-event-display'; +import { EventDisplayService } from '../../services/event-display.service'; + +/** + * Keyboard-triggered command palette (#942). Opens on Ctrl/Cmd+K, lists the + * registered commands, prompts for parameters from each command's inputSchema, + * and runs them through the command registry. Icon-less and self-hiding, so it + * adds zero toolbar footprint and works even when the toolbar is hidden. + */ +@Component({ + standalone: false, + selector: 'app-command-palette', + templateUrl: './command-palette.component.html', + styleUrls: ['./command-palette.component.scss'], +}) +export class CommandPaletteComponent implements OnInit { + /** Whether the palette overlay is visible. */ + open = false; + /** Current search query. */ + query = ''; + /** Commands matching the current query, in registry order. */ + filtered: Command[] = []; + /** Index of the highlighted command in the filtered list. */ + selectedIndex = 0; + /** Command awaiting parameter input, or null when picking from the list. */ + activeCommand: Command | null = null; + /** Working parameter values for the active command's form. */ + paramValues: Record = {}; + /** Last execution result banner, or null. */ + result: { ok: boolean; text: string } | null = null; + + private registry!: CommandRegistry; + + /** + * @param eventDisplay The Phoenix event display service. + * @param cdr Change detector for pushing updates outside Angular events. + */ + constructor( + private eventDisplay: EventDisplayService, + private cdr: ChangeDetectorRef, + ) {} + + /** Cache the command registry once the service is ready. */ + ngOnInit(): void { + this.registry = this.eventDisplay.getCommandRegistry(); + this.filtered = this.registry.list(); + } + + /** + * Global keyboard handler: Ctrl/Cmd+K toggles the palette (excluding AltGr + * combos); Escape closes; Arrow keys and Enter drive the list while open. + * @param event The keydown event. + */ + @HostListener('document:keydown', ['$event']) + onDocumentKeydown(event: KeyboardEvent): void { + if ( + (event.ctrlKey || event.metaKey) && + !event.altKey && + event.key?.toLowerCase() === 'k' + ) { + event.preventDefault(); + this.toggle(); + return; + } + if (!this.open) return; + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); + return; + } + // While a parameter form is open, let its inputs handle keys. + if (this.activeCommand) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + this.selectedIndex = Math.min( + this.selectedIndex + 1, + this.filtered.length - 1, + ); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, 0); + } else if (event.key === 'Enter') { + event.preventDefault(); + const cmd = this.filtered[this.selectedIndex]; + if (cmd) this.choose(cmd); + } + } + + /** Toggle the palette open/closed. */ + toggle(): void { + if (this.open) this.close(); + else this.openPalette(); + } + + /** Open the palette, resetting query and selection. */ + openPalette(): void { + this.open = true; + this.query = ''; + this.result = null; + this.activeCommand = null; + this.selectedIndex = 0; + this.filtered = this.registry.list(); + this.cdr.detectChanges(); + } + + /** Close the palette and clear transient state. */ + close(): void { + this.open = false; + this.activeCommand = null; + this.result = null; + this.cdr.detectChanges(); + } + + /** + * Filter the command list by a query (matches name, title, description). + * @param query The search text. + */ + updateQuery(query: string): void { + this.query = query; + const q = query.trim().toLowerCase(); + this.filtered = this.registry.list().filter((c) => { + if (!q) return true; + return ( + c.name.toLowerCase().includes(q) || + (c.title ?? '').toLowerCase().includes(q) || + c.description.toLowerCase().includes(q) + ); + }); + this.selectedIndex = 0; + } + + /** + * Pick a command: run it immediately when it takes no parameters, otherwise + * open its parameter form. + * @param command The chosen command. + */ + async choose(command: Command): Promise { + this.result = null; + const props = command.inputSchema?.properties ?? {}; + if (Object.keys(props).length === 0) { + await this.runNow(command, {}); + return; + } + this.activeCommand = command; + this.paramValues = {}; + for (const [name, prop] of Object.entries(props)) { + this.paramValues[name] = prop.type === 'boolean' ? false : ''; + } + this.cdr.detectChanges(); + } + + /** + * Resolve the selectable options for a parameter, from a static enum or a + * live enumSource (collections, presetViews, eventKeys). An empty array + * means the field is rendered as free text. + * @param prop The parameter schema. + * @returns The option strings. + */ + optionsFor(prop: CommandProperty): string[] { + if (prop.enum) return prop.enum.map((v) => String(v)); + switch (prop.enumSource) { + case 'collections': + return Object.values(this.eventDisplay.getCollections() ?? {}).flat(); + case 'presetViews': + return (this.eventDisplay.getUIManager()?.getPresetViews() ?? []).map( + (v: any) => v.name, + ); + case 'eventKeys': + return Object.keys(this.eventDisplay.getEventsData() ?? {}); + default: + return []; + } + } + + /** + * The active command's parameters in declared order, for the form template. + * @returns Name/schema pairs, or an empty array when no command is active. + */ + activeParams(): { name: string; prop: CommandProperty }[] { + const props = this.activeCommand?.inputSchema?.properties ?? {}; + return Object.entries(props).map(([name, prop]) => ({ name, prop })); + } + + /** Submit the active command's parameter form, coercing values by type. */ + async submitParams(): Promise { + if (!this.activeCommand) return; + const props = this.activeCommand.inputSchema?.properties ?? {}; + const args: Record = {}; + for (const [name, prop] of Object.entries(props)) { + const raw = this.paramValues[name]; + if (raw === undefined || raw === '') continue; + if (prop.type === 'integer' || prop.type === 'number') { + args[name] = Number(raw); + } else if (prop.type === 'boolean') { + args[name] = !!raw; + } else { + args[name] = raw; + } + } + await this.runNow(this.activeCommand, args); + } + + /** + * Execute a command through the registry and show the result banner. + * @param command The command to run. + * @param args The argument object. + */ + async runNow(command: Command, args: Record): Promise { + const res = await this.registry.execute(command.name, args); + if (res.ok) { + this.result = { ok: true, text: `${command.title ?? command.name} done` }; + this.activeCommand = null; + this.query = ''; + } else { + // This workspace compiles without strictNullChecks, so truthiness + // narrowing does not split the ok:true/false result union. Read the + // failure message off the explicitly-typed failure variant instead. + const failure = res as { ok: false; error?: string }; + this.result = { ok: false, text: failure.error ?? 'Command failed' }; + } + this.cdr.detectChanges(); + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts index dd5568949..20386cd9d 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts @@ -80,6 +80,7 @@ import { MasterclassPanelComponent, MasterclassPanelOverlayComponent, SessionPillComponent, + CommandPaletteComponent, } from './ui-menu'; import { AttributePipe } from '../services/extras/attribute.pipe'; @@ -161,6 +162,7 @@ const PHOENIX_COMPONENTS: Type[] = [ MasterclassPanelOverlayComponent, SessionPillComponent, ShortcutsDialogComponent, + CommandPaletteComponent, ]; @NgModule({ diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts index 0fd3e3dd6..eaa5cb226 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts @@ -51,3 +51,4 @@ export * from './kinematics-panel/kinematics-panel-overlay/kinematics-panel-over export * from './masterclass-panel/masterclass-panel.component'; export * from './masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component'; export * from '../session-pill/session-pill.component'; +export * from '../command-palette/command-palette.component'; diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html index ddebc3882..afac89081 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.html @@ -17,3 +17,4 @@ + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.ts index a6674a991..2f1cd54ae 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu-wrapper/ui-menu-wrapper.component.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core'; +import { Component, Input } from '@angular/core'; @Component({ standalone: false, // this is now required when using NgModule @@ -8,4 +8,7 @@ import { Component } from '@angular/core'; }) export class UiMenuWrapperComponent { hideUIMenu: boolean = false; + + /** Whether the Ctrl/Cmd+K command palette is mounted (#942). */ + @Input() showCommandPalette = true; } diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html index 0ef6db706..52f91f2ec 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html @@ -1,4 +1,4 @@ - + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts index 3fdd44f35..12fb60b7a 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts @@ -29,6 +29,7 @@ export interface UIMenuConfig { showKinematicsPanel?: boolean; showMasterclassPanel?: boolean; showHistogramPanel?: boolean; + showCommandPalette?: boolean; } export const defaultUIMenuConfig: UIMenuConfig = { @@ -50,6 +51,7 @@ export const defaultUIMenuConfig: UIMenuConfig = { showKinematicsPanel: true, showMasterclassPanel: false, showHistogramPanel: false, + showCommandPalette: true, }; @Component({ From 561d1eb17429402d1c9f111f0e4541854f104dd6 Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Sat, 4 Jul 2026 01:19:32 +0530 Subject: [PATCH 08/27] fix: close the palette before running a command so its overlay leaves the canvas (#942) Signed-off-by: rx18-eng --- .../command-palette.component.html | 10 -------- .../command-palette.component.scss | 13 ----------- .../command-palette.component.test.ts | 22 +++++++++++++----- .../command-palette.component.ts | 23 ++++++++++--------- 4 files changed, 28 insertions(+), 40 deletions(-) diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html index e990b11eb..e5ed3e060 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html @@ -91,15 +91,5 @@ - - -
- {{ result.text }} -
diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss index 79cc4760a..04758500f 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.scss @@ -114,16 +114,3 @@ border-color: transparent; } } - -.cmdp-result { - padding: 10px 16px; - font-size: 13px; - - &.ok { - color: #7fd18b; - } - - &.err { - color: #ff8a8a; - } -} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts index 4586dc111..fe30d227e 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.test.ts @@ -33,9 +33,10 @@ function make( getEventsData: () => ({}), }; const cdr: any = { detectChanges: jest.fn() }; - const c = new CommandPaletteComponent(eventDisplay, cdr); + const notify: any = { success: jest.fn(), error: jest.fn() }; + const c = new CommandPaletteComponent(eventDisplay, cdr, notify); c.ngOnInit(); - return { c, registry }; + return { c, registry, notify }; } const key = (over: any) => @@ -110,6 +111,15 @@ describe('CommandPaletteComponent (choose, params, run)', () => { expect(registry.execute).toHaveBeenCalledWith('next-event', {}); }); + it('CLOSES the palette when a command runs, and toasts success (backdrop must not persist over the canvas)', async () => { + const { c, notify } = make(); + c.openPalette(); + expect(c.open).toBe(true); + await c.choose(c.filtered.find((x) => x.name === 'next-event')!); + expect(c.open).toBe(false); + expect(notify.success).toHaveBeenCalled(); + }); + it('opens a param form for a command with parameters (no execute yet)', async () => { const themed = fakeCommand('set-theme', { inputSchema: { @@ -175,12 +185,12 @@ describe('CommandPaletteComponent (choose, params, run)', () => { }); }); - it('shows an error result when a command fails', async () => { - const { c, registry } = make(); + it('toasts an error (and still closes) when a command fails', async () => { + const { c, registry, notify } = make(); registry.execute.mockResolvedValueOnce({ ok: false, error: 'boom' }); c.openPalette(); await c.choose(c.filtered.find((x) => x.name === 'next-event')!); - expect(c.result?.ok).toBe(false); - expect(c.result?.text).toContain('boom'); + expect(notify.error).toHaveBeenCalledWith('boom'); + expect(c.open).toBe(false); }); }); diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts index 75b5cf997..2baea026c 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.ts @@ -10,6 +10,7 @@ import type { CommandRegistry, } from 'phoenix-event-display'; import { EventDisplayService } from '../../services/event-display.service'; +import { NotificationService } from '../../services/notification.service'; /** * Keyboard-triggered command palette (#942). Opens on Ctrl/Cmd+K, lists the @@ -36,18 +37,18 @@ export class CommandPaletteComponent implements OnInit { activeCommand: Command | null = null; /** Working parameter values for the active command's form. */ paramValues: Record = {}; - /** Last execution result banner, or null. */ - result: { ok: boolean; text: string } | null = null; private registry!: CommandRegistry; /** * @param eventDisplay The Phoenix event display service. * @param cdr Change detector for pushing updates outside Angular events. + * @param notification Service for success/error toasts after a command runs. */ constructor( private eventDisplay: EventDisplayService, private cdr: ChangeDetectorRef, + private notification: NotificationService, ) {} /** Cache the command registry once the service is ready. */ @@ -106,7 +107,6 @@ export class CommandPaletteComponent implements OnInit { openPalette(): void { this.open = true; this.query = ''; - this.result = null; this.activeCommand = null; this.selectedIndex = 0; this.filtered = this.registry.list(); @@ -117,7 +117,6 @@ export class CommandPaletteComponent implements OnInit { close(): void { this.open = false; this.activeCommand = null; - this.result = null; this.cdr.detectChanges(); } @@ -145,7 +144,6 @@ export class CommandPaletteComponent implements OnInit { * @param command The chosen command. */ async choose(command: Command): Promise { - this.result = null; const props = command.inputSchema?.properties ?? {}; if (Object.keys(props).length === 0) { await this.runNow(command, {}); @@ -211,23 +209,26 @@ export class CommandPaletteComponent implements OnInit { } /** - * Execute a command through the registry and show the result banner. + * Execute a command through the registry. The palette closes FIRST so its + * full-screen overlay never sits over the live WebGL canvas while a command + * runs: an overlay above a continuously-rendering scene (e.g. after + * enabling auto-rotate) disables the browser's direct-canvas path and can + * exhaust the GPU into a context loss. Feedback is a toast, not an in-palette + * banner, since the palette is already gone. * @param command The command to run. * @param args The argument object. */ async runNow(command: Command, args: Record): Promise { + this.close(); const res = await this.registry.execute(command.name, args); if (res.ok) { - this.result = { ok: true, text: `${command.title ?? command.name} done` }; - this.activeCommand = null; - this.query = ''; + this.notification.success(`${command.title ?? command.name} done`); } else { // This workspace compiles without strictNullChecks, so truthiness // narrowing does not split the ok:true/false result union. Read the // failure message off the explicitly-typed failure variant instead. const failure = res as { ok: false; error?: string }; - this.result = { ok: false, text: failure.error ?? 'Command failed' }; + this.notification.error(failure.error ?? 'Command failed'); } - this.cdr.detectChanges(); } } From 715a1b2a382b284c2efadc9b9cd064936d6f6e36 Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Sat, 4 Jul 2026 01:28:51 +0530 Subject: [PATCH 09/27] fix: float command palette panel instead of a full-screen backdrop over the canvas (#942) Signed-off-by: rx18-eng --- .../command-palette.component.html | 169 +++++++++--------- .../command-palette.component.scss | 19 +- .../command-palette.component.test.ts | 22 ++- .../command-palette.component.ts | 19 ++ 4 files changed, 128 insertions(+), 101 deletions(-) diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html index e5ed3e060..483bd0252 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/command-palette/command-palette.component.html @@ -1,95 +1,86 @@ -