diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e5981a3..320efbf94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/tui/components/custom-editor.ts b/src/tui/components/custom-editor.ts index b66452e61..80feb4145 100644 --- a/src/tui/components/custom-editor.ts +++ b/src/tui/components/custom-editor.ts @@ -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) { diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 753f5511f..a5e6e34d7 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -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); diff --git a/ui/src/ui/views/config-form.node.ts b/ui/src/ui/views/config-form.node.ts index 6024c2407..07cb9f239 100644 --- a/ui/src/ui/views/config-form.node.ts +++ b/ui/src/ui/views/config-form.node.ts @@ -43,19 +43,15 @@ export function renderNode(params: { unsupported: Set; disabled: boolean; showLabel?: boolean; - searchQuery?: string; onPatch: (path: Array, 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`
@@ -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: {
`; } - 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; hints: ConfigUiHints; disabled: boolean; showLabel?: boolean; - options: string[]; + options: unknown[]; onPatch: (path: Array, 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`
@@ -338,14 +353,15 @@ function renderSelect(params: {
@@ -360,10 +376,9 @@ function renderObject(params: { unsupported: Set; disabled: boolean; showLabel?: boolean; - searchQuery?: string; onPatch: (path: Array, 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; disabled: boolean; showLabel?: boolean; - searchQuery?: string; onPatch: (path: Array, 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, })} diff --git a/ui/src/ui/views/config-form.render.ts b/ui/src/ui/views/config-form.render.ts index 441abc318..1c64c1caf 100644 --- a/ui/src/ui/views/config-form.render.ts +++ b/ui/src/ui/views/config-form.render.ts @@ -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, })} diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 616489549..e8448c52f 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -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) + "..."; }