Files
openclaw/ui/src/components/config-form.node.ts
T
Peter Steinberger 096cb91175 feat(tooling): enforce noUncheckedIndexedAccess in core and ui lanes (NUIA phase 3c) (#104981)
* fix(ui): make indexed access explicit across the Control UI

Burns down all 322 ui-lane noUncheckedIndexedAccess errors: untrusted
markdown/diff/patch parsing gets miss-tolerant guards (renderer rules
degrade to empty output instead of throwing mid-render), accumulators
use canonical sparse initialization, DOM lookups stay null-guarded, and
length-checked constructions carry named invariants.

* feat(tooling): enforce noUncheckedIndexedAccess in the core and ui lanes

Flips the flag on for tsconfig.core.json and tsconfig.ui.json (covering
src, all packages including the two deferred ones, and ui) and retires
the strict-ratchet lane wholesale: config, script, projects reference,
check/CI wiring, changed-lane routing, and sync test. The assertion-ban
oxlint override stays as an independent surface.
2026-07-12 06:36:36 +01:00

1376 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Control UI view renders config form screen content.
import { html, nothing, type TemplateResult } from "lit";
import type { ConfigUiHints } from "../api/types.ts";
import { icons as sharedIcons } from "../components/icons.ts";
import "../components/tooltip.ts";
import { formatUnknownText } from "../lib/format.ts";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../lib/string-coerce.ts";
import {
defaultValue,
hasSensitiveConfigData,
hintForPath,
humanize,
pathKey,
REDACTED_PLACEHOLDER,
schemaType,
type JsonSchema,
} from "./config-form.shared.ts";
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));
return keys.length === 0;
}
function jsonValue(value: unknown): string {
if (value === undefined) {
return "";
}
try {
return JSON.stringify(value, null, 2) ?? "";
} catch {
return "";
}
}
function formatComparablePrimitive(value: unknown): string | null {
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean" ||
typeof value === "bigint"
) {
return String(value);
}
return null;
}
function matchesComparablePrimitiveValue(left: unknown, right: unknown): boolean {
if (Object.is(left, right)) {
return true;
}
const leftComparable = formatComparablePrimitive(left);
const rightComparable = formatComparablePrimitive(right);
return leftComparable !== null && leftComparable === rightComparable;
}
// SVG Icons as template literals
const icons = {
chevronDown: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
`,
plus: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`,
minus: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`,
trash: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
></path>
</svg>
`,
edit: html`
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
`,
};
type FieldMeta = {
label: string;
help?: string;
tags: string[];
};
function isSecretRefObject(value: unknown): value is {
source: string;
id: string;
provider?: string;
} {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const candidate = value as Record<string, unknown>;
if (typeof candidate.source !== "string" || typeof candidate.id !== "string") {
return false;
}
return candidate.provider === undefined || typeof candidate.provider === "string";
}
type SensitiveRenderParams = {
path: Array<string | number>;
value: unknown;
hints: ConfigUiHints;
revealSensitive: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
};
type SensitiveRenderState = {
isSensitive: boolean;
isRedacted: boolean;
isRevealed: boolean;
canReveal: boolean;
};
type ConfigSearchCriteria = {
text: string;
tags: string[];
};
function getSensitiveRenderState(params: SensitiveRenderParams): SensitiveRenderState {
const isSensitive = hasSensitiveConfigData(params.value, params.path, params.hints);
const isRevealed =
isSensitive &&
(params.revealSensitive || (params.isSensitivePathRevealed?.(params.path) ?? false));
return {
isSensitive,
isRedacted: isSensitive && !isRevealed,
isRevealed,
canReveal: isSensitive,
};
}
function renderSensitiveToggleButton(params: {
path: Array<string | number>;
state: SensitiveRenderState;
disabled: boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
}): TemplateResult | typeof nothing {
const { state } = params;
if (!state.isSensitive || !params.onToggleSensitivePath) {
return nothing;
}
const label = state.canReveal
? state.isRevealed
? "Hide value"
: "Reveal value"
: "Disable stream mode to reveal value";
return html`
<openclaw-tooltip .content=${label}>
<button
type="button"
class="btn btn--icon ${state.isRevealed ? "active" : ""}"
style="width:28px;height:28px;padding:0;"
aria-label=${label}
aria-pressed=${state.isRevealed}
?disabled=${params.disabled || !state.canReveal}
@click=${() => params.onToggleSensitivePath?.(params.path)}
>
${state.isRevealed ? sharedIcons.eye : sharedIcons.eyeOff}
</button>
</openclaw-tooltip>
`;
}
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 = normalizeLowercaseStringOrEmpty(token);
if (normalized && !seen.has(normalized)) {
seen.add(normalized);
tags.push(normalized);
}
return leading;
});
return {
text: normalizeLowercaseStringOrEmpty(stripped),
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 = normalizeLowercaseStringOrEmpty(tag);
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 (normalizeOptionalLowercaseString(candidate)?.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) => normalizeLowercaseStringOrEmpty(tag)));
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;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
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 { 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">
<div class="cfg-field__label">${label}</div>
<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) {
const variants = schema.anyOf ?? schema.oneOf ?? [];
const nonNull = variants.filter(
(v) => !(v.type === "null" || (Array.isArray(v.type) && v.type.includes("null"))),
);
if (nonNull.length === 1) {
const selectedSchema = nonNull[0];
return selectedSchema ? renderNode({ ...params, schema: selectedSchema }) : nothing;
}
// Check if it's a set of literal values (enum-like)
const extractLiteral = (v: JsonSchema): unknown => {
if (v.const !== undefined) {
return v.const;
}
if (v.enum && v.enum.length === 1) {
return v.enum[0];
}
return undefined;
};
const literals = nonNull.map(extractLiteral);
const allLiterals = literals.every((v) => v !== undefined);
if (allLiterals && literals.length > 0 && literals.length <= 5) {
// Use segmented control for small sets
const resolvedValue = value ?? schema.default;
return html`
<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`
<button
type="button"
class="cfg-segmented__btn ${matchesComparablePrimitiveValue(lit, resolvedValue)
? "active"
: ""}"
?disabled=${disabled}
@click=${() => onPatch(path, lit)}
>
${formatUnknownText(lit)}
</button>
`,
)}
</div>
</div>
`;
}
if (allLiterals && literals.length > 5) {
// Use dropdown for larger sets
return renderSelect({ ...params, options: literals, value: value ?? schema.default });
}
// Handle mixed primitive types
const primitiveTypes = new Set(nonNull.map((variant) => schemaType(variant)).filter(Boolean));
const normalizedTypes = new Set(
[...primitiveTypes].map((v) => (v === "integer" ? "number" : v)),
);
if ([...normalizedTypes].every((v) => ["string", "number", "boolean"].includes(v as string))) {
const hasString = normalizedTypes.has("string");
const hasNumber = normalizedTypes.has("number");
const hasBoolean = normalizedTypes.has("boolean");
if (hasBoolean && normalizedTypes.size === 1) {
return renderNode({
...params,
schema: { ...schema, type: "boolean", anyOf: undefined, oneOf: undefined },
});
}
if (hasString || hasNumber) {
return renderTextInput({
...params,
inputType: hasNumber && !hasString ? "number" : "text",
});
}
}
// Complex union (e.g. array | object) — render as JSON textarea
return renderJsonTextarea({
schema,
value,
path,
hints,
disabled,
showLabel,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
onToggleSensitivePath: params.onToggleSensitivePath,
onPatch,
});
}
// Enum - use segmented for small, dropdown for large
if (schema.enum) {
const options = schema.enum;
if (options.length <= 5) {
const resolvedValue = value ?? schema.default;
return html`
<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`
<button
type="button"
class="cfg-segmented__btn ${matchesComparablePrimitiveValue(opt, resolvedValue)
? "active"
: ""}"
?disabled=${disabled}
@click=${() => onPatch(path, opt)}
>
${formatUnknownText(opt)}
</button>
`,
)}
</div>
</div>
`;
}
return renderSelect({ ...params, options, value: value ?? schema.default });
}
// Object type - collapsible section
if (type === "object") {
return renderObject(params);
}
// Array type
if (type === "array") {
return renderArray(params);
}
// Boolean - toggle row
if (type === "boolean") {
const displayValue =
typeof value === "boolean"
? value
: typeof schema.default === "boolean"
? schema.default
: false;
return html`
<label class="cfg-toggle-row ${disabled ? "disabled" : ""}">
<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
type="checkbox"
.checked=${displayValue}
?disabled=${disabled}
@change=${(e: Event) => onPatch(path, (e.target as HTMLInputElement).checked)}
/>
<span class="cfg-toggle__track"></span>
</div>
</label>
`;
}
// Number/Integer
if (type === "number" || type === "integer") {
return renderNumberInput(params);
}
// String
if (type === "string") {
return renderTextInput({ ...params, inputType: "text" });
}
// Fallback
return html`
<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported type: ${type}. Use Raw mode.</div>
</div>
`;
}
function renderTextInput(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
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, help, tags } = resolveFieldMeta(path, schema, hints);
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const isStructuredValue =
value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value);
const isStructuredSecretRef = isSecretRefObject(value);
const rawAvailable = params.rawAvailable ?? true;
const effectiveRedacted = sensitiveState.isRedacted || isStructuredSecretRef;
const placeholder = effectiveRedacted
? isStructuredSecretRef
? rawAvailable
? "Structured value (SecretRef) - use Raw mode to edit"
: "Structured value (SecretRef) - edit the config file directly"
: REDACTED_PLACEHOLDER
: (hint?.placeholder ??
(schema.default !== undefined ? `Default: ${formatUnknownText(schema.default)}` : ""));
const displayValue = effectiveRedacted
? ""
: isStructuredValue
? jsonValue(value)
: (value ?? "");
const effectiveInputType = sensitiveState.isSensitive && !effectiveRedacted ? "text" : inputType;
return html`
<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=${effectiveInputType}
class="cfg-input${effectiveRedacted ? " cfg-input--redacted" : ""}"
placeholder=${placeholder}
.value=${formatUnknownText(displayValue)}
?disabled=${disabled}
?readonly=${effectiveRedacted}
@click=${() => {
if (
sensitiveState.isRedacted &&
!isStructuredSecretRef &&
params.onToggleSensitivePath
) {
params.onToggleSensitivePath(path);
}
}}
@input=${(e: Event) => {
if (effectiveRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
if (inputType === "number") {
if (raw.trim() === "") {
onPatch(path, undefined);
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
return;
}
onPatch(path, raw);
}}
@change=${(e: Event) => {
if (inputType === "number" || effectiveRedacted) {
return;
}
const raw = (e.target as HTMLInputElement).value;
onPatch(path, raw.trim());
}}
/>
${isStructuredSecretRef
? nothing
: renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
${schema.default !== undefined
? html`
<openclaw-tooltip content="Reset to default">
<button
type="button"
class="cfg-input__reset"
aria-label="Reset to default"
?disabled=${disabled || effectiveRedacted}
@click=${() => onPatch(path, schema.default)}
>
</button>
</openclaw-tooltip>
`
: nothing}
</div>
</div>
`;
}
function renderNumberInput(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
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 { label, help, tags } = resolveFieldMeta(path, schema, hints);
const displayValue = value ?? schema.default ?? "";
const numValue = typeof displayValue === "number" ? displayValue : 0;
return html`
<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"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue - 1)}
>
</button>
<input
type="number"
class="cfg-number__input"
.value=${formatUnknownText(displayValue)}
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
<button
type="button"
class="cfg-number__btn"
?disabled=${disabled}
@click=${() => onPatch(path, numValue + 1)}
>
+
</button>
</div>
</div>
`;
}
function renderSelect(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
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 { label, help, tags } = resolveFieldMeta(path, schema, hints);
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
);
const unset = "__unset__";
return html`
<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}
.value=${currentIndex >= 0 ? String(currentIndex) : unset}
@change=${(e: Event) => {
const val = (e.target as HTMLSelectElement).value;
onPatch(path, val === unset ? undefined : options[Number(val)]);
}}
>
<option value=${unset} ?selected=${currentIndex < 0}>Select...</option>
${options.map(
(opt, idx) =>
html` <option value=${String(idx)} ?selected=${idx === currentIndex}>
${String(opt)}
</option>`,
)}
</select>
</div>
`;
}
function renderJsonTextarea(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const { label, help, tags } = resolveFieldMeta(path, schema, hints);
const fallback = jsonValue(value);
const sensitiveState = getSensitiveRenderState({
path,
value,
hints,
revealSensitive: params.revealSensitive ?? false,
isSensitivePathRevealed: params.isSensitivePathRevealed,
});
const displayValue = sensitiveState.isRedacted ? "" : fallback;
return html`
<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">
<textarea
class="cfg-textarea${sensitiveState.isRedacted ? " cfg-textarea--redacted" : ""}"
placeholder=${sensitiveState.isRedacted ? REDACTED_PLACEHOLDER : "JSON value"}
rows="3"
.value=${displayValue}
?disabled=${disabled}
?readonly=${sensitiveState.isRedacted}
@click=${() => {
if (sensitiveState.isRedacted && params.onToggleSensitivePath) {
params.onToggleSensitivePath(path);
}
}}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(path, undefined);
return;
}
try {
onPatch(path, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path,
state: sensitiveState,
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
})}
</div>
</div>
`;
}
function renderObject(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
rawAvailable,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = 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 =
fallback && typeof fallback === "object" && !Array.isArray(fallback)
? (fallback as Record<string, unknown>)
: {};
const props = schema.properties ?? {};
const entries = Object.entries(props);
// Sort by hint order
const sorted = entries.toSorted((a, b) => {
const orderA = hintForPath([...path, a[0]], hints)?.order ?? 0;
const orderB = hintForPath([...path, b[0]], hints)?.order ?? 0;
if (orderA !== orderB) {
return orderA - orderB;
}
return a[0].localeCompare(b[0]);
});
const reserved = new Set(Object.keys(props));
const additional = schema.additionalProperties;
const allowExtra = Boolean(additional) && typeof additional === "object";
const fields = html`
${sorted.map(([propKey, node]) =>
renderNode({
schema: node,
value: obj[propKey],
path: [...path, propKey],
hints,
rawAvailable,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
}),
)}
${allowExtra
? renderMapField({
schema: additional,
value: obj,
path,
hints,
rawAvailable,
unsupported,
disabled,
reservedKeys: reserved,
searchCriteria: childSearchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})
: nothing}
`;
// For top-level, don't wrap in collapsible
if (path.length === 1) {
return html` <div class="cfg-fields">${fields}</div> `;
}
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=${path.length <= 2}>
<summary class="cfg-object__header">
<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}
<div class="cfg-object__content">${fields}</div>
</details>
`;
}
function renderArray(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
schema,
value,
path,
hints,
unsupported,
disabled,
onPatch,
searchCriteria,
rawAvailable,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = 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 itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {
return html`
<div class="cfg-field cfg-field--error">
<div class="cfg-field__label">${label}</div>
<div class="cfg-field__error">Unsupported array schema. Use Raw mode.</div>
</div>
`;
}
const arr = Array.isArray(value) ? value : Array.isArray(schema.default) ? schema.default : [];
return html`
<div class="cfg-array">
<div class="cfg-array__header">
<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"
class="cfg-array__add"
?disabled=${disabled}
@click=${() => {
const next = [...arr, defaultValue(itemsSchema)];
onPatch(path, next);
}}
>
<span class="cfg-array__add-icon">${icons.plus}</span>
Add
</button>
</div>
${help ? html`<div class="cfg-array__help">${help}</div>` : nothing}
${arr.length === 0
? html` <div class="cfg-array__empty">No items yet. Click "Add" to create one.</div> `
: html`
<div class="cfg-array__items">
${arr.map(
(item, idx) => html`
<div class="cfg-array__item">
<div class="cfg-array__item-header">
<span class="cfg-array__item-index">#${idx + 1}</span>
<openclaw-tooltip content="Remove item">
<button
type="button"
class="cfg-array__item-remove"
aria-label="Remove item"
?disabled=${disabled}
@click=${() => {
const next = [...arr];
next.splice(idx, 1);
onPatch(path, next);
}}
>
${icons.trash}
</button>
</openclaw-tooltip>
</div>
<div class="cfg-array__item-content">
${renderNode({
schema: itemsSchema,
value: item,
path: [...path, idx],
hints,
rawAvailable,
unsupported,
disabled,
searchCriteria: childSearchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</div>
</div>
`,
)}
</div>
`}
</div>
`;
}
function renderMapField(params: {
schema: JsonSchema;
value: Record<string, unknown>;
path: Array<string | number>;
hints: ConfigUiHints;
rawAvailable?: boolean;
unsupported: Set<string>;
disabled: boolean;
reservedKeys: Set<string>;
searchCriteria?: ConfigSearchCriteria;
revealSensitive?: boolean;
isSensitivePathRevealed?: (path: Array<string | number>) => boolean;
onToggleSensitivePath?: (path: Array<string | number>) => void;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const {
schema,
value,
path,
hints,
rawAvailable,
unsupported,
disabled,
reservedKeys,
onPatch,
searchCriteria,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
} = 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">
<div class="cfg-map__header">
<span class="cfg-map__label">Custom entries</span>
<button
type="button"
class="cfg-map__add"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
let index = 1;
let key = `custom-${index}`;
while (key in next) {
index += 1;
key = `custom-${index}`;
}
next[key] = anySchema ? {} : defaultValue(schema);
onPatch(path, next);
}}
>
<span class="cfg-map__add-icon">${icons.plus}</span>
Add Entry
</button>
</div>
${visibleEntries.length === 0
? html` <div class="cfg-map__empty">No custom entries.</div> `
: html`
<div class="cfg-map__items">
${visibleEntries.map(([key, entryValue]) => {
const valuePath = [...path, key];
const fallback = jsonValue(entryValue);
const sensitiveState = getSensitiveRenderState({
path: valuePath,
value: entryValue,
hints,
revealSensitive: revealSensitive ?? false,
isSensitivePathRevealed,
});
return html`
<div class="cfg-map__item">
<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>
<openclaw-tooltip content="Remove entry">
<button
type="button"
class="cfg-map__item-remove"
aria-label="Remove entry"
?disabled=${disabled}
@click=${() => {
const next = { ...value };
delete next[key];
onPatch(path, next);
}}
>
${icons.trash}
</button>
</openclaw-tooltip>
</div>
<div class="cfg-map__item-value">
${anySchema
? html`
<div class="cfg-input-wrap">
<textarea
class="cfg-textarea cfg-textarea--sm${sensitiveState.isRedacted
? " cfg-textarea--redacted"
: ""}"
placeholder=${sensitiveState.isRedacted
? REDACTED_PLACEHOLDER
: "JSON value"}
rows="2"
.value=${sensitiveState.isRedacted ? "" : fallback}
?disabled=${disabled}
?readonly=${sensitiveState.isRedacted}
@click=${() => {
if (sensitiveState.isRedacted && onToggleSensitivePath) {
onToggleSensitivePath(valuePath);
}
}}
@change=${(e: Event) => {
if (sensitiveState.isRedacted) {
return;
}
const target = e.target as HTMLTextAreaElement;
const raw = target.value.trim();
if (!raw) {
onPatch(valuePath, undefined);
return;
}
try {
onPatch(valuePath, JSON.parse(raw));
} catch {
target.value = fallback;
}
}}
></textarea>
${renderSensitiveToggleButton({
path: valuePath,
state: sensitiveState,
disabled,
onToggleSensitivePath,
})}
</div>
`
: renderNode({
schema,
value: entryValue,
path: valuePath,
hints,
rawAvailable,
unsupported,
disabled,
searchCriteria,
showLabel: false,
revealSensitive,
isSensitivePathRevealed,
onToggleSensitivePath,
onPatch,
})}
</div>
</div>
`;
})}
</div>
`}
</div>
`;
}