import type { Permission } from "@opengeni/sdk"; import { formatErrorMessage } from "@opengeni/sdk"; import type { CommandContext, SlashArg, SlashCommand } from "./types"; import { type OpenGeniText } from "../messages/runtime"; /** * Parse a composer value into a command name + the rest. A command is * recognized ONLY when the value's first character is "/" (the start token); * anything else is plain chat and returns null. * * "/cl" -> { name: "cl", rest: "", hasTrailingSpace: false } * "/goal " -> { name: "goal", rest: "", hasTrailingSpace: true } * "/goal pause"-> { name: "goal", rest: "pause", hasTrailingSpace: false } */ export type ParsedCommandLine = { name: string; rest: string; /** True when the name token is closed by a space — arg-hint mode. */ hasTrailingSpace: boolean; args: string[]; }; export function parseCommandLine(value: string): ParsedCommandLine | null { if (value[0] !== "/") { return null; } const body = value.slice(1); const firstSpace = body.indexOf(" "); if (firstSpace === -1) { return { name: body, rest: "", hasTrailingSpace: false, args: [] }; } const name = body.slice(0, firstSpace); const rest = body.slice(firstSpace + 1); return { name, rest, hasTrailingSpace: true, args: rest.split(/\s+/).filter((token) => token.length > 0), }; } const PERMISSION_SUPERUSER: Permission = "workspace:admin"; /** Whether the operator's permission set satisfies a command's gate. */ export function hasPermission( required: Permission | undefined, permissions: Permission[], ): boolean { if (!required) { return true; } return permissions.includes(required) || permissions.includes(PERMISSION_SUPERUSER); } /** Match a command (by name or alias) against the value. */ export function matchCommand( commands: readonly SlashCommand[], value: string, ): SlashCommand | null { const parsed = parseCommandLine(value); if (!parsed) { return null; } const token = parsed.name.toLowerCase(); return ( commands.find((command) => command.name === token || command.aliases?.includes(token)) ?? null ); } type FilterCtx = Pick; /** * The commands visible for the current token + context. Permission-absent and * `available()===false` commands are dropped entirely (a gated command is never * shown, not shown-disabled). Filtering is a prefix match on name/alias. */ export function filterCommands( commands: readonly SlashCommand[], token: string, ctx: FilterCtx, ): SlashCommand[] { const needle = token.toLowerCase(); return commands.filter((command) => { if (!hasPermission(command.permission, ctx.permissions)) { return false; } if (command.available && !command.available(ctx)) { return false; } if (needle.length === 0) { return true; } return ( command.name.startsWith(needle) || (command.aliases?.some((alias) => alias.startsWith(needle)) ?? false) ); }); } /** Render a command's arg hint for the palette footer / help, e.g. "". */ export function argHint(args: readonly SlashArg[] | undefined): string { if (!args || args.length === 0) { return ""; } return args .map((arg) => { const label = arg.oneOf ? arg.oneOf.join("|") : arg.name; return arg.required ? `<${label}>` : `[${label}]`; }) .join(" "); } /** The first required arg that has not yet been supplied, if any. */ export function firstMissingRequiredArg(command: SlashCommand, args: string[]): SlashArg | null { const required = (command.args ?? []).filter((arg) => arg.required); for (let i = 0; i < required.length; i += 1) { const value = args[i]; if (value === undefined || value.length === 0) { return required[i] ?? null; } } return null; } function requireSession(t: OpenGeniText, ctx: CommandContext): string { if (!ctx.sessionId) { throw new Error(t("commands.registry.noActiveSessionYetStart")); } return ctx.sessionId; } const hasSession = (ctx: FilterCtx): boolean => ctx.sessionId !== null; /** * The default command set. Adding a command is one object literal here; the * palette list, filter, arg-hint footer, and /help all render from this array. * Apps concat their own commands via the ChatComposer `commands` prop. */ export const defaultCommands = (t: OpenGeniText): readonly SlashCommand[] => [ { name: "help", aliases: ["?"], description: t("commands.registry.showAvailableCommands"), run: (_args, ctx) => { ctx.openHelp(); return { status: "ok" }; }, }, { name: "clear-view", description: t("commands.registry.clearTheLocalTimelineView"), run: (_args, ctx) => { // clearView() reports whether the host actually wired a view-reset. If it // didn't (the console surface has no resettable local timeline), reporting // "Local view cleared." would be a false success — return an honest error // instead so the operator isn't told something happened when nothing did. const cleared = ctx.clearView(); if (!cleared) { return { status: "error", message: t("commands.registry.thisViewCanTBe"), }; } return { status: "ok", message: t("commands.registry.localViewCleared") }; }, }, { name: "goal", description: t("commands.registry.pauseOrResumeTheSession"), permission: "sessions:control", available: hasSession, args: [ { name: "action", required: true, oneOf: ["pause", "resume"], description: "pause | resume" }, ], run: async (args, ctx) => { const sessionId = requireSession(t, ctx); const action = args[0]; if (action !== "pause" && action !== "resume") { return { status: "error", message: "Usage: /goal pause | /goal resume" }; } try { await ctx.client.updateGoal(ctx.workspaceId, sessionId, { status: action === "pause" ? "paused" : "active", }); return { status: "ok", message: action === "pause" ? t("commands.registry.goalPaused") : t("commands.registry.goalResumed"), }; } catch (cause) { return { status: "error", message: commandErrorMessage(ctx, cause, goalErrorMessage(t, cause, action)), }; } }, }, { name: "compact", description: t("commands.registry.compactTheConversationContextNow"), permission: "sessions:control", available: hasSession, run: async (_args, ctx) => { const sessionId = requireSession(t, ctx); try { const result = await ctx.client.compactSessionContext(ctx.workspaceId, sessionId); return { status: "ok", message: result.message }; } catch (cause) { return { status: "error", message: commandErrorMessage(ctx, cause) }; } }, }, { name: "clear", description: t("commands.registry.clearTheConversationContextDestructive"), permission: "sessions:control", danger: true, available: hasSession, run: async (_args, ctx) => { const sessionId = requireSession(t, ctx); const confirmed = await ctx.confirm(); if (!confirmed) { // Canceled: no error, but keep the "/clear" draft so the operator who // backed out doesn't silently lose what they typed. return { status: "ok", keepDraft: true }; } try { await ctx.client.clearSessionContext(ctx.workspaceId, sessionId); return { status: "ok", message: t("commands.registry.contextCleared") }; } catch (cause) { return { status: "error", message: commandErrorMessage(ctx, cause, clearErrorMessage(t, cause)), }; } }, }, ]; function commandErrorMessage( ctx: CommandContext, cause: unknown, message = formatErrorMessage(cause), ): string { return ctx.formatError?.(cause, message) || message; } function statusCode(cause: unknown): number | undefined { if ( cause && typeof cause === "object" && "status" in cause && typeof (cause as { status?: unknown }).status === "number" ) { return (cause as { status: number }).status; } return undefined; } function goalErrorMessage(t: OpenGeniText, cause: unknown, action: "pause" | "resume"): string { const code = statusCode(cause); if (code === 404) { return t("commands.registry.thisSessionHasNoGoal"); } if (code === 409) { return action === "resume" ? t("commands.registry.onlyAPausedGoalCan") : t("commands.registry.goalIsAlreadyInA"); } return formatErrorMessage(cause); } function clearErrorMessage(t: OpenGeniText, cause: unknown): string { if (statusCode(cause) === 409) { return t("commands.registry.pauseTheSessionAndWait"); } return formatErrorMessage(cause); }