@@ -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: ["lock","alarm"]'
2361- : 'JSON: "lock" 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