Skip to content

Commit 1fb26d6

Browse files
committed
fix(ui): clearer condition-builder labels, optional value, bareword input (#966)
User feedback on the new form was: 1. "args.foo" path placeholder is gibberish; no real label on path/value 2. value box should not be mandatory for ops where backend allows None 3. typing `lock` into the value box errored with "Invalid JSON" — every normal-looking input has to be quoted 4. for ha_call_service `data` was the only arg without an obvious meaning Changes: - Real `<label>`s on the form rows: "Argument:", "Match when:", "Value:". - Op dropdown shows friendly text ("is present (any value)", "equals", "is one of", "matches regex", etc.); wire values unchanged. - Hint line under the value row reflects the current op so users know whether a value is required and roughly what shape it should take. - Value is now OPTIONAL for ops where the backend accepts a missing field (exists, eq, neq, contains). Submitting an empty value omits the `value` key from the predicate entirely. - Bareword inputs auto-coerce: `lock` → `"lock"`, `lock,alarm` → list, `42` → number, `true` → bool. Falls back to a clearer error if even the smart-coercion can't make JSON. - Path dropdown options now carry the schema `description` as a `title` tooltip, so `data` reads as "Service data dict" on hover instead of being a mystery. - Schema-declared enums render as a value dropdown automatically (no registry entry needed) when the path's JSON-schema has `enum`. Also fix /tmp/extract_js.py — naive paren-counter broke once form strings started containing parens; switch to ast.parse so future edits don't silently break the harness.
1 parent 45c9d2c commit 1fb26d6

1 file changed

Lines changed: 180 additions & 59 deletions

File tree

src/ha_mcp/settings_ui.py

Lines changed: 180 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -772,14 +772,20 @@ def apply_tool_visibility(
772772
.policy-add-predicate:hover { background: var(--surface-hover); }
773773
.policy-predicate-form { background: var(--bg); padding: 10px;
774774
margin: 6px 0; border: 1px dashed var(--border); border-radius: 6px;
775-
display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
775+
display: flex; flex-direction: column; gap: 8px; }
776+
.policy-predicate-form .policy-form-row { display: flex; flex-wrap: wrap;
777+
gap: 6px; align-items: center; }
778+
.policy-predicate-form .policy-form-label { min-width: 90px;
779+
color: var(--text-secondary); font-size: 0.82rem; }
776780
.policy-predicate-form select,
777781
.policy-predicate-form input { padding: 5px 8px; border-radius: 4px;
778782
border: 1px solid var(--border); background: var(--surface);
779783
color: var(--text); font-size: 0.85rem; }
780-
.policy-predicate-form input.policy-predicate-path { min-width: 140px; }
781-
.policy-predicate-form input.policy-predicate-value { min-width: 200px;
784+
.policy-predicate-form input.policy-predicate-path-custom { min-width: 200px; }
785+
.policy-predicate-form input.policy-predicate-value { min-width: 220px;
782786
font-family: monospace; }
787+
.policy-predicate-form .policy-form-hint { font-size: 0.75rem;
788+
color: var(--text-secondary); padding-left: 96px; margin-top: -4px; }
783789
.policy-predicate-form-error { color: var(--danger); font-size: 0.78rem;
784790
width: 100%; margin-top: 4px; }
785791
.policy-rule-lifetime { margin: 10px 0; font-size: 0.85rem;
@@ -2166,25 +2172,36 @@ def apply_tool_visibility(
21662172
'<ul class="policy-predicate-list">' + emptyHint + predicateRows + '</ul>' +
21672173
'<button class="policy-add-predicate">+ Add condition</button>' +
21682174
'<div class="policy-predicate-form" style="display:none;">' +
2169-
'<select class="policy-predicate-op">' +
2170-
'<option value="eq">eq</option>' +
2171-
'<option value="neq">neq</option>' +
2172-
'<option value="in">in</option>' +
2173-
'<option value="not_in">not_in</option>' +
2174-
'<option value="regex">regex</option>' +
2175-
'<option value="contains">contains</option>' +
2176-
'<option value="exists">exists</option>' +
2177-
'<option value="gt">gt</option>' +
2178-
'<option value="lt">lt</option>' +
2179-
'</select>' +
2180-
'<select class="policy-predicate-path-select">' +
2181-
'<option value="">(loading paths...)</option>' +
2182-
'</select>' +
2183-
'<input type="text" class="policy-predicate-path-custom" ' +
2184-
'placeholder="args.foo" style="display:none">' +
2185-
'<span class="policy-predicate-value-slot"></span>' +
2186-
'<button class="policy-predicate-form-save">Save condition</button>' +
2187-
'<button class="policy-predicate-form-cancel">Cancel</button>' +
2175+
'<div class="policy-form-row">' +
2176+
'<label class="policy-form-label">Argument:</label>' +
2177+
'<select class="policy-predicate-path-select">' +
2178+
'<option value="">(loading...)</option>' +
2179+
'</select>' +
2180+
'<input type="text" class="policy-predicate-path-custom" ' +
2181+
'placeholder="e.g. args.color_temp" style="display:none">' +
2182+
'</div>' +
2183+
'<div class="policy-form-row">' +
2184+
'<label class="policy-form-label">Match when:</label>' +
2185+
'<select class="policy-predicate-op">' +
2186+
'<option value="exists">is present (any value)</option>' +
2187+
'<option value="eq">equals</option>' +
2188+
'<option value="neq">does NOT equal</option>' +
2189+
'<option value="in">is one of</option>' +
2190+
'<option value="not_in">is NOT one of</option>' +
2191+
'<option value="contains">contains</option>' +
2192+
'<option value="regex">matches regex</option>' +
2193+
'<option value="gt">is greater than</option>' +
2194+
'<option value="lt">is less than</option>' +
2195+
'</select>' +
2196+
'</div>' +
2197+
'<div class="policy-form-row policy-value-row">' +
2198+
'<label class="policy-form-label">Value:</label>' +
2199+
'<span class="policy-predicate-value-slot"></span>' +
2200+
'</div>' +
2201+
'<div class="policy-form-row">' +
2202+
'<button class="policy-predicate-form-save">Save condition</button>' +
2203+
'<button class="policy-predicate-form-cancel">Cancel</button>' +
2204+
'</div>' +
21882205
'<div class="policy-predicate-form-error" style="display:none;"></div>' +
21892206
'</div>' +
21902207
'</div>' +
@@ -2260,7 +2277,8 @@ def apply_tool_visibility(
22602277
} else {
22612278
html += '<option value="">(pick an argument)</option>';
22622279
for (const p of paths) {
2263-
html += '<option value="' + escapeHtml(p.path) + '">' +
2280+
const tip = p.description ? ' title="' + escapeHtml(p.description) + '"' : '';
2281+
html += '<option value="' + escapeHtml(p.path) + '"' + tip + '>' +
22642282
escapeHtml(p.label) +
22652283
(p.required ? ' *' : '') +
22662284
(p.type ? ' (' + escapeHtml(p.type) + ')' : '') +
@@ -2307,94 +2325,189 @@ def apply_tool_visibility(
23072325
}
23082326
};
23092327
2328+
// Ops where backend accepts a missing value entirely. For these,
2329+
// leaving the value box blank is fine and the rule still works
2330+
// (exists: arg just has to be present; eq/neq/contains: matches the
2331+
// null/missing case explicitly).
2332+
const VALUE_OPTIONAL_OPS = new Set(['exists', 'eq', 'neq', 'contains']);
2333+
2334+
const hintForOp = (op) => {
2335+
if (op === 'exists') {
2336+
return 'Optional. Leave blank to gate on the argument being present at all.';
2337+
}
2338+
if (op === 'in' || op === 'not_in') {
2339+
return 'Required. Pick one or more values, or type a JSON list.';
2340+
}
2341+
if (op === 'regex') {
2342+
return 'Required. A regular expression to match the argument against.';
2343+
}
2344+
if (op === 'contains') {
2345+
return 'Optional. A substring (for strings) or item (for lists).';
2346+
}
2347+
if (op === 'gt' || op === 'lt') {
2348+
return 'Required. A number to compare against.';
2349+
}
2350+
return 'Optional. The value the argument must equal. Leave blank to gate on null.';
2351+
};
2352+
23102353
// Render the value control inside valueSlotEl based on current op +
2311-
// path. Calls back with the populated control so the save handler
2312-
// can read it via valueSlotEl.querySelector(...).
2354+
// path. The control is always visible (even for op=exists) so users
2355+
// can refine the rule later without re-discovering where the input
2356+
// went.
23132357
const renderValueControl = async (existingValue) => {
23142358
const op = opEl.value;
23152359
const path = currentPath();
2316-
if (op === 'exists') {
2317-
valueSlotEl.innerHTML = '<em style="color:var(--text-secondary);font-size:0.78rem">' +
2318-
'(no value needed for op=exists)</em>';
2319-
return;
2320-
}
2360+
const pathMeta = ((toolSchema && toolSchema.paths) || [])
2361+
.find(p => p.path === path);
23212362
const sourceKey = (toolSchema && toolSchema.value_sources)
23222363
? toolSchema.value_sources[path]
23232364
: null;
23242365
const isMulti = (op === 'in' || op === 'not_in');
23252366
const isSingleChoice = (op === 'eq' || op === 'neq');
2326-
if (sourceKey && (isMulti || isSingleChoice)) {
2367+
const choosable = isMulti || isSingleChoice;
2368+
2369+
// 1) Live value source (e.g. ha_entities) wins — most useful.
2370+
if (sourceKey && choosable) {
23272371
valueSlotEl.innerHTML = '<em style="color:var(--text-secondary);font-size:0.78rem">' +
23282372
'Loading choices…</em>';
23292373
const choices = await loadValueChoices(sourceKey);
2330-
if (!choices) {
2331-
// Fall back to free text if the fetch failed — the user can
2332-
// still author the rule by hand.
2333-
renderFreeTextValue(existingValue);
2374+
if (choices) {
2375+
renderChoiceSelect(choices, existingValue, isMulti);
2376+
renderHint(op);
23342377
return;
23352378
}
2336-
const existingArr = Array.isArray(existingValue)
2337-
? existingValue
2338-
: (existingValue !== undefined && existingValue !== null ? [existingValue] : []);
2339-
let html = '<select class="policy-predicate-value-control"' +
2340-
(isMulti ? ' multiple size="6" style="min-width:200px"' : '') +
2341-
'>';
2342-
if (!isMulti) {
2343-
html += '<option value="">(pick a value)</option>';
2344-
}
2345-
for (const c of choices) {
2346-
const selected = existingArr.includes(c) ? ' selected' : '';
2347-
html += '<option value="' + escapeHtml(String(c)) + '"' + selected + '>' +
2348-
escapeHtml(String(c)) + '</option>';
2349-
}
2350-
html += '</select>';
2351-
valueSlotEl.innerHTML = html;
2379+
// fetch failed → fall through to free-text
2380+
}
2381+
2382+
// 2) Schema-declared enum — render as choice list too.
2383+
if (choosable && pathMeta && Array.isArray(pathMeta.enum) && pathMeta.enum.length) {
2384+
renderChoiceSelect(pathMeta.enum, existingValue, isMulti);
2385+
renderHint(op);
23522386
return;
23532387
}
2388+
2389+
// 3) Free-text JSON fallback (or op=exists, where blank is the norm).
23542390
renderFreeTextValue(existingValue);
2391+
renderHint(op);
2392+
};
2393+
2394+
const renderChoiceSelect = (choices, existingValue, isMulti) => {
2395+
const existingArr = Array.isArray(existingValue)
2396+
? existingValue
2397+
: (existingValue !== undefined && existingValue !== null ? [existingValue] : []);
2398+
let html = '<select class="policy-predicate-value-control"' +
2399+
(isMulti ? ' multiple size="6" style="min-width:220px"' : '') +
2400+
'>';
2401+
if (!isMulti) {
2402+
html += '<option value="">(leave blank or pick a value)</option>';
2403+
}
2404+
for (const c of choices) {
2405+
const selected = existingArr.includes(c) ? ' selected' : '';
2406+
html += '<option value="' + escapeHtml(String(c)) + '"' + selected + '>' +
2407+
escapeHtml(String(c)) + '</option>';
2408+
}
2409+
html += '</select>';
2410+
valueSlotEl.innerHTML = html;
23552411
};
23562412
23572413
const renderFreeTextValue = (existingValue) => {
23582414
const op = opEl.value;
2359-
const placeholder = (op === 'in' || op === 'not_in')
2360-
? 'JSON list: [&quot;lock&quot;,&quot;alarm&quot;]'
2361-
: 'JSON: &quot;lock&quot; or 100 or true';
2415+
let placeholder;
2416+
if (op === 'exists') {
2417+
placeholder = 'usually left blank';
2418+
} else if (op === 'in' || op === 'not_in') {
2419+
placeholder = '["lock","alarm_control_panel"]';
2420+
} else if (op === 'regex') {
2421+
placeholder = '^light\\..+';
2422+
} else {
2423+
placeholder = '"lock" or 42 or true';
2424+
}
23622425
const initial = (existingValue === undefined || existingValue === null)
23632426
? ''
23642427
: JSON.stringify(existingValue);
23652428
valueSlotEl.innerHTML = '<input type="text" ' +
23662429
'class="policy-predicate-value-control policy-predicate-value" ' +
2367-
'placeholder="' + placeholder + '" ' +
2430+
'placeholder="' + escapeHtml(placeholder) + '" ' +
23682431
'value="' + escapeHtml(initial) + '">';
23692432
};
23702433
2434+
const renderHint = (op) => {
2435+
// Remove any previous hint then add a fresh one below the value row.
2436+
const oldHint = formEl.querySelector('.policy-form-hint');
2437+
if (oldHint) oldHint.remove();
2438+
const hint = document.createElement('div');
2439+
hint.className = 'policy-form-hint';
2440+
hint.textContent = hintForOp(op);
2441+
formEl.querySelector('.policy-value-row').after(hint);
2442+
};
2443+
23712444
const readValueControl = () => {
2445+
const op = opEl.value;
23722446
const ctrl = valueSlotEl.querySelector('.policy-predicate-value-control');
23732447
if (!ctrl) return {ok: true, value: undefined};
23742448
if (ctrl.tagName === 'SELECT') {
23752449
if (ctrl.multiple) {
23762450
const picked = Array.from(ctrl.selectedOptions).map(o => o.value);
23772451
if (picked.length === 0) {
2452+
if (VALUE_OPTIONAL_OPS.has(op)) return {ok: true, value: undefined};
23782453
return {ok: false, error: 'pick at least one value'};
23792454
}
23802455
return {ok: true, value: picked};
23812456
}
23822457
if (!ctrl.value) {
2458+
if (VALUE_OPTIONAL_OPS.has(op)) return {ok: true, value: undefined};
23832459
return {ok: false, error: 'pick a value'};
23842460
}
23852461
return {ok: true, value: ctrl.value};
23862462
}
23872463
const raw = ctrl.value.trim();
23882464
if (!raw) {
2389-
return {ok: false, error: 'value is required (use JSON: "lock", ["a","b"], 100, true)'};
2465+
if (VALUE_OPTIONAL_OPS.has(op)) return {ok: true, value: undefined};
2466+
return {ok: false, error: 'value is required for op=' + op};
23902467
}
2468+
// First try raw JSON. If that fails, fall back to smart-coercion
2469+
// so users can type "lock" or "lock,alarm" without remembering the
2470+
// quoting rules.
23912471
try {
23922472
return {ok: true, value: JSON.parse(raw)};
2393-
} catch (e) {
2394-
return {ok: false, error: 'Invalid JSON: ' + e.message};
2473+
} catch (_e) {
2474+
const coerced = coerceBarewords(raw, op);
2475+
if (coerced.ok) return coerced;
2476+
return {ok: false, error: coerced.error};
23952477
}
23962478
};
23972479
2480+
// Coerce common bareword inputs into the JSON the backend expects.
2481+
// "lock" (op=eq) → "lock"
2482+
// "lock" (op=in) → ["lock"]
2483+
// "lock,alarm_control" (op=in/not_in) → ["lock","alarm_control"]
2484+
// "42" → 42 (numeric autodetect for any op)
2485+
// "true" / "false" → boolean
2486+
const coerceBarewords = (raw, op) => {
2487+
const wrap = (v) => (op === 'in' || op === 'not_in') ? [v] : v;
2488+
if (op === 'in' || op === 'not_in') {
2489+
// Try comma-split first — if any chunk is comma-separated, build list
2490+
if (raw.indexOf(',') !== -1) {
2491+
const items = raw.split(',').map(s => s.trim()).filter(Boolean);
2492+
if (items.length === 0) {
2493+
return {ok: false, error: 'empty list for op=' + op};
2494+
}
2495+
return {ok: true, value: items.map(coerceScalar)};
2496+
}
2497+
}
2498+
const scalar = coerceScalar(raw);
2499+
return {ok: true, value: wrap(scalar)};
2500+
};
2501+
2502+
const coerceScalar = (s) => {
2503+
if (s === 'true') return true;
2504+
if (s === 'false') return false;
2505+
if (s === 'null') return null;
2506+
if (/^-?\\d+$/.test(s)) return parseInt(s, 10);
2507+
if (/^-?\\d+\\.\\d+$/.test(s)) return parseFloat(s);
2508+
return s; // plain string
2509+
};
2510+
23982511
const fetchToolSchema = async () => {
23992512
if (toolSchema !== null) return toolSchema;
24002513
try {
@@ -2467,14 +2580,22 @@ def apply_tool_visibility(
24672580
return;
24682581
}
24692582
const predicate = {path: path, op: op};
2583+
// op=exists is presence-only — backend rejects any value field,
2584+
// so ignore whatever's in the value box even if the user typed
2585+
// something. Other ops read normally.
24702586
if (op !== 'exists') {
24712587
const parsed = readValueControl();
24722588
if (!parsed.ok) {
24732589
errorEl.textContent = parsed.error;
24742590
errorEl.style.display = '';
24752591
return;
24762592
}
2477-
predicate.value = parsed.value;
2593+
// Only attach `value` when the user actually entered one — backend
2594+
// accepts a missing field for eq/neq/contains and treats it as
2595+
// a null-match.
2596+
if (parsed.value !== undefined) {
2597+
predicate.value = parsed.value;
2598+
}
24782599
}
24792600
if (editingIdx >= 0) {
24802601
rule.when[editingIdx] = predicate;

0 commit comments

Comments
 (0)