fix: config form semantics + editor ctor (#1315) (thanks @MaudeBot)

This commit is contained in:
Peter Steinberger
2026-01-20 20:12:16 +00:00
parent c287664923
commit 2e7e135bc0
6 changed files with 76 additions and 39 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.clawd.bot
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
- Doctor: clarify plugin auto-enable hint text in the startup banner.
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
- UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315) — thanks @MaudeBot.
- Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).
- TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs.
- TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl.

View File

@@ -1,4 +1,4 @@
import { Editor, type EditorTheme, Key, matchesKey } from "@mariozechner/pi-tui";
import { Editor, type EditorTheme, Key, matchesKey, type TUI } from "@mariozechner/pi-tui";
export class CustomEditor extends Editor {
onEscape?: () => void;
@@ -12,8 +12,8 @@ export class CustomEditor extends Editor {
onShiftTab?: () => void;
onAltEnter?: () => void;
constructor(theme: EditorTheme) {
super(theme);
constructor(tui: TUI, theme: EditorTheme) {
super(tui, theme);
}
handleInput(data: string): void {
if (matchesKey(data, Key.alt("enter")) && this.onAltEnter) {

View File

@@ -193,7 +193,7 @@ export async function runTui(opts: TuiOptions) {
const statusContainer = new Container();
const footer = new Text("", 1, 0);
const chatLog = new ChatLog();
const editor = new CustomEditor(editorTheme);
const editor = new CustomEditor(tui, editorTheme);
const root = new Container();
root.addChild(header);
root.addChild(chatLog);

View File

@@ -43,19 +43,15 @@ export function renderNode(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchQuery?: string;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult | typeof nothing {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
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 key = pathKey(path);
const hasDefault = schema.default !== undefined;
const isDefault = hasDefault && JSON.stringify(value) === JSON.stringify(schema.default);
const isEmpty = value === undefined || value === null || value === "";
if (unsupported.has(key)) {
return html`<div class="cfg-field cfg-field--error">
@@ -109,7 +105,7 @@ export function renderNode(params: {
if (allLiterals && literals.length > 5) {
// Use dropdown for larger sets
return renderSelect({ ...params, options: literals.map(String), value: String(value ?? schema.default ?? "") });
return renderSelect({ ...params, options: literals, value: value ?? schema.default });
}
// Handle mixed primitive types
@@ -123,7 +119,15 @@ export function renderNode(params: {
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,
@@ -157,7 +161,7 @@ export function renderNode(params: {
</div>
`;
}
return renderSelect({ ...params, options: options.map(String), value: String(value ?? schema.default ?? "") });
return renderSelect({ ...params, options, value: value ?? schema.default });
}
// Object type - collapsible section
@@ -227,7 +231,9 @@ function renderTextInput(params: {
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (schema.default !== undefined ? `Default: ${schema.default}` : "");
const placeholder =
hint?.placeholder ??
(isSensitive ? "••••" : schema.default !== undefined ? `Default: ${schema.default}` : "");
const displayValue = value ?? "";
return html`
@@ -243,12 +249,16 @@ function renderTextInput(params: {
?disabled=${disabled}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
if (inputType === "number" && raw.trim() !== "") {
if (inputType === "number") {
if (raw.trim() === "") {
onPatch(path, undefined);
return;
}
const parsed = Number(raw);
onPatch(path, Number.isNaN(parsed) ? raw : parsed);
} else {
onPatch(path, raw === "" ? undefined : raw);
return;
}
onPatch(path, raw);
}}
/>
${schema.default !== undefined ? html`
@@ -317,12 +327,12 @@ function renderNumberInput(params: {
function renderSelect(params: {
schema: JsonSchema;
value: string;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
disabled: boolean;
showLabel?: boolean;
options: string[];
options: unknown[];
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, disabled, options, onPatch } = params;
@@ -330,6 +340,11 @@ function renderSelect(params: {
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
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">
@@ -338,14 +353,15 @@ function renderSelect(params: {
<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 === "" ? undefined : val);
onPatch(path, val === unset ? undefined : options[Number(val)]);
}}
>
<option value="" ?selected=${!value}>Select...</option>
${options.map(opt => html`
<option value=${opt} ?selected=${opt === value}>${opt}</option>
<option value=${unset}>Select...</option>
${options.map((opt, idx) => html`
<option value=${String(idx)}>${String(opt)}</option>
`)}
</select>
</div>
@@ -360,10 +376,9 @@ function renderObject(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchQuery?: string;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
@@ -401,7 +416,6 @@ function renderObject(params: {
unsupported,
disabled,
onPatch,
searchQuery,
})
)}
${allowExtra ? renderMapField({
@@ -436,7 +450,6 @@ function renderObject(params: {
unsupported,
disabled,
onPatch,
searchQuery,
})
)}
${allowExtra ? renderMapField({
@@ -462,10 +475,9 @@ function renderArray(params: {
unsupported: Set<string>;
disabled: boolean;
showLabel?: boolean;
searchQuery?: string;
onPatch: (path: Array<string | number>, value: unknown) => void;
}): TemplateResult {
const { schema, value, path, hints, unsupported, disabled, onPatch, searchQuery } = params;
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
@@ -537,7 +549,6 @@ function renderArray(params: {
disabled,
showLabel: false,
onPatch,
searchQuery,
})}
</div>
</div>

View File

@@ -97,19 +97,39 @@ function matchesSearch(key: string, schema: JsonSchema, query: string): boolean
if (meta.description.toLowerCase().includes(q)) return true;
}
// Check schema title/description
if (schema.title?.toLowerCase().includes(q)) return true;
if (schema.description?.toLowerCase().includes(q)) return true;
// Deep search in properties
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(q)) return true;
if (propSchema.title?.toLowerCase().includes(q)) return true;
if (propSchema.description?.toLowerCase().includes(q)) return true;
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;
}
@@ -190,7 +210,6 @@ export function renderConfigForm(props: ConfigFormProps) {
disabled: props.disabled ?? false,
showLabel: false,
onPatch: props.onPatch,
searchQuery,
})}
</div>
</section>

View File

@@ -122,7 +122,13 @@ function computeDiff(
}
function truncateValue(value: unknown, maxLen = 40): string {
const str = JSON.stringify(value);
let str: string;
try {
const json = JSON.stringify(value);
str = json ?? String(value);
} catch {
str = String(value);
}
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 3) + "...";
}