Config UI: tag filters and complete schema help/labels coverage (#23796)

* Config UI: add tag filters and complete schema help/labels

* Config UI: finalize tags/help polish and unblock test suite

* Protocol: regenerate Swift gateway models
This commit is contained in:
Tak Hoffman
2026-02-22 15:17:07 -06:00
committed by GitHub
parent c539782c09
commit f8171ffcdc
28 changed files with 3409 additions and 274 deletions

View File

@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Config/UI: add tag-aware settings filtering and broaden config labels/help copy so fields are easier to discover and understand in the dashboard config screen.
- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence.
- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.

View File

@@ -2381,6 +2381,9 @@ public struct CronRunLogEntry: Codable, Sendable {
public let status: AnyCodable?
public let error: String?
public let summary: String?
public let delivered: Bool?
public let deliverystatus: AnyCodable?
public let deliveryerror: String?
public let sessionid: String?
public let sessionkey: String?
public let runatms: Int?
@@ -2394,6 +2397,9 @@ public struct CronRunLogEntry: Codable, Sendable {
status: AnyCodable?,
error: String?,
summary: String?,
delivered: Bool?,
deliverystatus: AnyCodable?,
deliveryerror: String?,
sessionid: String?,
sessionkey: String?,
runatms: Int?,
@@ -2406,6 +2412,9 @@ public struct CronRunLogEntry: Codable, Sendable {
self.status = status
self.error = error
self.summary = summary
self.delivered = delivered
self.deliverystatus = deliverystatus
self.deliveryerror = deliveryerror
self.sessionid = sessionid
self.sessionkey = sessionkey
self.runatms = runatms
@@ -2420,6 +2429,9 @@ public struct CronRunLogEntry: Codable, Sendable {
case status
case error
case summary
case delivered
case deliverystatus = "deliveryStatus"
case deliveryerror = "deliveryError"
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case runatms = "runAtMs"

View File

@@ -2381,6 +2381,9 @@ public struct CronRunLogEntry: Codable, Sendable {
public let status: AnyCodable?
public let error: String?
public let summary: String?
public let delivered: Bool?
public let deliverystatus: AnyCodable?
public let deliveryerror: String?
public let sessionid: String?
public let sessionkey: String?
public let runatms: Int?
@@ -2394,6 +2397,9 @@ public struct CronRunLogEntry: Codable, Sendable {
status: AnyCodable?,
error: String?,
summary: String?,
delivered: Bool?,
deliverystatus: AnyCodable?,
deliveryerror: String?,
sessionid: String?,
sessionkey: String?,
runatms: Int?,
@@ -2406,6 +2412,9 @@ public struct CronRunLogEntry: Codable, Sendable {
self.status = status
self.error = error
self.summary = summary
self.delivered = delivered
self.deliverystatus = deliverystatus
self.deliveryerror = deliveryerror
self.sessionid = sessionid
self.sessionkey = sessionkey
self.runatms = runatms
@@ -2420,6 +2429,9 @@ public struct CronRunLogEntry: Codable, Sendable {
case status
case error
case summary
case delivered
case deliverystatus = "deliveryStatus"
case deliveryerror = "deliveryError"
case sessionid = "sessionId"
case sessionkey = "sessionKey"
case runatms = "runAtMs"

View File

@@ -33,6 +33,7 @@ import type {
export type ChannelConfigUiHint = {
label?: string;
help?: string;
tags?: string[];
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;

View File

@@ -0,0 +1,763 @@
import { describe, expect, it } from "vitest";
import { FIELD_HELP } from "./schema.help.js";
import { FIELD_LABELS } from "./schema.labels.js";
const ROOT_SECTIONS = [
"meta",
"env",
"wizard",
"diagnostics",
"logging",
"update",
"browser",
"ui",
"auth",
"models",
"nodeHost",
"agents",
"tools",
"bindings",
"broadcast",
"audio",
"media",
"messages",
"commands",
"approvals",
"session",
"cron",
"hooks",
"web",
"channels",
"discovery",
"canvasHost",
"talk",
"gateway",
"memory",
"plugins",
] as const;
const TARGET_KEYS = [
"memory.citations",
"memory.backend",
"memory.qmd.searchMode",
"memory.qmd.scope",
"memory.qmd.includeDefaultMemory",
"memory.qmd.mcporter.enabled",
"memory.qmd.mcporter.serverName",
"memory.qmd.command",
"memory.qmd.mcporter",
"memory.qmd.mcporter.startDaemon",
"memory.qmd.paths",
"memory.qmd.paths.path",
"memory.qmd.paths.pattern",
"memory.qmd.paths.name",
"memory.qmd.sessions.enabled",
"memory.qmd.sessions.exportDir",
"memory.qmd.sessions.retentionDays",
"memory.qmd.update.interval",
"memory.qmd.update.debounceMs",
"memory.qmd.update.onBoot",
"memory.qmd.update.waitForBootSync",
"memory.qmd.update.embedInterval",
"memory.qmd.update.commandTimeoutMs",
"memory.qmd.update.updateTimeoutMs",
"memory.qmd.update.embedTimeoutMs",
"memory.qmd.limits.maxResults",
"memory.qmd.limits.maxSnippetChars",
"memory.qmd.limits.maxInjectedChars",
"memory.qmd.limits.timeoutMs",
"agents.defaults.memorySearch.provider",
"agents.defaults.memorySearch.fallback",
"agents.defaults.memorySearch.sources",
"agents.defaults.memorySearch.extraPaths",
"agents.defaults.memorySearch.experimental.sessionMemory",
"agents.defaults.memorySearch.remote.baseUrl",
"agents.defaults.memorySearch.remote.apiKey",
"agents.defaults.memorySearch.remote.headers",
"agents.defaults.memorySearch.remote.batch.enabled",
"agents.defaults.memorySearch.remote.batch.wait",
"agents.defaults.memorySearch.remote.batch.concurrency",
"agents.defaults.memorySearch.remote.batch.pollIntervalMs",
"agents.defaults.memorySearch.remote.batch.timeoutMinutes",
"agents.defaults.memorySearch.local.modelPath",
"agents.defaults.memorySearch.store.path",
"agents.defaults.memorySearch.store.vector.enabled",
"agents.defaults.memorySearch.store.vector.extensionPath",
"agents.defaults.memorySearch.query.hybrid.enabled",
"agents.defaults.memorySearch.query.hybrid.vectorWeight",
"agents.defaults.memorySearch.query.hybrid.textWeight",
"agents.defaults.memorySearch.query.hybrid.candidateMultiplier",
"agents.defaults.memorySearch.query.hybrid.mmr.enabled",
"agents.defaults.memorySearch.query.hybrid.mmr.lambda",
"agents.defaults.memorySearch.query.hybrid.temporalDecay.enabled",
"agents.defaults.memorySearch.query.hybrid.temporalDecay.halfLifeDays",
"agents.defaults.memorySearch.cache.enabled",
"agents.defaults.memorySearch.cache.maxEntries",
"agents.defaults.memorySearch.sync.onSearch",
"agents.defaults.memorySearch.sync.watch",
"agents.defaults.memorySearch.sync.sessions.deltaBytes",
"agents.defaults.memorySearch.sync.sessions.deltaMessages",
"models.mode",
"models.providers.*.auth",
"models.providers.*.authHeader",
"gateway.reload.mode",
"gateway.controlUi.allowInsecureAuth",
"gateway.controlUi.dangerouslyDisableDeviceAuth",
"cron",
"cron.enabled",
"cron.store",
"cron.maxConcurrentRuns",
"cron.webhook",
"cron.webhookToken",
"cron.sessionRetention",
"session",
"session.scope",
"session.dmScope",
"session.identityLinks",
"session.resetTriggers",
"session.idleMinutes",
"session.reset",
"session.reset.mode",
"session.reset.atHour",
"session.reset.idleMinutes",
"session.resetByType",
"session.resetByType.direct",
"session.resetByType.dm",
"session.resetByType.group",
"session.resetByType.thread",
"session.resetByChannel",
"session.store",
"session.typingIntervalSeconds",
"session.typingMode",
"session.mainKey",
"session.sendPolicy",
"session.sendPolicy.default",
"session.sendPolicy.rules",
"session.sendPolicy.rules[].action",
"session.sendPolicy.rules[].match",
"session.sendPolicy.rules[].match.channel",
"session.sendPolicy.rules[].match.chatType",
"session.sendPolicy.rules[].match.keyPrefix",
"session.sendPolicy.rules[].match.rawKeyPrefix",
"session.agentToAgent",
"session.agentToAgent.maxPingPongTurns",
"session.threadBindings",
"session.threadBindings.enabled",
"session.threadBindings.ttlHours",
"session.maintenance",
"session.maintenance.mode",
"session.maintenance.pruneAfter",
"session.maintenance.pruneDays",
"session.maintenance.maxEntries",
"session.maintenance.rotateBytes",
"approvals",
"approvals.exec",
"approvals.exec.enabled",
"approvals.exec.mode",
"approvals.exec.agentFilter",
"approvals.exec.sessionFilter",
"approvals.exec.targets",
"approvals.exec.targets[].channel",
"approvals.exec.targets[].to",
"approvals.exec.targets[].accountId",
"approvals.exec.targets[].threadId",
"nodeHost",
"nodeHost.browserProxy",
"nodeHost.browserProxy.enabled",
"nodeHost.browserProxy.allowProfiles",
"media",
"media.preserveFilenames",
"audio",
"audio.transcription",
"audio.transcription.command",
"audio.transcription.timeoutSeconds",
"bindings",
"bindings[].agentId",
"bindings[].match",
"bindings[].match.channel",
"bindings[].match.accountId",
"bindings[].match.peer",
"bindings[].match.peer.kind",
"bindings[].match.peer.id",
"bindings[].match.guildId",
"bindings[].match.teamId",
"bindings[].match.roles",
"broadcast",
"broadcast.strategy",
"broadcast.*",
"commands",
"commands.allowFrom",
"hooks",
"hooks.enabled",
"hooks.path",
"hooks.token",
"hooks.defaultSessionKey",
"hooks.allowRequestSessionKey",
"hooks.allowedSessionKeyPrefixes",
"hooks.allowedAgentIds",
"hooks.maxBodyBytes",
"hooks.transformsDir",
"hooks.mappings",
"hooks.mappings[].action",
"hooks.mappings[].wakeMode",
"hooks.mappings[].channel",
"hooks.mappings[].transform.module",
"hooks.gmail",
"hooks.gmail.pushToken",
"hooks.gmail.tailscale.mode",
"hooks.gmail.thinking",
"hooks.internal",
"hooks.internal.handlers",
"hooks.internal.handlers[].event",
"hooks.internal.handlers[].module",
"hooks.internal.load.extraDirs",
"messages",
"messages.messagePrefix",
"messages.responsePrefix",
"messages.groupChat",
"messages.groupChat.mentionPatterns",
"messages.groupChat.historyLimit",
"messages.queue",
"messages.queue.mode",
"messages.queue.byChannel",
"messages.queue.debounceMs",
"messages.queue.debounceMsByChannel",
"messages.queue.cap",
"messages.queue.drop",
"messages.inbound",
"messages.inbound.byChannel",
"messages.removeAckAfterReply",
"messages.tts",
"channels",
"channels.defaults",
"channels.defaults.groupPolicy",
"channels.defaults.heartbeat",
"channels.defaults.heartbeat.showOk",
"channels.defaults.heartbeat.showAlerts",
"channels.defaults.heartbeat.useIndicator",
"gateway",
"gateway.mode",
"gateway.bind",
"gateway.auth.mode",
"gateway.tailscale.mode",
"gateway.tools.allow",
"gateway.tools.deny",
"gateway.tls.enabled",
"gateway.tls.autoGenerate",
"gateway.http",
"gateway.http.endpoints",
"browser",
"browser.enabled",
"browser.cdpUrl",
"browser.headless",
"browser.noSandbox",
"browser.profiles",
"browser.profiles.*.driver",
"tools",
"tools.allow",
"tools.deny",
"tools.exec",
"tools.exec.host",
"tools.exec.security",
"tools.exec.ask",
"tools.exec.node",
"tools.agentToAgent.enabled",
"tools.elevated.enabled",
"tools.elevated.allowFrom",
"tools.subagents.tools",
"tools.sandbox.tools",
"web",
"web.enabled",
"web.heartbeatSeconds",
"web.reconnect",
"web.reconnect.initialMs",
"web.reconnect.maxMs",
"web.reconnect.factor",
"web.reconnect.jitter",
"web.reconnect.maxAttempts",
"discovery",
"discovery.wideArea.enabled",
"discovery.mdns",
"discovery.mdns.mode",
"canvasHost",
"canvasHost.enabled",
"canvasHost.root",
"canvasHost.port",
"canvasHost.liveReload",
"talk",
"talk.voiceId",
"talk.voiceAliases",
"talk.modelId",
"talk.outputFormat",
"talk.interruptOnSpeech",
"meta",
"env",
"env.shellEnv",
"env.shellEnv.enabled",
"env.shellEnv.timeoutMs",
"env.vars",
"wizard",
"wizard.lastRunAt",
"wizard.lastRunVersion",
"wizard.lastRunCommit",
"wizard.lastRunCommand",
"wizard.lastRunMode",
"diagnostics",
"diagnostics.otel",
"diagnostics.cacheTrace",
"logging",
"logging.level",
"logging.file",
"logging.consoleLevel",
"logging.consoleStyle",
"logging.redactSensitive",
"logging.redactPatterns",
"update",
"ui",
"ui.assistant",
"plugins",
"plugins.enabled",
"plugins.allow",
"plugins.deny",
"plugins.load",
"plugins.load.paths",
"plugins.slots",
"plugins.entries",
"plugins.entries.*.enabled",
"plugins.entries.*.apiKey",
"plugins.entries.*.env",
"plugins.entries.*.config",
"plugins.installs",
"auth",
"auth.cooldowns",
"models",
"models.providers",
"models.providers.*.baseUrl",
"models.providers.*.apiKey",
"models.providers.*.api",
"models.providers.*.headers",
"models.providers.*.models",
"models.bedrockDiscovery",
"models.bedrockDiscovery.enabled",
"models.bedrockDiscovery.region",
"models.bedrockDiscovery.providerFilter",
"models.bedrockDiscovery.refreshInterval",
"models.bedrockDiscovery.defaultContextWindow",
"models.bedrockDiscovery.defaultMaxTokens",
"agents",
"agents.defaults",
"agents.list",
"agents.defaults.compaction",
"agents.defaults.compaction.mode",
"agents.defaults.compaction.reserveTokens",
"agents.defaults.compaction.keepRecentTokens",
"agents.defaults.compaction.reserveTokensFloor",
"agents.defaults.compaction.maxHistoryShare",
"agents.defaults.compaction.memoryFlush",
"agents.defaults.compaction.memoryFlush.enabled",
"agents.defaults.compaction.memoryFlush.softThresholdTokens",
"agents.defaults.compaction.memoryFlush.prompt",
"agents.defaults.compaction.memoryFlush.systemPrompt",
] as const;
const ENUM_EXPECTATIONS: Record<string, string[]> = {
"memory.citations": ['"auto"', '"on"', '"off"'],
"memory.backend": ['"builtin"', '"qmd"'],
"memory.qmd.searchMode": ['"query"', '"search"', '"vsearch"'],
"models.mode": ['"merge"', '"replace"'],
"models.providers.*.auth": ['"api-key"', '"token"', '"oauth"', '"aws-sdk"'],
"gateway.reload.mode": ['"off"', '"restart"', '"hot"', '"hybrid"'],
"approvals.exec.mode": ['"session"', '"targets"', '"both"'],
"bindings[].match.peer.kind": ['"direct"', '"group"', '"channel"', '"dm"'],
"broadcast.strategy": ['"parallel"', '"sequential"'],
"hooks.mappings[].action": ['"wake"', '"agent"'],
"hooks.mappings[].wakeMode": ['"now"', '"next-heartbeat"'],
"hooks.gmail.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
"hooks.gmail.thinking": ['"off"', '"minimal"', '"low"', '"medium"', '"high"'],
"messages.queue.mode": [
'"steer"',
'"followup"',
'"collect"',
'"steer-backlog"',
'"steer+backlog"',
'"queue"',
'"interrupt"',
],
"messages.queue.drop": ['"old"', '"new"', '"summarize"'],
"channels.defaults.groupPolicy": ['"open"', '"disabled"', '"allowlist"'],
"gateway.mode": ['"local"', '"remote"'],
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],
"gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
"browser.profiles.*.driver": ['"clawd"', '"extension"'],
"discovery.mdns.mode": ['"off"', '"minimal"', '"full"'],
"wizard.lastRunMode": ['"local"', '"remote"'],
"diagnostics.otel.protocol": ['"http/protobuf"', '"grpc"'],
"logging.level": ['"silent"', '"fatal"', '"error"', '"warn"', '"info"', '"debug"', '"trace"'],
"logging.consoleLevel": [
'"silent"',
'"fatal"',
'"error"',
'"warn"',
'"info"',
'"debug"',
'"trace"',
],
"logging.consoleStyle": ['"pretty"', '"compact"', '"json"'],
"logging.redactSensitive": ['"off"', '"tools"'],
"update.channel": ['"stable"', '"beta"', '"dev"'],
"agents.defaults.compaction.mode": ['"default"', '"safeguard"'],
};
const TOOLS_HOOKS_TARGET_KEYS = [
"hooks.gmail.account",
"hooks.gmail.allowUnsafeExternalContent",
"hooks.gmail.hookUrl",
"hooks.gmail.includeBody",
"hooks.gmail.label",
"hooks.gmail.model",
"hooks.gmail.serve",
"hooks.gmail.subscription",
"hooks.gmail.tailscale",
"hooks.gmail.topic",
"hooks.internal.entries",
"hooks.internal.installs",
"hooks.internal.load",
"hooks.mappings[].allowUnsafeExternalContent",
"hooks.mappings[].deliver",
"hooks.mappings[].id",
"hooks.mappings[].match",
"hooks.mappings[].messageTemplate",
"hooks.mappings[].model",
"hooks.mappings[].name",
"hooks.mappings[].textTemplate",
"hooks.mappings[].thinking",
"hooks.mappings[].transform",
"tools.alsoAllow",
"tools.byProvider",
"tools.exec.approvalRunningNoticeMs",
"tools.links.enabled",
"tools.links.maxLinks",
"tools.links.models",
"tools.links.scope",
"tools.links.timeoutSeconds",
"tools.media.audio.attachments",
"tools.media.audio.enabled",
"tools.media.audio.language",
"tools.media.audio.maxBytes",
"tools.media.audio.maxChars",
"tools.media.audio.models",
"tools.media.audio.prompt",
"tools.media.audio.scope",
"tools.media.audio.timeoutSeconds",
"tools.media.concurrency",
"tools.media.image.attachments",
"tools.media.image.enabled",
"tools.media.image.maxBytes",
"tools.media.image.maxChars",
"tools.media.image.models",
"tools.media.image.prompt",
"tools.media.image.scope",
"tools.media.image.timeoutSeconds",
"tools.media.models",
"tools.media.video.attachments",
"tools.media.video.enabled",
"tools.media.video.maxBytes",
"tools.media.video.maxChars",
"tools.media.video.models",
"tools.media.video.prompt",
"tools.media.video.scope",
"tools.media.video.timeoutSeconds",
"tools.profile",
] as const;
const CHANNELS_AGENTS_TARGET_KEYS = [
"agents.defaults.memorySearch.chunking.overlap",
"agents.defaults.memorySearch.chunking.tokens",
"agents.defaults.memorySearch.enabled",
"agents.defaults.memorySearch.model",
"agents.defaults.memorySearch.query.maxResults",
"agents.defaults.memorySearch.query.minScore",
"agents.defaults.memorySearch.sync.onSessionStart",
"agents.defaults.memorySearch.sync.watchDebounceMs",
"agents.defaults.workspace",
"agents.list[].tools.alsoAllow",
"agents.list[].tools.byProvider",
"agents.list[].tools.profile",
"channels.bluebubbles",
"channels.discord",
"channels.discord.token",
"channels.imessage",
"channels.imessage.cliPath",
"channels.irc",
"channels.mattermost",
"channels.msteams",
"channels.signal",
"channels.signal.account",
"channels.slack",
"channels.slack.appToken",
"channels.slack.botToken",
"channels.slack.userToken",
"channels.slack.userTokenReadOnly",
"channels.telegram",
"channels.telegram.botToken",
"channels.telegram.capabilities.inlineButtons",
"channels.whatsapp",
] as const;
const FINAL_BACKLOG_TARGET_KEYS = [
"browser.evaluateEnabled",
"browser.remoteCdpHandshakeTimeoutMs",
"browser.remoteCdpTimeoutMs",
"browser.snapshotDefaults",
"browser.snapshotDefaults.mode",
"browser.ssrfPolicy",
"browser.ssrfPolicy.allowPrivateNetwork",
"browser.ssrfPolicy.allowedHostnames",
"browser.ssrfPolicy.hostnameAllowlist",
"diagnostics.enabled",
"diagnostics.otel.enabled",
"diagnostics.otel.endpoint",
"diagnostics.otel.flushIntervalMs",
"diagnostics.otel.headers",
"diagnostics.otel.logs",
"diagnostics.otel.metrics",
"diagnostics.otel.sampleRate",
"diagnostics.otel.serviceName",
"diagnostics.otel.traces",
"gateway.remote.password",
"gateway.remote.token",
"skills.load.watch",
"skills.load.watchDebounceMs",
"talk.apiKey",
"ui.assistant.avatar",
"ui.assistant.name",
"ui.seamColor",
] as const;
describe("config help copy quality", () => {
it("keeps root section labels and help complete", () => {
for (const key of ROOT_SECTIONS) {
expect(FIELD_LABELS[key], `missing root label for ${key}`).toBeDefined();
expect(FIELD_HELP[key], `missing root help for ${key}`).toBeDefined();
}
});
it("keeps labels in parity for all help keys", () => {
for (const key of Object.keys(FIELD_HELP)) {
expect(FIELD_LABELS[key], `missing label for help key ${key}`).toBeDefined();
}
});
it("covers the target confusing fields with non-trivial explanations", () => {
for (const key of TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|selects|sets|defines)/i.test(help),
`help should include operational guidance for ${key}`,
).toBe(true);
}
});
it("covers tools/hooks help keys with non-trivial operational guidance", () => {
for (const key of TOOLS_HOOKS_TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
help,
),
`help should include operational guidance for ${key}`,
).toBe(true);
}
});
it("covers channels/agents help keys with non-trivial operational guidance", () => {
for (const key of CHANNELS_AGENTS_TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
help,
),
`help should include operational guidance for ${key}`,
).toBe(true);
}
});
it("covers final backlog help keys with non-trivial operational guidance", () => {
for (const key of FINAL_BACKLOG_TARGET_KEYS) {
const help = FIELD_HELP[key];
expect(help, `missing help for ${key}`).toBeDefined();
expect(help.length, `help too short for ${key}`).toBeGreaterThanOrEqual(80);
expect(
/(default|keep|use|enable|disable|controls|set|sets|increase|lower|prefer|tune|avoid|choose|when)/i.test(
help,
),
`help should include operational guidance for ${key}`,
).toBe(true);
}
});
it("documents option behavior for enum-style fields", () => {
for (const [key, options] of Object.entries(ENUM_EXPECTATIONS)) {
const help = FIELD_HELP[key];
expect(help, `missing help for enum key ${key}`).toBeDefined();
for (const token of options) {
expect(help.includes(token), `missing option ${token} in ${key}`).toBe(true);
}
}
});
it("explains memory citations mode semantics", () => {
const help = FIELD_HELP["memory.citations"];
expect(help.includes('"auto"')).toBe(true);
expect(help.includes('"on"')).toBe(true);
expect(help.includes('"off"')).toBe(true);
expect(/always|always shows/i.test(help)).toBe(true);
expect(/hides|hide/i.test(help)).toBe(true);
});
it("includes concrete examples on path and interval fields", () => {
expect(FIELD_HELP["memory.qmd.paths.pattern"].includes("**/*.md")).toBe(true);
expect(FIELD_HELP["memory.qmd.update.interval"].includes("5m")).toBe(true);
expect(FIELD_HELP["memory.qmd.update.embedInterval"].includes("60m")).toBe(true);
expect(FIELD_HELP["agents.defaults.memorySearch.store.path"]).toContain(
"~/.openclaw/memory/{agentId}.sqlite",
);
});
it("documents cron deprecation, migration, and retention formats", () => {
const legacy = FIELD_HELP["cron.webhook"];
expect(/deprecated|legacy/i.test(legacy)).toBe(true);
expect(legacy.includes('delivery.mode="webhook"')).toBe(true);
expect(legacy.includes("delivery.to")).toBe(true);
const retention = FIELD_HELP["cron.sessionRetention"];
expect(retention.includes("24h")).toBe(true);
expect(retention.includes("7d")).toBe(true);
expect(retention.includes("1h30m")).toBe(true);
expect(/false/i.test(retention)).toBe(true);
const token = FIELD_HELP["cron.webhookToken"];
expect(/token|bearer/i.test(token)).toBe(true);
expect(/secret|env|rotate/i.test(token)).toBe(true);
});
it("documents session send-policy examples and prefix semantics", () => {
const rules = FIELD_HELP["session.sendPolicy.rules"];
expect(rules.includes("{ action:")).toBe(true);
expect(rules.includes('"deny"')).toBe(true);
expect(rules.includes('"discord"')).toBe(true);
const keyPrefix = FIELD_HELP["session.sendPolicy.rules[].match.keyPrefix"];
expect(/normalized/i.test(keyPrefix)).toBe(true);
const rawKeyPrefix = FIELD_HELP["session.sendPolicy.rules[].match.rawKeyPrefix"];
expect(/raw|unnormalized/i.test(rawKeyPrefix)).toBe(true);
});
it("documents session maintenance duration/size examples and deprecations", () => {
const pruneAfter = FIELD_HELP["session.maintenance.pruneAfter"];
expect(pruneAfter.includes("30d")).toBe(true);
expect(pruneAfter.includes("12h")).toBe(true);
const rotate = FIELD_HELP["session.maintenance.rotateBytes"];
expect(rotate.includes("10mb")).toBe(true);
expect(rotate.includes("1gb")).toBe(true);
const deprecated = FIELD_HELP["session.maintenance.pruneDays"];
expect(/deprecated/i.test(deprecated)).toBe(true);
expect(deprecated.includes("session.maintenance.pruneAfter")).toBe(true);
});
it("documents approvals filters and target semantics", () => {
const sessionFilter = FIELD_HELP["approvals.exec.sessionFilter"];
expect(/substring|regex/i.test(sessionFilter)).toBe(true);
expect(sessionFilter.includes("discord:")).toBe(true);
expect(sessionFilter.includes("^agent:ops:")).toBe(true);
const agentFilter = FIELD_HELP["approvals.exec.agentFilter"];
expect(agentFilter.includes("primary")).toBe(true);
expect(agentFilter.includes("ops-agent")).toBe(true);
const targetTo = FIELD_HELP["approvals.exec.targets[].to"];
expect(/channel ID|user ID|thread root/i.test(targetTo)).toBe(true);
expect(/differs|per provider/i.test(targetTo)).toBe(true);
});
it("documents broadcast and audio command examples", () => {
const audioCmd = FIELD_HELP["audio.transcription.command"];
expect(audioCmd.includes("whisper-cli")).toBe(true);
expect(audioCmd.includes("{input}")).toBe(true);
const broadcastMap = FIELD_HELP["broadcast.*"];
expect(/source peer ID/i.test(broadcastMap)).toBe(true);
expect(/destination peer IDs/i.test(broadcastMap)).toBe(true);
});
it("documents hook transform safety and queue behavior options", () => {
const transformModule = FIELD_HELP["hooks.mappings[].transform.module"];
expect(/relative/i.test(transformModule)).toBe(true);
expect(/path traversal|reviewed|controlled/i.test(transformModule)).toBe(true);
const queueMode = FIELD_HELP["messages.queue.mode"];
expect(queueMode.includes('"interrupt"')).toBe(true);
expect(queueMode.includes('"steer+backlog"')).toBe(true);
});
it("documents gateway bind modes and web reconnect semantics", () => {
const bind = FIELD_HELP["gateway.bind"];
expect(bind.includes('"loopback"')).toBe(true);
expect(bind.includes('"tailnet"')).toBe(true);
const reconnect = FIELD_HELP["web.reconnect.maxAttempts"];
expect(/0 means no retries|no retries/i.test(reconnect)).toBe(true);
expect(/failure sequence|retry/i.test(reconnect)).toBe(true);
});
it("documents metadata/admin semantics for logging, wizard, and plugins", () => {
const wizardMode = FIELD_HELP["wizard.lastRunMode"];
expect(wizardMode.includes('"local"')).toBe(true);
expect(wizardMode.includes('"remote"')).toBe(true);
const consoleStyle = FIELD_HELP["logging.consoleStyle"];
expect(consoleStyle.includes('"pretty"')).toBe(true);
expect(consoleStyle.includes('"compact"')).toBe(true);
expect(consoleStyle.includes('"json"')).toBe(true);
const pluginApiKey = FIELD_HELP["plugins.entries.*.apiKey"];
expect(/secret|env|credential/i.test(pluginApiKey)).toBe(true);
const pluginEnv = FIELD_HELP["plugins.entries.*.env"];
expect(/scope|plugin|environment/i.test(pluginEnv)).toBe(true);
});
it("documents auth/model root semantics and provider secret handling", () => {
const providerKey = FIELD_HELP["models.providers.*.apiKey"];
expect(/secret|env|credential/i.test(providerKey)).toBe(true);
const bedrockRefresh = FIELD_HELP["models.bedrockDiscovery.refreshInterval"];
expect(/refresh|seconds|interval/i.test(bedrockRefresh)).toBe(true);
expect(/cost|noise|api/i.test(bedrockRefresh)).toBe(true);
const authCooldowns = FIELD_HELP["auth.cooldowns"];
expect(/cooldown|backoff|retry/i.test(authCooldowns)).toBe(true);
});
it("documents agent compaction safeguards and memory flush behavior", () => {
const mode = FIELD_HELP["agents.defaults.compaction.mode"];
expect(mode.includes('"default"')).toBe(true);
expect(mode.includes('"safeguard"')).toBe(true);
const historyShare = FIELD_HELP["agents.defaults.compaction.maxHistoryShare"];
expect(/0\\.1-0\\.9|fraction|share/i.test(historyShare)).toBe(true);
const flush = FIELD_HELP["agents.defaults.compaction.memoryFlush.enabled"];
expect(/pre-compaction|memory flush|token/i.test(flush)).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { FIELD_HELP } from "./schema.help.js";
import { FIELD_LABELS } from "./schema.labels.js";
import { applyDerivedTags } from "./schema.tags.js";
import { sensitive } from "./zod-schema.sensitive.js";
const log = createSubsystemLogger("config/schema");
@@ -9,6 +10,7 @@ const log = createSubsystemLogger("config/schema");
export type ConfigUiHint = {
label?: string;
help?: string;
tags?: string[];
group?: string;
order?: number;
advanced?: boolean;
@@ -143,7 +145,7 @@ export function buildBaseHints(): ConfigUiHints {
const current = hints[path];
hints[path] = current ? { ...current, placeholder } : { placeholder };
}
return hints;
return applyDerivedTags(hints);
}
export function applySensitiveHints(

View File

@@ -1,8 +1,31 @@
import { IRC_FIELD_LABELS } from "./schema.irc.js";
export const FIELD_LABELS: Record<string, string> = {
meta: "Metadata",
"meta.lastTouchedVersion": "Config Last Touched Version",
"meta.lastTouchedAt": "Config Last Touched At",
env: "Environment",
"env.shellEnv": "Shell Environment Import",
"env.shellEnv.enabled": "Shell Environment Import Enabled",
"env.shellEnv.timeoutMs": "Shell Environment Import Timeout (ms)",
"env.vars": "Environment Variable Overrides",
wizard: "Setup Wizard State",
"wizard.lastRunAt": "Wizard Last Run Timestamp",
"wizard.lastRunVersion": "Wizard Last Run Version",
"wizard.lastRunCommit": "Wizard Last Run Commit",
"wizard.lastRunCommand": "Wizard Last Run Command",
"wizard.lastRunMode": "Wizard Last Run Mode",
diagnostics: "Diagnostics",
"diagnostics.otel": "OpenTelemetry",
"diagnostics.cacheTrace": "Cache Trace",
logging: "Logging",
"logging.level": "Log Level",
"logging.file": "Log File Path",
"logging.consoleLevel": "Console Log Level",
"logging.consoleStyle": "Console Log Style",
"logging.redactSensitive": "Sensitive Data Redaction Mode",
"logging.redactPatterns": "Custom Redaction Patterns",
update: "Updates",
"update.channel": "Update Channel",
"update.checkOnStart": "Update Check on Start",
"update.auto.enabled": "Auto Update Enabled",
@@ -28,6 +51,41 @@ export const FIELD_LABELS: Record<string, string> = {
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
"agents.list.*.identity.avatar": "Identity Avatar",
"agents.list.*.skills": "Agent Skill Filter",
agents: "Agents",
"agents.defaults": "Agent Defaults",
"agents.list": "Agent List",
gateway: "Gateway",
"gateway.port": "Gateway Port",
"gateway.mode": "Gateway Mode",
"gateway.bind": "Gateway Bind Mode",
"gateway.customBindHost": "Gateway Custom Bind Host",
"gateway.controlUi": "Control UI",
"gateway.controlUi.enabled": "Control UI Enabled",
"gateway.auth": "Gateway Auth",
"gateway.auth.mode": "Gateway Auth Mode",
"gateway.auth.allowTailscale": "Gateway Auth Allow Tailscale Identity",
"gateway.auth.rateLimit": "Gateway Auth Rate Limit",
"gateway.auth.trustedProxy": "Gateway Trusted Proxy Auth",
"gateway.trustedProxies": "Gateway Trusted Proxy CIDRs",
"gateway.allowRealIpFallback": "Gateway Allow x-real-ip Fallback",
"gateway.tools": "Gateway Tool Exposure Policy",
"gateway.tools.allow": "Gateway Tool Allowlist",
"gateway.tools.deny": "Gateway Tool Denylist",
"gateway.channelHealthCheckMinutes": "Gateway Channel Health Check Interval (min)",
"gateway.tailscale": "Gateway Tailscale",
"gateway.tailscale.mode": "Gateway Tailscale Mode",
"gateway.tailscale.resetOnExit": "Gateway Tailscale Reset on Exit",
"gateway.remote": "Remote Gateway",
"gateway.remote.transport": "Remote Gateway Transport",
"gateway.reload": "Config Reload",
"gateway.tls": "Gateway TLS",
"gateway.tls.enabled": "Gateway TLS Enabled",
"gateway.tls.autoGenerate": "Gateway TLS Auto-Generate Cert",
"gateway.tls.certPath": "Gateway TLS Certificate Path",
"gateway.tls.keyPath": "Gateway TLS Key Path",
"gateway.tls.caPath": "Gateway TLS CA Path",
"gateway.http": "Gateway HTTP API",
"gateway.http.endpoints": "Gateway HTTP Endpoints",
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
@@ -36,6 +94,25 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.remote.tlsFingerprint": "Remote Gateway TLS Fingerprint",
"gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password",
browser: "Browser",
"browser.enabled": "Browser Enabled",
"browser.cdpUrl": "Browser CDP URL",
"browser.color": "Browser Accent Color",
"browser.executablePath": "Browser Executable Path",
"browser.headless": "Browser Headless Mode",
"browser.noSandbox": "Browser No-Sandbox Mode",
"browser.attachOnly": "Browser Attach-only Mode",
"browser.defaultProfile": "Browser Default Profile",
"browser.profiles": "Browser Profiles",
"browser.profiles.*.cdpPort": "Browser Profile CDP Port",
"browser.profiles.*.cdpUrl": "Browser Profile CDP URL",
"browser.profiles.*.driver": "Browser Profile Driver",
"browser.profiles.*.color": "Browser Profile Accent Color",
tools: "Tools",
"tools.allow": "Tool Allowlist",
"tools.deny": "Tool Denylist",
"tools.web": "Web Tools",
"tools.exec": "Exec Tool",
"tools.media.image.enabled": "Enable Image Understanding",
"tools.media.image.maxBytes": "Image Understanding Max Bytes",
"tools.media.image.maxChars": "Image Understanding Max Chars",
@@ -94,9 +171,30 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.exec.security": "Exec Security",
"tools.exec.ask": "Exec Ask",
"tools.exec.node": "Exec Node Binding",
"tools.agentToAgent": "Agent-to-Agent Tool Access",
"tools.agentToAgent.enabled": "Enable Agent-to-Agent Tool",
"tools.agentToAgent.allow": "Agent-to-Agent Target Allowlist",
"tools.elevated": "Elevated Tool Access",
"tools.elevated.enabled": "Enable Elevated Tool Access",
"tools.elevated.allowFrom": "Elevated Tool Allow Rules",
"tools.subagents": "Subagent Tool Policy",
"tools.subagents.tools": "Subagent Tool Allow/Deny Policy",
"tools.sandbox": "Sandbox Tool Policy",
"tools.sandbox.tools": "Sandbox Tool Allow/Deny Policy",
"tools.exec.pathPrepend": "Exec PATH Prepend",
"tools.exec.safeBins": "Exec Safe Bins",
"tools.exec.safeBinProfiles": "Exec Safe Bin Profiles",
approvals: "Approvals",
"approvals.exec": "Exec Approval Forwarding",
"approvals.exec.enabled": "Forward Exec Approvals",
"approvals.exec.mode": "Approval Forwarding Mode",
"approvals.exec.agentFilter": "Approval Agent Filter",
"approvals.exec.sessionFilter": "Approval Session Filter",
"approvals.exec.targets": "Approval Forwarding Targets",
"approvals.exec.targets[].channel": "Approval Target Channel",
"approvals.exec.targets[].to": "Approval Target Destination",
"approvals.exec.targets[].accountId": "Approval Target Account ID",
"approvals.exec.targets[].threadId": "Approval Target Thread ID",
"tools.message.allowCrossContextSend": "Allow Cross-Context Messaging",
"tools.message.crossContext.allowWithinProvider": "Allow Cross-Context (Same Provider)",
"tools.message.crossContext.allowAcrossProviders": "Allow Cross-Context (Across Providers)",
@@ -110,12 +208,23 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.web.search.maxResults": "Web Search Max Results",
"tools.web.search.timeoutSeconds": "Web Search Timeout (sec)",
"tools.web.search.cacheTtlMinutes": "Web Search Cache TTL (min)",
"tools.web.search.perplexity.apiKey": "Perplexity API Key",
"tools.web.search.perplexity.baseUrl": "Perplexity Base URL",
"tools.web.search.perplexity.model": "Perplexity Model",
"tools.web.fetch.enabled": "Enable Web Fetch Tool",
"tools.web.fetch.maxChars": "Web Fetch Max Chars",
"tools.web.fetch.maxCharsCap": "Web Fetch Hard Max Chars",
"tools.web.fetch.timeoutSeconds": "Web Fetch Timeout (sec)",
"tools.web.fetch.cacheTtlMinutes": "Web Fetch Cache TTL (min)",
"tools.web.fetch.maxRedirects": "Web Fetch Max Redirects",
"tools.web.fetch.userAgent": "Web Fetch User-Agent",
"tools.web.fetch.readability": "Web Fetch Readability Extraction",
"tools.web.fetch.firecrawl.enabled": "Enable Firecrawl Fallback",
"tools.web.fetch.firecrawl.apiKey": "Firecrawl API Key",
"tools.web.fetch.firecrawl.baseUrl": "Firecrawl Base URL",
"tools.web.fetch.firecrawl.onlyMainContent": "Firecrawl Main Content Only",
"tools.web.fetch.firecrawl.maxAgeMs": "Firecrawl Cache Max Age (ms)",
"tools.web.fetch.firecrawl.timeoutSeconds": "Firecrawl Timeout (sec)",
"gateway.controlUi.basePath": "Control UI Base Path",
"gateway.controlUi.root": "Control UI Assets Root",
"gateway.controlUi.allowedOrigins": "Control UI Allowed Origins",
@@ -128,8 +237,30 @@ export const FIELD_LABELS: Record<string, string> = {
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
"gateway.nodes.denyCommands": "Gateway Node Denylist",
nodeHost: "Node Host",
"nodeHost.browserProxy": "Node Browser Proxy",
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
media: "Media",
"media.preserveFilenames": "Preserve Media Filenames",
audio: "Audio",
"audio.transcription": "Audio Transcription",
"audio.transcription.command": "Audio Transcription Command",
"audio.transcription.timeoutSeconds": "Audio Transcription Timeout (sec)",
bindings: "Bindings",
"bindings[].agentId": "Binding Agent ID",
"bindings[].match": "Binding Match Rule",
"bindings[].match.channel": "Binding Channel",
"bindings[].match.accountId": "Binding Account ID",
"bindings[].match.peer": "Binding Peer Match",
"bindings[].match.peer.kind": "Binding Peer Kind",
"bindings[].match.peer.id": "Binding Peer ID",
"bindings[].match.guildId": "Binding Guild ID",
"bindings[].match.teamId": "Binding Team ID",
"bindings[].match.roles": "Binding Roles",
broadcast: "Broadcast",
"broadcast.strategy": "Broadcast Strategy",
"broadcast.*": "Broadcast Destination List",
"skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace",
@@ -149,7 +280,11 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.memorySearch.remote.baseUrl": "Remote Embedding Base URL",
"agents.defaults.memorySearch.remote.apiKey": "Remote Embedding API Key",
"agents.defaults.memorySearch.remote.headers": "Remote Embedding Headers",
"agents.defaults.memorySearch.remote.batch.enabled": "Remote Batch Embedding Enabled",
"agents.defaults.memorySearch.remote.batch.wait": "Remote Batch Wait for Completion",
"agents.defaults.memorySearch.remote.batch.concurrency": "Remote Batch Concurrency",
"agents.defaults.memorySearch.remote.batch.pollIntervalMs": "Remote Batch Poll Interval (ms)",
"agents.defaults.memorySearch.remote.batch.timeoutMinutes": "Remote Batch Timeout (min)",
"agents.defaults.memorySearch.model": "Memory Search Model",
"agents.defaults.memorySearch.fallback": "Memory Search Fallback",
"agents.defaults.memorySearch.local.modelPath": "Local Embedding Model Path",
@@ -182,6 +317,11 @@ export const FIELD_LABELS: Record<string, string> = {
"memory.backend": "Memory Backend",
"memory.citations": "Memory Citations Mode",
"memory.qmd.command": "QMD Binary",
"memory.qmd.mcporter": "QMD MCPorter",
"memory.qmd.mcporter.enabled": "QMD MCPorter Enabled",
"memory.qmd.mcporter.serverName": "QMD MCPorter Server Name",
"memory.qmd.mcporter.startDaemon": "QMD MCPorter Start Daemon",
"memory.qmd.searchMode": "QMD Search Mode",
"memory.qmd.includeDefaultMemory": "QMD Include Default Memory",
"memory.qmd.paths": "QMD Extra Paths",
"memory.qmd.paths.path": "QMD Path",
@@ -203,8 +343,27 @@ export const FIELD_LABELS: Record<string, string> = {
"memory.qmd.limits.maxInjectedChars": "QMD Max Injected Chars",
"memory.qmd.limits.timeoutMs": "QMD Search Timeout (ms)",
"memory.qmd.scope": "QMD Surface Scope",
auth: "Auth",
"auth.profiles": "Auth Profiles",
"auth.order": "Auth Profile Order",
"auth.cooldowns": "Auth Cooldowns",
models: "Models",
"models.mode": "Model Catalog Mode",
"models.providers": "Model Providers",
"models.providers.*.baseUrl": "Model Provider Base URL",
"models.providers.*.apiKey": "Model Provider API Key",
"models.providers.*.auth": "Model Provider Auth Mode",
"models.providers.*.api": "Model Provider API Adapter",
"models.providers.*.headers": "Model Provider Headers",
"models.providers.*.authHeader": "Model Provider Authorization Header",
"models.providers.*.models": "Model Provider Model List",
"models.bedrockDiscovery": "Bedrock Model Discovery",
"models.bedrockDiscovery.enabled": "Bedrock Discovery Enabled",
"models.bedrockDiscovery.region": "Bedrock Discovery Region",
"models.bedrockDiscovery.providerFilter": "Bedrock Discovery Provider Filter",
"models.bedrockDiscovery.refreshInterval": "Bedrock Discovery Refresh Interval (s)",
"models.bedrockDiscovery.defaultContextWindow": "Bedrock Default Context Window",
"models.bedrockDiscovery.defaultMaxTokens": "Bedrock Default Max Tokens",
"auth.cooldowns.billingBackoffHours": "Billing Backoff (hours)",
"auth.cooldowns.billingBackoffHoursByProvider": "Billing Backoff Overrides",
"auth.cooldowns.billingMaxHours": "Billing Backoff Cap (hours)",
@@ -219,6 +378,22 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
"agents.defaults.cliBackends": "CLI Backends",
"agents.defaults.compaction": "Compaction",
"agents.defaults.compaction.mode": "Compaction Mode",
"agents.defaults.compaction.reserveTokens": "Compaction Reserve Tokens",
"agents.defaults.compaction.keepRecentTokens": "Compaction Keep Recent Tokens",
"agents.defaults.compaction.reserveTokensFloor": "Compaction Reserve Token Floor",
"agents.defaults.compaction.maxHistoryShare": "Compaction Max History Share",
"agents.defaults.compaction.memoryFlush": "Compaction Memory Flush",
"agents.defaults.compaction.memoryFlush.enabled": "Compaction Memory Flush Enabled",
"agents.defaults.compaction.memoryFlush.softThresholdTokens":
"Compaction Memory Flush Soft Threshold",
"agents.defaults.compaction.memoryFlush.prompt": "Compaction Memory Flush Prompt",
"agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt",
"agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings",
"agents.defaults.sandbox.browser.network": "Sandbox Browser Network",
"agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range",
commands: "Commands",
"commands.native": "Native Commands",
"commands.nativeSkills": "Native Skill Commands",
"commands.text": "Text Commands",
@@ -231,7 +406,10 @@ export const FIELD_LABELS: Record<string, string> = {
"commands.ownerAllowFrom": "Command Owners",
"commands.ownerDisplay": "Owner ID Display",
"commands.ownerDisplaySecret": "Owner ID Hash Secret",
"commands.allowFrom": "Command Elevated Access Rules",
ui: "UI",
"ui.seamColor": "Accent Color",
"ui.assistant": "Assistant Appearance",
"ui.assistant.name": "Assistant Name",
"ui.assistant.avatar": "Assistant Avatar",
"browser.evaluateEnabled": "Browser Evaluate Enabled",
@@ -243,19 +421,174 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.ssrfPolicy.hostnameAllowlist": "Browser Hostname Allowlist",
"browser.remoteCdpTimeoutMs": "Remote CDP Timeout (ms)",
"browser.remoteCdpHandshakeTimeoutMs": "Remote CDP Handshake Timeout (ms)",
session: "Session",
"session.scope": "Session Scope",
"session.dmScope": "DM Session Scope",
"session.identityLinks": "Session Identity Links",
"session.resetTriggers": "Session Reset Triggers",
"session.idleMinutes": "Session Idle Minutes",
"session.reset": "Session Reset Policy",
"session.reset.mode": "Session Reset Mode",
"session.reset.atHour": "Session Daily Reset Hour",
"session.reset.idleMinutes": "Session Reset Idle Minutes",
"session.resetByType": "Session Reset by Chat Type",
"session.resetByType.direct": "Session Reset (Direct)",
"session.resetByType.dm": "Session Reset (DM Deprecated Alias)",
"session.resetByType.group": "Session Reset (Group)",
"session.resetByType.thread": "Session Reset (Thread)",
"session.resetByChannel": "Session Reset by Channel",
"session.store": "Session Store Path",
"session.typingIntervalSeconds": "Session Typing Interval (seconds)",
"session.typingMode": "Session Typing Mode",
"session.mainKey": "Session Main Key",
"session.sendPolicy": "Session Send Policy",
"session.sendPolicy.default": "Session Send Policy Default Action",
"session.sendPolicy.rules": "Session Send Policy Rules",
"session.sendPolicy.rules[].action": "Session Send Rule Action",
"session.sendPolicy.rules[].match": "Session Send Rule Match",
"session.sendPolicy.rules[].match.channel": "Session Send Rule Channel",
"session.sendPolicy.rules[].match.chatType": "Session Send Rule Chat Type",
"session.sendPolicy.rules[].match.keyPrefix": "Session Send Rule Key Prefix",
"session.sendPolicy.rules[].match.rawKeyPrefix": "Session Send Rule Raw Key Prefix",
"session.agentToAgent": "Session Agent-to-Agent",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"session.threadBindings": "Session Thread Bindings",
"session.threadBindings.enabled": "Thread Binding Enabled",
"session.threadBindings.ttlHours": "Thread Binding TTL (hours)",
"session.agentToAgent.maxPingPongTurns": "Agent-to-Agent Ping-Pong Turns",
"session.maintenance": "Session Maintenance",
"session.maintenance.mode": "Session Maintenance Mode",
"session.maintenance.pruneAfter": "Session Prune After",
"session.maintenance.pruneDays": "Session Prune Days (Deprecated)",
"session.maintenance.maxEntries": "Session Max Entries",
"session.maintenance.rotateBytes": "Session Rotate Size",
cron: "Cron",
"cron.enabled": "Cron Enabled",
"cron.store": "Cron Store Path",
"cron.maxConcurrentRuns": "Cron Max Concurrent Runs",
"cron.webhook": "Cron Legacy Webhook (Deprecated)",
"cron.webhookToken": "Cron Webhook Bearer Token",
"cron.sessionRetention": "Cron Session Retention",
hooks: "Hooks",
"hooks.enabled": "Hooks Enabled",
"hooks.path": "Hooks Endpoint Path",
"hooks.token": "Hooks Auth Token",
"hooks.defaultSessionKey": "Hooks Default Session Key",
"hooks.allowRequestSessionKey": "Hooks Allow Request Session Key",
"hooks.allowedSessionKeyPrefixes": "Hooks Allowed Session Key Prefixes",
"hooks.allowedAgentIds": "Hooks Allowed Agent IDs",
"hooks.maxBodyBytes": "Hooks Max Body Bytes",
"hooks.presets": "Hooks Presets",
"hooks.transformsDir": "Hooks Transforms Directory",
"hooks.mappings": "Hook Mappings",
"hooks.mappings[].id": "Hook Mapping ID",
"hooks.mappings[].match": "Hook Mapping Match",
"hooks.mappings[].match.path": "Hook Mapping Match Path",
"hooks.mappings[].match.source": "Hook Mapping Match Source",
"hooks.mappings[].action": "Hook Mapping Action",
"hooks.mappings[].wakeMode": "Hook Mapping Wake Mode",
"hooks.mappings[].name": "Hook Mapping Name",
"hooks.mappings[].agentId": "Hook Mapping Agent ID",
"hooks.mappings[].sessionKey": "Hook Mapping Session Key",
"hooks.mappings[].messageTemplate": "Hook Mapping Message Template",
"hooks.mappings[].textTemplate": "Hook Mapping Text Template",
"hooks.mappings[].deliver": "Hook Mapping Deliver Reply",
"hooks.mappings[].allowUnsafeExternalContent": "Hook Mapping Allow Unsafe External Content",
"hooks.mappings[].channel": "Hook Mapping Delivery Channel",
"hooks.mappings[].to": "Hook Mapping Delivery Destination",
"hooks.mappings[].model": "Hook Mapping Model Override",
"hooks.mappings[].thinking": "Hook Mapping Thinking Override",
"hooks.mappings[].timeoutSeconds": "Hook Mapping Timeout (sec)",
"hooks.mappings[].transform": "Hook Mapping Transform",
"hooks.mappings[].transform.module": "Hook Transform Module",
"hooks.mappings[].transform.export": "Hook Transform Export",
"hooks.gmail": "Gmail Hook",
"hooks.gmail.account": "Gmail Hook Account",
"hooks.gmail.label": "Gmail Hook Label",
"hooks.gmail.topic": "Gmail Hook Pub/Sub Topic",
"hooks.gmail.subscription": "Gmail Hook Subscription",
"hooks.gmail.pushToken": "Gmail Hook Push Token",
"hooks.gmail.hookUrl": "Gmail Hook Callback URL",
"hooks.gmail.includeBody": "Gmail Hook Include Body",
"hooks.gmail.maxBytes": "Gmail Hook Max Body Bytes",
"hooks.gmail.renewEveryMinutes": "Gmail Hook Renew Interval (min)",
"hooks.gmail.allowUnsafeExternalContent": "Gmail Hook Allow Unsafe External Content",
"hooks.gmail.serve": "Gmail Hook Local Server",
"hooks.gmail.serve.bind": "Gmail Hook Server Bind Address",
"hooks.gmail.serve.port": "Gmail Hook Server Port",
"hooks.gmail.serve.path": "Gmail Hook Server Path",
"hooks.gmail.tailscale": "Gmail Hook Tailscale",
"hooks.gmail.tailscale.mode": "Gmail Hook Tailscale Mode",
"hooks.gmail.tailscale.path": "Gmail Hook Tailscale Path",
"hooks.gmail.tailscale.target": "Gmail Hook Tailscale Target",
"hooks.gmail.model": "Gmail Hook Model Override",
"hooks.gmail.thinking": "Gmail Hook Thinking Override",
"hooks.internal": "Internal Hooks",
"hooks.internal.enabled": "Internal Hooks Enabled",
"hooks.internal.handlers": "Internal Hook Handlers",
"hooks.internal.handlers[].event": "Internal Hook Event",
"hooks.internal.handlers[].module": "Internal Hook Module",
"hooks.internal.handlers[].export": "Internal Hook Export",
"hooks.internal.entries": "Internal Hook Entries",
"hooks.internal.load": "Internal Hook Loader",
"hooks.internal.load.extraDirs": "Internal Hook Extra Directories",
"hooks.internal.installs": "Internal Hook Install Records",
web: "Web Channel",
"web.enabled": "Web Channel Enabled",
"web.heartbeatSeconds": "Web Channel Heartbeat Interval (sec)",
"web.reconnect": "Web Channel Reconnect Policy",
"web.reconnect.initialMs": "Web Reconnect Initial Delay (ms)",
"web.reconnect.maxMs": "Web Reconnect Max Delay (ms)",
"web.reconnect.factor": "Web Reconnect Backoff Factor",
"web.reconnect.jitter": "Web Reconnect Jitter",
"web.reconnect.maxAttempts": "Web Reconnect Max Attempts",
discovery: "Discovery",
"discovery.wideArea": "Wide-area Discovery",
"discovery.wideArea.enabled": "Wide-area Discovery Enabled",
"discovery.mdns": "mDNS Discovery",
canvasHost: "Canvas Host",
"canvasHost.enabled": "Canvas Host Enabled",
"canvasHost.root": "Canvas Host Root Directory",
"canvasHost.port": "Canvas Host Port",
"canvasHost.liveReload": "Canvas Host Live Reload",
talk: "Talk",
"talk.voiceId": "Talk Voice ID",
"talk.voiceAliases": "Talk Voice Aliases",
"talk.modelId": "Talk Model ID",
"talk.outputFormat": "Talk Output Format",
"talk.interruptOnSpeech": "Talk Interrupt on Speech",
messages: "Messages",
"messages.messagePrefix": "Inbound Message Prefix",
"messages.responsePrefix": "Outbound Response Prefix",
"messages.groupChat": "Group Chat Rules",
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
"messages.groupChat.historyLimit": "Group History Limit",
"messages.queue": "Inbound Queue",
"messages.queue.mode": "Queue Mode",
"messages.queue.byChannel": "Queue Mode by Channel",
"messages.queue.debounceMs": "Queue Debounce (ms)",
"messages.queue.debounceMsByChannel": "Queue Debounce by Channel (ms)",
"messages.queue.cap": "Queue Capacity",
"messages.queue.drop": "Queue Drop Strategy",
"messages.inbound": "Inbound Debounce",
"messages.suppressToolErrors": "Suppress Tool Error Warnings",
"messages.ackReaction": "Ack Reaction Emoji",
"messages.ackReactionScope": "Ack Reaction Scope",
"messages.removeAckAfterReply": "Remove Ack Reaction After Reply",
"messages.statusReactions": "Status Reactions",
"messages.statusReactions.enabled": "Enable Status Reactions",
"messages.statusReactions.emojis": "Status Reaction Emojis",
"messages.statusReactions.timing": "Status Reaction Timing",
"messages.inbound.debounceMs": "Inbound Message Debounce (ms)",
"messages.inbound.byChannel": "Inbound Debounce by Channel (ms)",
"messages.tts": "Message Text-to-Speech",
"talk.apiKey": "Talk API Key",
channels: "Channels",
"channels.defaults": "Channel Defaults",
"channels.defaults.groupPolicy": "Default Group Policy",
"channels.defaults.heartbeat": "Default Heartbeat Visibility",
"channels.defaults.heartbeat.showOk": "Heartbeat Show OK",
"channels.defaults.heartbeat.showAlerts": "Heartbeat Show Alerts",
"channels.defaults.heartbeat.useIndicator": "Heartbeat Use Indicator",
"channels.whatsapp": "WhatsApp",
"channels.telegram": "Telegram",
"channels.telegram.customCommands": "Telegram Custom Commands",
@@ -270,6 +603,9 @@ export const FIELD_LABELS: Record<string, string> = {
...IRC_FIELD_LABELS,
"channels.telegram.botToken": "Telegram Bot Token",
"channels.telegram.dmPolicy": "Telegram DM Policy",
"channels.telegram.configWrites": "Telegram Config Writes",
"channels.telegram.commands.native": "Telegram Native Commands",
"channels.telegram.commands.nativeSkills": "Telegram Native Skill Commands",
"channels.telegram.streaming": "Telegram Streaming Mode",
"channels.telegram.retry.attempts": "Telegram Retry Attempts",
"channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)",
@@ -281,11 +617,20 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.whatsapp.dmPolicy": "WhatsApp DM Policy",
"channels.whatsapp.selfChatMode": "WhatsApp Self-Phone Mode",
"channels.whatsapp.debounceMs": "WhatsApp Message Debounce (ms)",
"channels.whatsapp.configWrites": "WhatsApp Config Writes",
"channels.signal.dmPolicy": "Signal DM Policy",
"channels.signal.configWrites": "Signal Config Writes",
"channels.imessage.dmPolicy": "iMessage DM Policy",
"channels.imessage.configWrites": "iMessage Config Writes",
"channels.bluebubbles.dmPolicy": "BlueBubbles DM Policy",
"channels.msteams.configWrites": "MS Teams Config Writes",
"channels.irc.configWrites": "IRC Config Writes",
"channels.discord.dmPolicy": "Discord DM Policy",
"channels.discord.dm.policy": "Discord DM Policy",
"channels.discord.configWrites": "Discord Config Writes",
"channels.discord.proxy": "Discord Proxy URL",
"channels.discord.commands.native": "Discord Native Commands",
"channels.discord.commands.nativeSkills": "Discord Native Skill Commands",
"channels.discord.streaming": "Discord Streaming Mode",
"channels.discord.streamMode": "Discord Stream Mode (Legacy)",
"channels.discord.draftChunk.minChars": "Discord Draft Chunk Min Chars",
@@ -304,6 +649,7 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.discord.intents.guildMembers": "Discord Guild Members Intent",
"channels.discord.voice.enabled": "Discord Voice Enabled",
"channels.discord.voice.autoJoin": "Discord Voice Auto-Join",
"channels.discord.voice.tts": "Discord Voice Text-to-Speech",
"channels.discord.pluralkit.enabled": "Discord PluralKit Enabled",
"channels.discord.pluralkit.token": "Discord PluralKit Token",
"channels.discord.activity": "Discord Presence Activity",
@@ -312,6 +658,9 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.discord.activityUrl": "Discord Presence Activity URL",
"channels.slack.dm.policy": "Slack DM Policy",
"channels.slack.dmPolicy": "Slack DM Policy",
"channels.slack.configWrites": "Slack Config Writes",
"channels.slack.commands.native": "Slack Native Commands",
"channels.slack.commands.nativeSkills": "Slack Native Skill Commands",
"channels.slack.allowBots": "Slack Allow Bot Messages",
"channels.discord.token": "Discord Bot Token",
"channels.slack.botToken": "Slack Bot Token",
@@ -326,6 +675,7 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.slack.thread.initialHistoryLimit": "Slack Thread Initial History Limit",
"channels.mattermost.botToken": "Mattermost Bot Token",
"channels.mattermost.baseUrl": "Mattermost Base URL",
"channels.mattermost.configWrites": "Mattermost Config Writes",
"channels.mattermost.chatmode": "Mattermost Chat Mode",
"channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes",
"channels.mattermost.requireMention": "Mattermost Require Mention",
@@ -333,15 +683,23 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.imessage.cliPath": "iMessage CLI Path",
"agents.list[].skills": "Agent Skill Filter",
"agents.list[].identity.avatar": "Agent Avatar",
"agents.list[].heartbeat.suppressToolErrorWarnings":
"Agent Heartbeat Suppress Tool Error Warnings",
"agents.list[].sandbox.browser.network": "Agent Sandbox Browser Network",
"agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range",
"discovery.mdns.mode": "mDNS Discovery Mode",
plugins: "Plugins",
"plugins.enabled": "Enable Plugins",
"plugins.allow": "Plugin Allowlist",
"plugins.deny": "Plugin Denylist",
"plugins.load": "Plugin Loader",
"plugins.load.paths": "Plugin Load Paths",
"plugins.slots": "Plugin Slots",
"plugins.slots.memory": "Memory Plugin",
"plugins.entries": "Plugin Entries",
"plugins.entries.*.enabled": "Plugin Enabled",
"plugins.entries.*.apiKey": "Plugin API Key",
"plugins.entries.*.env": "Plugin Environment Variables",
"plugins.entries.*.config": "Plugin Config",
"plugins.installs": "Plugin Install Records",
"plugins.installs.*.source": "Plugin Install Source",

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { buildConfigSchema } from "./schema.js";
import { applyDerivedTags, CONFIG_TAGS, deriveTagsForPath } from "./schema.tags.js";
describe("config schema tags", () => {
it("derives security/auth tags for credential paths", () => {
const tags = deriveTagsForPath("gateway.auth.token");
expect(tags).toContain("security");
expect(tags).toContain("auth");
});
it("derives tools/performance tags for web fetch timeout paths", () => {
const tags = deriveTagsForPath("tools.web.fetch.timeoutSeconds");
expect(tags).toContain("tools");
expect(tags).toContain("performance");
});
it("keeps tags in the allowed taxonomy", () => {
const withTags = applyDerivedTags({
"gateway.auth.token": {},
"tools.web.fetch.timeoutSeconds": {},
"channels.slack.accounts.*.token": {},
});
const allowed = new Set<string>(CONFIG_TAGS);
for (const hint of Object.values(withTags)) {
for (const tag of hint.tags ?? []) {
expect(allowed.has(tag)).toBe(true);
}
}
});
it("covers core/built-in config paths with tags", () => {
const schema = buildConfigSchema();
const allowed = new Set<string>(CONFIG_TAGS);
for (const [key, hint] of Object.entries(schema.uiHints)) {
if (!key.includes(".")) {
continue;
}
const tags = hint.tags ?? [];
expect(tags.length, `expected tags for ${key}`).toBeGreaterThan(0);
for (const tag of tags) {
expect(allowed.has(tag), `unexpected tag ${tag} on ${key}`).toBe(true);
}
}
});
});

180
src/config/schema.tags.ts Normal file
View File

@@ -0,0 +1,180 @@
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
export const CONFIG_TAGS = [
"security",
"auth",
"network",
"access",
"privacy",
"observability",
"performance",
"reliability",
"storage",
"models",
"media",
"automation",
"channels",
"tools",
"advanced",
] as const;
export type ConfigTag = (typeof CONFIG_TAGS)[number];
const TAG_PRIORITY: Record<ConfigTag, number> = {
security: 0,
auth: 1,
access: 2,
network: 3,
privacy: 4,
observability: 5,
reliability: 6,
performance: 7,
storage: 8,
models: 9,
media: 10,
automation: 11,
channels: 12,
tools: 13,
advanced: 14,
};
const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
"gateway.auth.token": ["security", "auth", "access", "network"],
"gateway.auth.password": ["security", "auth", "access", "network"],
"gateway.controlUi.dangerouslyDisableDeviceAuth": ["security", "access", "network", "advanced"],
"gateway.controlUi.allowInsecureAuth": ["security", "access", "network", "advanced"],
"tools.exec.applyPatch.workspaceOnly": ["tools", "security", "access", "advanced"],
};
const PREFIX_RULES: Array<{ prefix: string; tags: ConfigTag[] }> = [
{ prefix: "channels.", tags: ["channels", "network"] },
{ prefix: "tools.", tags: ["tools"] },
{ prefix: "gateway.", tags: ["network"] },
{ prefix: "nodehost.", tags: ["network"] },
{ prefix: "discovery.", tags: ["network"] },
{ prefix: "auth.", tags: ["auth", "access"] },
{ prefix: "memory.", tags: ["storage"] },
{ prefix: "models.", tags: ["models"] },
{ prefix: "diagnostics.", tags: ["observability"] },
{ prefix: "logging.", tags: ["observability"] },
{ prefix: "cron.", tags: ["automation"] },
{ prefix: "talk.", tags: ["media"] },
{ prefix: "audio.", tags: ["media"] },
];
const KEYWORD_RULES: Array<{ pattern: RegExp; tags: ConfigTag[] }> = [
{ pattern: /(token|password|secret|api[_.-]?key|tlsfingerprint)/i, tags: ["security", "auth"] },
{ pattern: /(allow|deny|owner|permission|policy|access)/i, tags: ["access"] },
{ pattern: /(timeout|debounce|interval|concurrency|max|limit|cachettl)/i, tags: ["performance"] },
{ pattern: /(retry|backoff|fallback|circuit|health|reload|probe)/i, tags: ["reliability"] },
{ pattern: /(path|dir|file|store|db|session|cache)/i, tags: ["storage"] },
{ pattern: /(telemetry|trace|metrics|logs|diagnostic)/i, tags: ["observability"] },
{ pattern: /(experimental|dangerously|insecure)/i, tags: ["advanced", "security"] },
{ pattern: /(privacy|redact|sanitize|anonym|pseudonym)/i, tags: ["privacy"] },
];
const MODEL_PATH_PATTERN = /(^|\.)(model|models|modelid|imagemodel)(\.|$)/i;
const MEDIA_PATH_PATTERN = /(tools\.media\.|^audio\.|^talk\.|image|video|stt|tts)/i;
const AUTOMATION_PATH_PATTERN = /(cron|heartbeat|schedule|onstart|watchdebounce)/i;
const AUTH_KEYWORD_PATTERN = /(token|password|secret|api[_.-]?key|credential|oauth)/i;
function normalizeTag(tag: string): ConfigTag | null {
const normalized = tag.trim().toLowerCase() as ConfigTag;
return CONFIG_TAGS.includes(normalized) ? normalized : null;
}
function normalizeTags(tags: ReadonlyArray<string>): ConfigTag[] {
const out = new Set<ConfigTag>();
for (const tag of tags) {
const normalized = normalizeTag(tag);
if (normalized) {
out.add(normalized);
}
}
return [...out].toSorted((a, b) => TAG_PRIORITY[a] - TAG_PRIORITY[b]);
}
function patternToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^.]+");
return new RegExp(`^${escaped}$`, "i");
}
function resolveOverride(path: string): ConfigTag[] | undefined {
const direct = TAG_OVERRIDES[path];
if (direct) {
return direct;
}
for (const [pattern, tags] of Object.entries(TAG_OVERRIDES)) {
if (!pattern.includes("*")) {
continue;
}
if (patternToRegExp(pattern).test(path)) {
return tags;
}
}
return undefined;
}
function addTags(set: Set<ConfigTag>, tags: ReadonlyArray<ConfigTag>): void {
for (const tag of tags) {
set.add(tag);
}
}
export function deriveTagsForPath(path: string, hint?: ConfigUiHint): ConfigTag[] {
const lowerPath = path.toLowerCase();
const override = resolveOverride(path);
if (override) {
return normalizeTags(override);
}
const tags = new Set<ConfigTag>();
for (const rule of PREFIX_RULES) {
if (lowerPath.startsWith(rule.prefix)) {
addTags(tags, rule.tags);
}
}
for (const rule of KEYWORD_RULES) {
if (rule.pattern.test(path)) {
addTags(tags, rule.tags);
}
}
if (MODEL_PATH_PATTERN.test(path)) {
tags.add("models");
}
if (MEDIA_PATH_PATTERN.test(path)) {
tags.add("media");
}
if (AUTOMATION_PATH_PATTERN.test(path)) {
tags.add("automation");
}
if (hint?.sensitive) {
tags.add("security");
if (AUTH_KEYWORD_PATTERN.test(path)) {
tags.add("auth");
}
}
if (hint?.advanced) {
tags.add("advanced");
}
if (tags.size === 0) {
tags.add("advanced");
}
return normalizeTags([...tags]);
}
export function applyDerivedTags(hints: ConfigUiHints): ConfigUiHints {
const next: ConfigUiHints = {};
for (const [path, hint] of Object.entries(hints)) {
const existingTags = Array.isArray(hint?.tags) ? hint.tags : [];
const derivedTags = deriveTagsForPath(path, hint);
const tags = normalizeTags([...derivedTags, ...existingTags]);
next[path] = { ...hint, tags };
}
return next;
}

View File

@@ -2,6 +2,7 @@ import { CHANNEL_IDS } from "../channels/registry.js";
import { VERSION } from "../version.js";
import type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
import { applySensitiveHints, buildBaseHints, mapSensitivePaths } from "./schema.hints.js";
import { applyDerivedTags } from "./schema.tags.js";
import { OpenClawSchema } from "./zod-schema.js";
export type { ConfigUiHint, ConfigUiHints } from "./schema.hints.js";
@@ -75,7 +76,7 @@ export type PluginUiMetadata = {
description?: string;
configUiHints?: Record<
string,
Pick<ConfigUiHint, "label" | "help" | "advanced" | "sensitive" | "placeholder">
Pick<ConfigUiHint, "label" | "help" | "tags" | "advanced" | "sensitive" | "placeholder">
>;
configSchema?: JsonSchemaNode;
};
@@ -327,7 +328,7 @@ function buildBaseConfigSchema(): ConfigSchemaResponse {
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const hints = mapSensitivePaths(OpenClawSchema, "", buildBaseHints());
const hints = applyDerivedTags(mapSensitivePaths(OpenClawSchema, "", buildBaseHints()));
const next = {
schema: stripChannelSchema(schema),
uiHints: hints,
@@ -357,7 +358,9 @@ export function buildConfigSchema(params?: {
plugins,
channels,
);
const mergedHints = applySensitiveHints(mergedWithoutSensitiveHints, extensionHintKeys);
const mergedHints = applyDerivedTags(
applySensitiveHints(mergedWithoutSensitiveHints, extensionHintKeys),
);
const mergedSchema = applyChannelSchemas(applyPluginSchemas(base.schema, plugins), channels);
return {
...base,

View File

@@ -41,6 +41,7 @@ export const ConfigUiHintSchema = Type.Object(
{
label: Type.Optional(Type.String()),
help: Type.Optional(Type.String()),
tags: Type.Optional(Type.Array(Type.String())),
group: Type.Optional(Type.String()),
order: Type.Optional(Type.Integer()),
advanced: Type.Optional(Type.Boolean()),

View File

@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import type { AuthRateLimiter } from "../../auth-rate-limit.js";
import { resolveConnectAuthDecision, type ConnectAuthState } from "./auth-context.js";
type VerifyDeviceTokenFn = Parameters<typeof resolveConnectAuthDecision>[0]["verifyDeviceToken"];
function createRateLimiter(params?: { allowed?: boolean; retryAfterMs?: number }): {
limiter: AuthRateLimiter;
reset: ReturnType<typeof vi.fn>;
@@ -35,7 +37,7 @@ function createBaseState(overrides?: Partial<ConnectAuthState>): ConnectAuthStat
}
async function resolveDeviceTokenDecision(params: {
verifyDeviceToken: ReturnType<typeof vi.fn>;
verifyDeviceToken: VerifyDeviceTokenFn;
stateOverrides?: Partial<ConnectAuthState>;
rateLimiter?: AuthRateLimiter;
clientIp?: string;
@@ -54,7 +56,7 @@ async function resolveDeviceTokenDecision(params: {
describe("resolveConnectAuthDecision", () => {
it("keeps shared-secret mismatch when fallback device-token check fails", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState(),
hasDeviceIdentity: true,
@@ -69,7 +71,7 @@ describe("resolveConnectAuthDecision", () => {
});
it("reports explicit device-token mismatches as device_token_mismatch", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: false }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: false }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
deviceTokenCandidateSource: "explicit-device-token",
@@ -86,7 +88,7 @@ describe("resolveConnectAuthDecision", () => {
it("accepts valid device tokens and marks auth method as device-token", async () => {
const rateLimiter = createRateLimiter();
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveDeviceTokenDecision({
verifyDeviceToken,
rateLimiter: rateLimiter.limiter,
@@ -100,7 +102,7 @@ describe("resolveConnectAuthDecision", () => {
it("returns rate-limited auth result without verifying device token", async () => {
const rateLimiter = createRateLimiter({ allowed: false, retryAfterMs: 60_000 });
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveDeviceTokenDecision({
verifyDeviceToken,
rateLimiter: rateLimiter.limiter,
@@ -113,7 +115,7 @@ describe("resolveConnectAuthDecision", () => {
});
it("returns the original decision when device fallback does not apply", async () => {
const verifyDeviceToken = vi.fn(async () => ({ ok: true }));
const verifyDeviceToken = vi.fn<VerifyDeviceTokenFn>(async () => ({ ok: true }));
const decision = await resolveConnectAuthDecision({
state: createBaseState({
authResult: { ok: true, method: "token" },

View File

@@ -29,6 +29,7 @@ export type PluginLogger = {
export type PluginConfigUiHint = {
label?: string;
help?: string;
tags?: string[];
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;

View File

@@ -72,12 +72,19 @@ export function resolveSlackThreadTs(params: {
incomingThreadTs: string | undefined;
messageTs: string | undefined;
hasReplied: boolean;
isThreadReply?: boolean;
}): string | undefined {
const isThreadReply =
params.isThreadReply ??
(typeof params.incomingThreadTs === "string" &&
params.incomingThreadTs.length > 0 &&
params.incomingThreadTs !== params.messageTs);
const planner = createSlackReplyReferencePlanner({
replyToMode: params.replyToMode,
incomingThreadTs: params.incomingThreadTs,
messageTs: params.messageTs,
hasReplied: params.hasReplied,
isThreadReply,
});
return planner.use();
}

View File

@@ -59,13 +59,13 @@ function createHarness(params?: {
describe("tui command handlers", () => {
it("renders the sending indicator before chat.send resolves", async () => {
let resolveSend: ((value: { runId: string }) => void) | null = null;
const sendChat = vi.fn(
() =>
new Promise<{ runId: string }>((resolve) => {
resolveSend = resolve;
}),
);
let resolveSend: (value: { runId: string }) => void = () => {
throw new Error("sendChat promise resolver was not initialized");
};
const sendPromise = new Promise<{ runId: string }>((resolve) => {
resolveSend = (value) => resolve(value);
});
const sendChat = vi.fn(() => sendPromise);
const setActivityStatus = vi.fn();
const { handleCommand, requestRender } = createHarness({
@@ -81,10 +81,7 @@ describe("tui command handlers", () => {
const renderOrders = requestRender.mock.invocationCallOrder;
expect(renderOrders.some((order) => order > sendingOrder)).toBe(true);
if (typeof resolveSend !== "function") {
throw new Error("expected sendChat to be pending");
}
(resolveSend as (value: { runId: string }) => void)({ runId: "r1" });
resolveSend({ runId: "r1" });
await pending;
expect(setActivityStatus).toHaveBeenCalledWith("waiting");
});

View File

@@ -30,12 +30,24 @@ const envSnapshot = () => ({
PATH: process.env.PATH,
SHERPA_ONNX_MODEL_DIR: process.env.SHERPA_ONNX_MODEL_DIR,
WHISPER_CPP_MODEL: process.env.WHISPER_CPP_MODEL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GROQ_API_KEY: process.env.GROQ_API_KEY,
DEEPGRAM_API_KEY: process.env.DEEPGRAM_API_KEY,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
});
const restoreEnv = (snapshot: ReturnType<typeof envSnapshot>) => {
process.env.PATH = snapshot.PATH;
process.env.SHERPA_ONNX_MODEL_DIR = snapshot.SHERPA_ONNX_MODEL_DIR;
process.env.WHISPER_CPP_MODEL = snapshot.WHISPER_CPP_MODEL;
process.env.OPENAI_API_KEY = snapshot.OPENAI_API_KEY;
process.env.GROQ_API_KEY = snapshot.GROQ_API_KEY;
process.env.DEEPGRAM_API_KEY = snapshot.DEEPGRAM_API_KEY;
process.env.GEMINI_API_KEY = snapshot.GEMINI_API_KEY;
process.env.OPENCLAW_AGENT_DIR = snapshot.OPENCLAW_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = snapshot.PI_CODING_AGENT_DIR;
};
const withEnvSnapshot = async <T>(run: () => Promise<T>): Promise<T> => {
@@ -176,9 +188,16 @@ describe("media understanding auto-detect (e2e)", () => {
it("skips auto-detect when no supported binaries are available", async () => {
await withEnvSnapshot(async () => {
const emptyBinDir = await createTrackedTempDir(tempPaths, "openclaw-bin-empty-");
const isolatedAgentDir = await createTrackedTempDir(tempPaths, "openclaw-agent-empty-");
process.env.PATH = emptyBinDir;
delete process.env.SHERPA_ONNX_MODEL_DIR;
delete process.env.WHISPER_CPP_MODEL;
delete process.env.OPENAI_API_KEY;
delete process.env.GROQ_API_KEY;
delete process.env.DEEPGRAM_API_KEY;
delete process.env.GEMINI_API_KEY;
process.env.OPENCLAW_AGENT_DIR = isolatedAgentDir;
process.env.PI_CODING_AGENT_DIR = isolatedAgentDir;
const filePath = await createTrackedTempMedia(tempPaths, ".wav");
const ctx: MsgContext = {

View File

@@ -53,14 +53,19 @@
/* Search */
.config-search {
position: relative;
padding: 14px;
display: grid;
gap: 6px;
padding: 12px 14px 10px;
border-bottom: 1px solid var(--border);
}
.config-search__input-row {
position: relative;
}
.config-search__icon {
position: absolute;
left: 28px;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
@@ -103,7 +108,7 @@
.config-search__clear {
position: absolute;
right: 22px;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
@@ -128,6 +133,131 @@
color: var(--text);
}
.config-search__hint {
display: grid;
gap: 6px;
}
.config-search__hint-label {
font-size: 10px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.config-search__tag-picker {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-elevated);
transition:
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease,
background var(--duration-fast) ease;
}
.config-search__tag-picker[open] {
border-color: var(--accent);
box-shadow: var(--focus-ring);
background: var(--bg-hover);
}
:root[data-theme="light"] .config-search__tag-picker {
background: white;
}
.config-search__tag-trigger {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 30px;
padding: 6px 8px;
cursor: pointer;
}
.config-search__tag-trigger::-webkit-details-marker {
display: none;
}
.config-search__tag-placeholder {
font-size: 11px;
color: var(--muted);
}
.config-search__tag-chips {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
min-width: 0;
}
.config-search__tag-chip {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 2px 7px;
font-size: 10px;
font-weight: 500;
color: var(--text);
background: var(--bg);
}
.config-search__tag-chip--count {
color: var(--muted);
}
.config-search__tag-caret {
color: var(--muted);
font-size: 12px;
line-height: 1;
}
.config-search__tag-picker[open] .config-search__tag-caret {
transform: rotate(180deg);
}
.config-search__tag-menu {
max-height: 104px;
overflow-y: auto;
border-top: 1px solid var(--border);
padding: 6px;
display: grid;
gap: 6px;
}
.config-search__tag-option {
display: block;
width: 100%;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 6px 8px;
background: transparent;
color: var(--muted);
font-size: 11px;
text-align: left;
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease,
border-color var(--duration-fast) ease;
}
.config-search__tag-option:hover {
background: var(--bg-hover);
color: var(--text);
}
.config-search__tag-option.active {
background: var(--accent-subtle);
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 34%, transparent);
}
/* Navigation */
.config-nav {
flex: 1;
@@ -536,7 +666,7 @@
.config-form--modern {
display: grid;
gap: 26px;
gap: 20px;
}
.config-section-card {
@@ -603,7 +733,7 @@
}
.config-section-card__content {
padding: 22px;
padding: 18px;
}
/* ===========================================
@@ -612,12 +742,16 @@
.cfg-fields {
display: grid;
gap: 22px;
gap: 14px;
}
.cfg-fields--inline {
gap: 10px;
}
.cfg-field {
display: grid;
gap: 8px;
gap: 6px;
}
.cfg-field--error {
@@ -639,6 +773,28 @@
line-height: 1.45;
}
.cfg-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.cfg-tag {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: var(--radius-full);
padding: 2px 8px;
font-size: 11px;
color: var(--muted);
background: var(--bg-elevated);
white-space: nowrap;
}
:root[data-theme="light"] .cfg-tag {
background: white;
}
.cfg-field__error {
font-size: 12px;
color: var(--danger);
@@ -989,22 +1145,25 @@
.cfg-object {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-accent);
background: transparent;
overflow: hidden;
}
:root[data-theme="light"] .cfg-object {
background: white;
background: transparent;
}
.cfg-object__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
padding: 10px 12px;
cursor: pointer;
list-style: none;
transition: background var(--duration-fast) ease;
transition:
background var(--duration-fast) ease,
border-color var(--duration-fast) ease;
border-radius: var(--radius-md);
}
.cfg-object__header:hover {
@@ -1021,6 +1180,12 @@
color: var(--text);
}
.cfg-object__title-wrap {
display: grid;
gap: 6px;
min-width: 0;
}
.cfg-object__chevron {
width: 18px;
height: 18px;
@@ -1038,16 +1203,16 @@
}
.cfg-object__help {
padding: 0 18px 14px;
padding: 0 12px 10px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--border);
}
.cfg-object__content {
padding: 18px;
padding: 12px;
display: grid;
gap: 18px;
gap: 12px;
border-top: 1px solid var(--border);
}
/* Array */
@@ -1061,7 +1226,7 @@
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
padding: 10px 12px;
background: var(--bg-accent);
border-bottom: 1px solid var(--border);
}
@@ -1071,12 +1236,18 @@
}
.cfg-array__label {
flex: 1;
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.cfg-array__title {
flex: 1;
min-width: 0;
display: grid;
gap: 6px;
}
.cfg-array__count {
font-size: 12px;
color: var(--muted);
@@ -1124,7 +1295,7 @@
}
.cfg-array__help {
padding: 12px 18px;
padding: 10px 12px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--border);
@@ -1151,7 +1322,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
padding: 10px 12px;
background: var(--bg-accent);
border-bottom: 1px solid var(--border);
}
@@ -1200,7 +1371,7 @@
}
.cfg-array__item-content {
padding: 18px;
padding: 12px;
}
/* Map (custom entries) */
@@ -1215,7 +1386,7 @@
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 18px;
padding: 10px 12px;
background: var(--bg-accent);
border-bottom: 1px solid var(--border);
}
@@ -1268,15 +1439,28 @@
.cfg-map__items {
display: grid;
gap: 10px;
padding: 14px;
gap: 8px;
padding: 10px 12px 12px;
}
.cfg-map__item {
display: grid;
grid-template-columns: 150px 1fr auto;
gap: 10px;
align-items: start;
gap: 8px;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-accent);
}
:root[data-theme="light"] .cfg-map__item {
background: white;
}
.cfg-map__item-header {
display: grid;
grid-template-columns: minmax(0, 300px) auto;
gap: 8px;
align-items: center;
}
.cfg-map__item-key {
@@ -1287,9 +1471,13 @@
min-width: 0;
}
.cfg-map__item-value > .cfg-fields {
gap: 10px;
}
.cfg-map__item-remove {
width: 34px;
height: 34px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
@@ -1410,6 +1598,10 @@
gap: 10px;
}
.cfg-map__item-header {
grid-template-columns: 1fr auto;
}
.cfg-map__item-remove {
justify-self: end;
}

View File

@@ -197,6 +197,113 @@ describe("config form renderer", () => {
expect(container.textContent).toContain("Plugin Enabled");
});
it("renders tags from uiHints metadata", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security", "secret"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
onPatch,
}),
container,
);
const tags = Array.from(container.querySelectorAll(".cfg-tag")).map((node) =>
node.textContent?.trim(),
);
expect(tags).toContain("security");
expect(tags).toContain("secret");
});
it("filters by tag query", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "tag:security",
onPatch,
}),
container,
);
expect(container.textContent).toContain("Gateway");
expect(container.textContent).toContain("Token");
expect(container.textContent).not.toContain("Allow From");
expect(container.textContent).not.toContain("Mode");
});
it("does not treat plain text as tag filter", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "security",
onPatch,
}),
container,
);
expect(container.textContent).toContain('No settings match "security"');
});
it("requires both text and tag when combined", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema(rootSchema);
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "token tag:security",
onPatch,
}),
container,
);
expect(container.textContent).toContain("Token");
expect(container.textContent).not.toContain('No settings match "token tag:security"');
const noMatchContainer = document.createElement("div");
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"gateway.auth.token": { tags: ["security"] },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {},
searchQuery: "mode tag:security",
onPatch,
}),
noMatchContainer,
);
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
});
it("flags unsupported unions", () => {
const schema = {
type: "object",

View File

@@ -286,6 +286,7 @@ export type ConfigSnapshot = {
export type ConfigUiHint = {
label?: string;
help?: string;
tags?: string[];
group?: string;
order?: number;
advanced?: boolean;

View File

@@ -9,7 +9,7 @@ import {
type JsonSchema,
} from "./config-form.shared.ts";
const META_KEYS = new Set(["title", "description", "default", "nullable"]);
const META_KEYS = new Set(["title", "description", "default", "nullable", "tags", "x-tags"]);
function isAnySchema(schema: JsonSchema): boolean {
const keys = Object.keys(schema ?? {}).filter((key) => !META_KEYS.has(key));
@@ -94,6 +94,234 @@ const icons = {
`,
};
type FieldMeta = {
label: string;
help?: string;
tags: string[];
};
export type ConfigSearchCriteria = {
text: string;
tags: string[];
};
function hasSearchCriteria(criteria: ConfigSearchCriteria | undefined): boolean {
return Boolean(criteria && (criteria.text.length > 0 || criteria.tags.length > 0));
}
export function parseConfigSearchQuery(query: string): ConfigSearchCriteria {
const tags: string[] = [];
const seen = new Set<string>();
const raw = query.trim();
const stripped = raw.replace(/(^|\s)tag:([^\s]+)/gi, (_, leading: string, token: string) => {
const normalized = token.trim().toLowerCase();
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
tags.push(normalized);
}
return leading;
});
return {
text: stripped.trim().toLowerCase(),
tags,
};
}
function normalizeTags(raw: unknown): string[] {
if (!Array.isArray(raw)) {
return [];
}
const seen = new Set<string>();
const tags: string[] = [];
for (const value of raw) {
if (typeof value !== "string") {
continue;
}
const tag = value.trim();
if (!tag) {
continue;
}
const key = tag.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
tags.push(tag);
}
return tags;
}
function resolveFieldMeta(
path: Array<string | number>,
schema: JsonSchema,
hints: ConfigUiHints,
): FieldMeta {
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const schemaTags = normalizeTags(schema["x-tags"] ?? schema.tags);
const hintTags = normalizeTags(hint?.tags);
return {
label,
help,
tags: hintTags.length > 0 ? hintTags : schemaTags,
};
}
function matchesText(text: string, candidates: Array<string | undefined>): boolean {
if (!text) {
return true;
}
for (const candidate of candidates) {
if (candidate && candidate.toLowerCase().includes(text)) {
return true;
}
}
return false;
}
function matchesTags(filterTags: string[], fieldTags: string[]): boolean {
if (filterTags.length === 0) {
return true;
}
const normalized = new Set(fieldTags.map((tag) => tag.toLowerCase()));
return filterTags.every((tag) => normalized.has(tag));
}
function matchesNodeSelf(params: {
schema: JsonSchema;
path: Array<string | number>;
hints: ConfigUiHints;
criteria: ConfigSearchCriteria;
}): boolean {
const { schema, path, hints, criteria } = params;
if (!hasSearchCriteria(criteria)) {
return true;
}
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
if (!matchesTags(criteria.tags, tags)) {
return false;
}
if (!criteria.text) {
return true;
}
const pathLabel = path
.filter((segment): segment is string => typeof segment === "string")
.join(".");
const enumText =
schema.enum && schema.enum.length > 0
? schema.enum.map((value) => String(value)).join(" ")
: "";
return matchesText(criteria.text, [
label,
help,
schema.title,
schema.description,
pathLabel,
enumText,
]);
}
export function matchesNodeSearch(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
criteria: ConfigSearchCriteria;
}): boolean {
const { schema, value, path, hints, criteria } = params;
if (!hasSearchCriteria(criteria)) {
return true;
}
if (matchesNodeSelf({ schema, path, hints, criteria })) {
return true;
}
const type = schemaType(schema);
if (type === "object") {
const fallback = value ?? schema.default;
const obj =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
for (const [propKey, node] of Object.entries(props)) {
if (
matchesNodeSearch({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
criteria,
})
) {
return true;
}
}
const additional = schema.additionalProperties;
if (additional && typeof additional === "object") {
const reserved = new Set(Object.keys(props));
for (const [entryKey, entryValue] of Object.entries(obj)) {
if (reserved.has(entryKey)) {
continue;
}
if (
matchesNodeSearch({
schema: additional,
value: entryValue,
path: [...path, entryKey],
hints,
criteria,
})
) {
return true;
}
}
}
return false;
}
if (type === "array") {
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
return false;
}
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
if (arr.length === 0) {
return false;
}
for (let idx = 0; idx < arr.length; idx += 1) {
if (
matchesNodeSearch({
schema: itemsSchema,
value: arr[idx],
path: [...path, idx],
hints,
criteria,
})
) {
return true;
}
}
}
return false;
}
function renderTags(tags: string[]): TemplateResult | typeof nothing {
if (tags.length === 0) {
return nothing;
}
return html`
<div class="cfg-tags">
${tags.map((tag) => html`<span class="cfg-tag">${tag}</span>`)}
</div>
`;
}
export function renderNode(params: {
schema: JsonSchema;
value: unknown;
@@ -102,15 +330,15 @@ export function renderNode(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const key = pathKey(path);
const criteria = params.searchCriteria;
if (unsupported.has(key)) {
return html`<div class="cfg-field cfg-field--error">
@@ -118,6 +346,13 @@ export function renderNode(params: {
<div class="cfg-field__error">Unsupported schema node. Use Raw mode.</div>
</div>`;
}
if (
criteria &&
hasSearchCriteria(criteria) &&
!matchesNodeSearch({ schema, value, path, hints, criteria })
) {
return nothing;
}
// Handle anyOf/oneOf unions
if (schema.anyOf || schema.oneOf) {
@@ -150,6 +385,7 @@ export function renderNode(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-segmented">
${literals.map(
(lit) => html`
@@ -215,6 +451,7 @@ export function renderNode(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-segmented">
${options.map(
(opt) => html`
@@ -258,6 +495,7 @@ export function renderNode(params: {
<div class="cfg-toggle-row__content">
<span class="cfg-toggle-row__label">${label}</span>
${help ? html`<span class="cfg-toggle-row__help">${help}</span>` : nothing}
${renderTags(tags)}
</div>
<div class="cfg-toggle">
<input
@@ -298,14 +536,14 @@ function renderTextInput(params: {
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
inputType: "text" | "number";
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const isSensitive =
(hint?.sensitive ?? false) && !/^\$\{[^}]*\}$/.test(String(value ?? "").trim());
const placeholder =
@@ -322,6 +560,7 @@ function renderTextInput(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-input-wrap">
<input
type=${isSensitive ? "password" : inputType}
@@ -375,13 +614,12 @@ function renderNumberInput(params: {
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const displayValue = value ?? schema.default ?? "";
const numValue = typeof displayValue === "number" ? displayValue : 0;
@@ -389,6 +627,7 @@ function renderNumberInput(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<div class="cfg-number">
<button
type="button"
@@ -425,14 +664,13 @@ function renderSelect(params: {
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
options: unknown[];
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, options, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
@@ -443,6 +681,7 @@ function renderSelect(params: {
<div class="cfg-field">
${showLabel ? html`<label class="cfg-field__label">${label}</label>` : nothing}
${help ? html`<div class="cfg-field__help">${help}</div>` : nothing}
${renderTags(tags)}
<select
class="cfg-select"
?disabled=${disabled}
@@ -471,12 +710,17 @@ function renderObject(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
searchCriteria && hasSearchCriteria(searchCriteria)
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
: false;
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
const fallback = value ?? schema.default;
const obj =
@@ -509,6 +753,7 @@ function renderObject(params: {
hints,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
onPatch,
}),
)}
@@ -522,6 +767,7 @@ function renderObject(params: {
unsupported,
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
onPatch,
})
: nothing
@@ -537,11 +783,22 @@ function renderObject(params: {
`;
}
if (!showLabel) {
return html`
<div class="cfg-fields cfg-fields--inline">
${fields}
</div>
`;
}
// Nested objects get collapsible treatment
return html`
<details class="cfg-object" open>
<details class="cfg-object" ?open=${path.length <= 2}>
<summary class="cfg-object__header">
<span class="cfg-object__title">${label}</span>
<span class="cfg-object__title-wrap">
<span class="cfg-object__title">${label}</span>
${renderTags(tags)}
</span>
<span class="cfg-object__chevron">${icons.chevronDown}</span>
</summary>
${help ? html`<div class="cfg-object__help">${help}</div>` : nothing}
@@ -560,13 +817,17 @@ function renderArray(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const { schema, value, path, hints, unsupported, disabled, onPatch, searchCriteria } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const selfMatched =
searchCriteria && hasSearchCriteria(searchCriteria)
? matchesNodeSelf({ schema, path, hints, criteria: searchCriteria })
: false;
const childSearchCriteria = selfMatched ? undefined : searchCriteria;
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
@@ -583,7 +844,10 @@ function renderArray(params: {
return html`
<div class="cfg-array">
<div class="cfg-array__header">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
<div class="cfg-array__title">
${showLabel ? html`<span class="cfg-array__label">${label}</span>` : nothing}
${renderTags(tags)}
</div>
<span class="cfg-array__count">${arr.length} item${arr.length !== 1 ? "s" : ""}</span>
<button
type="button"
@@ -634,6 +898,7 @@ function renderArray(params: {
hints,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
onPatch,
})}
@@ -656,11 +921,34 @@ function renderMapField(params: {
unsupported: Set<string>;
disabled: boolean;
reservedKeys: Set<string>;
searchCriteria?: ConfigSearchCriteria;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, reservedKeys, onPatch } = params;
const {
schema,
value,
path,
hints,
unsupported,
disabled,
reservedKeys,
onPatch,
searchCriteria,
} = params;
const anySchema = isAnySchema(schema);
const entries = Object.entries(value ?? {}).filter(([key]) => !reservedKeys.has(key));
const visibleEntries =
searchCriteria && hasSearchCriteria(searchCriteria)
? entries.filter(([key, entryValue]) =>
matchesNodeSearch({
schema,
value: entryValue,
path: [...path, key],
hints,
criteria: searchCriteria,
}),
)
: entries;
return html`
<div class="cfg-map">
@@ -688,38 +976,53 @@ function renderMapField(params: {
</div>
${
entries.length === 0
visibleEntries.length === 0
? html`
<div class="cfg-map__empty">No custom entries.</div>
`
: html`
<div class="cfg-map__items">
${entries.map(([key, entryValue]) => {
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
return html`
<div class="cfg-map__item">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
.value=${key}
<div class="cfg-map__item-header">
<div class="cfg-map__item-key">
<input
type="text"
class="cfg-input cfg-input--sm"
placeholder="Key"
.value=${key}
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {
return;
}
const next = { ...value };
if (nextKey in next) {
return;
}
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@change=${(e: Event) => {
const nextKey = (e.target as HTMLInputElement).value.trim();
if (!nextKey || nextKey === key) {
return;
}
@click=${() => {
const next = { ...value };
if (nextKey in next) {
return;
}
next[nextKey] = next[key];
delete next[key];
onPatch(path, next);
}}
/>
>
${icons.trash}
</button>
</div>
<div class="cfg-map__item-value">
${
@@ -753,24 +1056,12 @@ function renderMapField(params: {
hints,
unsupported,
disabled,
searchCriteria,
showLabel: false,
onPatch,
})
}
</div>
<button
type="button"
class="cfg-map__item-remove"
title="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</div>
`;
})}

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import { icons } from "../icons.ts";
import type { ConfigUiHints } from "../types.ts";
import { renderNode } from "./config-form.node.ts";
import { matchesNodeSearch, parseConfigSearchQuery, renderNode } from "./config-form.node.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
export type ConfigFormProps = {
@@ -278,20 +278,27 @@ function getSectionIcon(key: string) {
return sectionIcons[key as keyof typeof sectionIcons] ?? sectionIcons.default;
}
function matchesSearch(key: string, schema: JsonSchema, query: string): boolean {
if (!query) {
function matchesSearch(params: {
key: string;
schema: JsonSchema;
sectionValue: unknown;
uiHints: ConfigUiHints;
query: string;
}): boolean {
if (!params.query) {
return true;
}
const q = query.toLowerCase();
const meta = SECTION_META[key];
const criteria = parseConfigSearchQuery(params.query);
const q = criteria.text;
const meta = SECTION_META[params.key];
// Check key name
if (key.toLowerCase().includes(q)) {
if (q && params.key.toLowerCase().includes(q)) {
return true;
}
// Check label and description
if (meta) {
if (q && meta) {
if (meta.label.toLowerCase().includes(q)) {
return true;
}
@@ -300,56 +307,13 @@ function matchesSearch(key: string, schema: JsonSchema, query: string): boolean
}
}
return schemaMatches(schema, q);
}
function schemaMatches(schema: JsonSchema, query: string): boolean {
if (schema.title?.toLowerCase().includes(query)) {
return true;
}
if (schema.description?.toLowerCase().includes(query)) {
return true;
}
if (schema.enum?.some((value) => String(value).toLowerCase().includes(query))) {
return true;
}
if (schema.properties) {
for (const [propKey, propSchema] of Object.entries(schema.properties)) {
if (propKey.toLowerCase().includes(query)) {
return true;
}
if (schemaMatches(propSchema, query)) {
return true;
}
}
}
if (schema.items) {
const items = Array.isArray(schema.items) ? schema.items : [schema.items];
for (const item of items) {
if (item && schemaMatches(item, query)) {
return true;
}
}
}
if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
if (schemaMatches(schema.additionalProperties, query)) {
return true;
}
}
const unions = schema.anyOf ?? schema.oneOf ?? schema.allOf;
if (unions) {
for (const entry of unions) {
if (entry && schemaMatches(entry, query)) {
return true;
}
}
}
return false;
return matchesNodeSearch({
schema: params.schema,
value: params.sectionValue,
path: [params.key],
hints: params.uiHints,
criteria,
});
}
export function renderConfigForm(props: ConfigFormProps) {
@@ -368,6 +332,7 @@ export function renderConfigForm(props: ConfigFormProps) {
const unsupported = new Set(props.unsupportedPaths ?? []);
const properties = schema.properties;
const searchQuery = props.searchQuery ?? "";
const searchCriteria = parseConfigSearchQuery(searchQuery);
const activeSection = props.activeSection;
const activeSubsection = props.activeSubsection ?? null;
@@ -384,7 +349,16 @@ export function renderConfigForm(props: ConfigFormProps) {
if (activeSection && key !== activeSection) {
return false;
}
if (searchQuery && !matchesSearch(key, node, searchQuery)) {
if (
searchQuery &&
!matchesSearch({
key,
schema: node,
sectionValue: value[key],
uiHints: props.uiHints,
query: searchQuery,
})
) {
return false;
}
return true;
@@ -456,6 +430,7 @@ export function renderConfigForm(props: ConfigFormProps) {
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
onPatch: props.onPatch,
})}
</div>
@@ -490,6 +465,7 @@ export function renderConfigForm(props: ConfigFormProps) {
unsupported,
disabled: props.disabled ?? false,
showLabel: false,
searchCriteria,
onPatch: props.onPatch,
})}
</div>

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { matchesNodeSearch, parseConfigSearchQuery } from "./config-form.node.ts";
const schema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
mode: {
type: "string",
enum: ["off", "token"],
},
},
};
describe("config form search", () => {
it("parses tag-prefixed query terms", () => {
const parsed = parseConfigSearchQuery("token tag:security tag:Auth");
expect(parsed.text).toBe("token");
expect(parsed.tags).toEqual(["security", "auth"]);
});
it("matches fields by tag through ui hints", () => {
const parsed = parseConfigSearchQuery("tag:security");
const matched = matchesNodeSearch({
schema: schema.properties.gateway,
value: {},
path: ["gateway"],
hints: {
"gateway.auth.token": { tags: ["security", "secret"] },
},
criteria: parsed,
});
expect(matched).toBe(true);
});
it("requires text and tag when combined", () => {
const positive = matchesNodeSearch({
schema: schema.properties.gateway,
value: {},
path: ["gateway"],
hints: {
"gateway.auth.token": { tags: ["security"] },
},
criteria: parseConfigSearchQuery("token tag:security"),
});
expect(positive).toBe(true);
const negative = matchesNodeSearch({
schema: schema.properties.gateway,
value: {},
path: ["gateway"],
hints: {
"gateway.auth.token": { tags: ["security"] },
},
criteria: parseConfigSearchQuery("mode tag:security"),
});
expect(negative).toBe(false);
});
});

View File

@@ -4,6 +4,8 @@ export type JsonSchema = {
type?: string | string[];
title?: string;
description?: string;
tags?: string[];
"x-tags"?: string[];
properties?: Record<string, JsonSchema>;
items?: JsonSchema | JsonSchema[];
additionalProperties?: JsonSchema | boolean;

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import {
appendTagFilter,
getTagFilters,
hasTagFilter,
removeTagFilter,
replaceTagFilters,
toggleTagFilter,
} from "./config-search.ts";
describe("config search tag helper", () => {
it("adds a tag when query is empty", () => {
expect(appendTagFilter("", "security")).toBe("tag:security");
});
it("appends a tag to existing text query", () => {
expect(appendTagFilter("token", "security")).toBe("token tag:security");
});
it("deduplicates existing tag filters case-insensitively", () => {
expect(appendTagFilter("token tag:Security", "security")).toBe("token tag:Security");
});
it("detects exact tag terms", () => {
expect(hasTagFilter("tag:security token", "security")).toBe(true);
expect(hasTagFilter("tag:security-hard token", "security")).toBe(false);
});
it("removes only the selected active tag", () => {
expect(removeTagFilter("token tag:security tag:auth", "security")).toBe("token tag:auth");
});
it("toggle removes active tag and keeps text", () => {
expect(toggleTagFilter("token tag:security", "security")).toBe("token");
});
it("toggle adds missing tag", () => {
expect(toggleTagFilter("token", "channels")).toBe("token tag:channels");
});
it("extracts unique normalized tags from query", () => {
expect(getTagFilters("token tag:Security tag:auth tag:security")).toEqual(["security", "auth"]);
});
it("replaces only tag filters and preserves free text", () => {
expect(replaceTagFilters("token tag:security mode", ["auth", "channels"])).toBe(
"token mode tag:auth tag:channels",
);
});
});

View File

@@ -0,0 +1,92 @@
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeTag(tag: string): string {
return tag.trim().toLowerCase();
}
export function getTagFilters(query: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
const pattern = /(^|\s)tag:([^\s]+)/gi;
const raw = query.trim();
let match: RegExpExecArray | null = pattern.exec(raw);
while (match) {
const normalized = normalizeTag(match[2] ?? "");
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
tags.push(normalized);
}
match = pattern.exec(raw);
}
return tags;
}
export function hasTagFilter(query: string, tag: string): boolean {
const normalizedTag = normalizeTag(tag);
if (!normalizedTag) {
return false;
}
const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "i");
return pattern.test(query.trim());
}
export function appendTagFilter(query: string, tag: string): string {
const normalizedTag = normalizeTag(tag);
const trimmed = query.trim();
if (!normalizedTag) {
return trimmed;
}
if (!trimmed) {
return `tag:${normalizedTag}`;
}
if (hasTagFilter(trimmed, normalizedTag)) {
return trimmed;
}
return `${trimmed} tag:${normalizedTag}`;
}
export function removeTagFilter(query: string, tag: string): string {
const normalizedTag = normalizeTag(tag);
const trimmed = query.trim();
if (!normalizedTag || !trimmed) {
return trimmed;
}
const pattern = new RegExp(`(^|\\s)tag:${escapeRegExp(normalizedTag)}(?=\\s|$)`, "ig");
return trimmed.replace(pattern, " ").replace(/\s+/g, " ").trim();
}
export function replaceTagFilters(query: string, tags: readonly string[]): string {
const uniqueTags: string[] = [];
const seen = new Set<string>();
for (const tag of tags) {
const normalized = normalizeTag(tag);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
uniqueTags.push(normalized);
}
const trimmed = query.trim();
const withoutTags = trimmed
.replace(/(^|\s)tag:([^\s]+)/gi, " ")
.replace(/\s+/g, " ")
.trim();
const tagTokens = uniqueTags.map((tag) => `tag:${tag}`).join(" ");
if (withoutTags && tagTokens) {
return `${withoutTags} ${tagTokens}`;
}
if (withoutTags) {
return withoutTags;
}
return tagTokens;
}
export function toggleTagFilter(query: string, tag: string): string {
if (hasTagFilter(query, tag)) {
return removeTagFilter(query, tag);
}
return appendTagFilter(query, tag);
}

View File

@@ -198,4 +198,35 @@ describe("config view", () => {
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(onSearchChange).toHaveBeenCalledWith("gateway");
});
it("shows all tag options in compact tag picker", () => {
const container = document.createElement("div");
render(renderConfig(baseProps()), container);
const options = Array.from(container.querySelectorAll(".config-search__tag-option")).map(
(option) => option.textContent?.trim(),
);
expect(options).toContain("tag:security");
expect(options).toContain("tag:advanced");
expect(options).toHaveLength(15);
});
it("updates search query when toggling a tag option", () => {
const container = document.createElement("div");
const onSearchChange = vi.fn();
render(
renderConfig({
...baseProps(),
onSearchChange,
}),
container,
);
const option = container.querySelector<HTMLButtonElement>(
'.config-search__tag-option[data-tag="security"]',
);
expect(option).toBeTruthy();
option?.click();
expect(onSearchChange).toHaveBeenCalledWith("tag:security");
});
});

View File

@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types.ts";
import { hintForPath, humanize, schemaType, type JsonSchema } from "./config-form.shared.ts";
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form.ts";
import { getTagFilters, replaceTagFilters } from "./config-search.ts";
export type ConfigProps = {
raw: string;
@@ -34,6 +35,24 @@ export type ConfigProps = {
onUpdate: () => void;
};
const TAG_SEARCH_PRESETS = [
"security",
"auth",
"network",
"access",
"privacy",
"observability",
"performance",
"reliability",
"storage",
"models",
"media",
"automation",
"channels",
"tools",
"advanced",
] as const;
// SVG Icons for sidebar (Lucide-style)
const sidebarIcons = {
all: html`
@@ -443,6 +462,7 @@ export function renderConfig(props: ConfigProps) {
hasChanges &&
(props.formMode === "raw" ? true : canSaveForm);
const canUpdate = props.connected && !props.applying && !props.updating;
const selectedTags = new Set(getTagFilters(props.searchQuery));
return html`
<div class="config-layout">
@@ -460,35 +480,91 @@ export function renderConfig(props: ConfigProps) {
<!-- Search -->
<div class="config-search">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
.value=${props.searchQuery}
@input=${(e: Event) => props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${
props.searchQuery
? html`
<button
class="config-search__clear"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing
}
</div>
<div class="config-search__hint">
<span class="config-search__hint-label" id="config-tag-filter-label">Tag filters:</span>
<details class="config-search__tag-picker">
<summary class="config-search__tag-trigger" aria-labelledby="config-tag-filter-label">
${
selectedTags.size === 0
? html`
<span class="config-search__tag-placeholder">Add tags</span>
`
: html`
<div class="config-search__tag-chips">
${Array.from(selectedTags)
.slice(0, 2)
.map(
(tag) =>
html`<span class="config-search__tag-chip">tag:${tag}</span>`,
)}
${
selectedTags.size > 2
? html`
<span class="config-search__tag-chip config-search__tag-chip--count"
>+${selectedTags.size - 2}</span
>
`
: nothing
}
</div>
`
}
<span class="config-search__tag-caret" aria-hidden="true">▾</span>
</summary>
<div class="config-search__tag-menu">
${TAG_SEARCH_PRESETS.map((tag) => {
const active = selectedTags.has(tag);
return html`
<button
type="button"
class="config-search__tag-option ${active ? "active" : ""}"
data-tag="${tag}"
aria-pressed=${active ? "true" : "false"}
@click=${() => {
const nextTags = active
? Array.from(selectedTags).filter((value) => value !== tag)
: [...selectedTags, tag];
props.onSearchChange(replaceTagFilters(props.searchQuery, nextTags));
}}
>
tag:${tag}
</button>
`;
})}
</div>
</details>
</div>
</div>
<!-- Section nav -->