diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index eda1844..9ef2fab 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,14 +7,14 @@ }, "metadata": { "description": "Structured agent-user communication: validated forms, decision cards with recommendations, structured pushback, and progress reports \u2014 batch questions instead of asking one at a time.", - "version": "0.10.0" + "version": "0.11.0" }, "plugins": [ { "name": "attune-forms", "description": "The communication grammar for AI agents: batch independent questions into ONE validated form; offer recommendations as decision cards with rationales and per-option tradeoffs; disagree constructively via pushback cards; report multi-step progress with a blocked-item picker. Renders rich HTML where the host supports widgets and degrades cleanly to plain questions everywhere else. Powered by the attune-forms PyPI package via a bundled MCP server.", "source": "./plugin", - "version": "0.10.0", + "version": "0.11.0", "author": { "name": "Smart AI Memory", "email": "admin@smartaimemory.com" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec41f6..9ae833c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ follow [SemVer](https://semver.org/). ## [Unreleased] +## [0.11.0] — 2026-08-30 + +Project path selection becomes a reusable form capability while preserving +manual entry and portable fallbacks. + +### Added +- **Path-aware text fields** — `path_kind` selects file, directory, or either, + while `path_options` carries host-validated project-relative choices. +- **Searchable Browse modal** — widget forms render a polished, accessible path + picker with Safari/Chrome-compatible overlay behavior, filtering, Escape and + backdrop closing, focus management, and escaped option labels. +- **Template support** — `FieldSlot.path_kind` lets intake templates request + the same picker without post-build mutation or form-id drift. + +### Changed +- `FORM_THEME_CSS` budget raised 12 KB → 16 KB for the isolated path-picker + family (chair-ratified 2026-08-30); forms without path fields do not emit it. + ## [0.10.0] — 2026-08-30 ### Added diff --git a/README.md b/README.md index f1f0ed6..ba98081 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,15 @@ either direction. The full argument: ["A Communication Grammar for AI Agents"](https://www.linkedin.com/pulse/communication-grammar-ai-agents-patrick-roebuck-sutse). -## What's new in 0.10.0 - -- **Inline MCP Apps surfaces** — hosts that advertise the standard MCP - Apps capability can discover and render Attune forms and command - workspaces directly in the conversation from one shared `ui://` resource. -- **Validated interactive round trips** — a click is never treated as - authority. The embedded surface sends it through the existing server-side - collector, and only a successful validated result reaches model context. -- **Portable by construction** — hosts without MCP Apps keep the same native, - structured-text, and Markdown paths. Partial support is named visibly so a - rendered control never fails silently. +## What's new in 0.11.0 + +- **Project path fields** — text fields can opt into a Browse button with + host-supplied, project-relative file and folder choices. +- **Searchable, accessible picker** — the widget modal filters paths, supports + Escape/backdrop closing and focus return, and avoids native-dialog browser + inconsistencies. +- **Portable fallback** — native and text-only hosts keep ordinary manual path + entry while server-side validation remains authoritative. This is the provider-neutral transport layer. Individual agent products still choose whether to render it inline, open it as a browser artifact, or use the diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index dad8b79..352b4a9 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "attune-forms", - "version": "0.10.0", + "version": "0.11.0", "description": "Structured agent-user communication \u2014 validated forms, decision cards, pushback, progress reports, deliberation, triage boards, confirm gates, rankings, and assumption reviews via the attune-forms MCP server.", "author": { "name": "Smart AI Memory", diff --git a/pyproject.toml b/pyproject.toml index bf35a31..1137878 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "attune-forms" -version = "0.10.0" +version = "0.11.0" description = "Dynamic forms and command workspaces: validated multi-surface interaction documents for AI agents" readme = "README.md" requires-python = ">=3.10" diff --git a/src/attune_forms/bridge.py b/src/attune_forms/bridge.py index 6bf2222..f25b1aa 100644 --- a/src/attune_forms/bridge.py +++ b/src/attune_forms/bridge.py @@ -860,6 +860,8 @@ def _derived_form_id(data: dict[str, Any]) -> str: "inferred_from", "top_n", "assumptions", + "path_kind", + "path_options", } ) @@ -1014,6 +1016,25 @@ def form_from_dict(data: dict[str, Any], *, source: str = "dict") -> FormSchema: inferred_from, inferred_problems = _parse_inferred_from(where, raw) problems.extend(inferred_problems) + path_kind = raw.get("path_kind") + path_options = raw.get("path_options", []) + if path_kind is not None and path_kind not in {"file", "directory", "either"}: + problems.append(f"{where} 'path_kind' must be 'file', 'directory', or 'either'") + if not isinstance(path_options, list) or any( + not isinstance(path, str) or not path.strip() for path in path_options + ): + problems.append(f"{where} 'path_options' must be a list of non-empty strings") + path_options = [] + elif len(path_options) != len(set(path_options)): + problems.append(f"{where} 'path_options' entries must be unique") + if path_options and path_kind is None: + problems.append(f"{where} 'path_options' requires 'path_kind'") + if path_kind is not None and qtype not in { + QuestionType.TEXT_INPUT, + QuestionType.TEXTAREA, + }: + problems.append(f"{where} 'path_kind' is only valid for text fields") + if fid and text and isinstance(fid, str) and isinstance(text, str): question = FormQuestion( id=fid, @@ -1041,6 +1062,8 @@ def form_from_dict(data: dict[str, Any], *, source: str = "dict") -> FormSchema: inferred_from=inferred_from, top_n=top_n, assumptions=assumptions, + path_kind=path_kind, + path_options=path_options, ) # R4 applies to the definition too: a `default` is a # pre-supplied answer, so it passes the same per-type diff --git a/src/attune_forms/intake_template.py b/src/attune_forms/intake_template.py index 4804240..734c6f8 100644 --- a/src/attune_forms/intake_template.py +++ b/src/attune_forms/intake_template.py @@ -97,6 +97,7 @@ class FieldSlot: help_text: str | None = None required: bool | None = None default: str | None = None + path_kind: str | None = None @dataclass @@ -193,6 +194,12 @@ def _slot_field( candidates = overrides[slot.key] else: candidates = PROVIDERS[slot.provider](ctx) + if slot.path_kind is not None: + base["type"] = slot.control or "text_input" + base["path_kind"] = slot.path_kind + base["path_options"] = list(candidates) + base.setdefault("required", True) + return base if candidates: base["type"] = slot.control or "single_select" base["options"] = [*candidates, slot.other] if slot.other else list(candidates) diff --git a/src/attune_forms/mcp_server.py b/src/attune_forms/mcp_server.py index b3e72ec..b82a778 100644 --- a/src/attune_forms/mcp_server.py +++ b/src/attune_forms/mcp_server.py @@ -132,6 +132,16 @@ def _field_schema() -> dict[str, Any]: "minimum": {"type": "number"}, "maximum": {"type": "number"}, "max_length": {"type": "integer"}, + "path_kind": { + "type": "string", + "enum": ["file", "directory", "either"], + "description": "Enable a project-relative path picker for this text field", + }, + "path_options": { + "type": "array", + "items": {"type": "string"}, + "description": "Host-validated project-relative paths offered by the picker", + }, "rationale": {"type": "string"}, "recommended": {"type": "string"}, "option_notes": {"type": "object"}, diff --git a/src/attune_forms/models.py b/src/attune_forms/models.py index 8b43c63..0818aab 100644 --- a/src/attune_forms/models.py +++ b/src/attune_forms/models.py @@ -414,6 +414,8 @@ class FormQuestion: inferred_from: str | None = None top_n: int | None = None assumptions: list[dict[str, str]] | None = None + path_kind: str | None = None + path_options: list[str] = field(default_factory=list) def __post_init__(self) -> None: """Default a CONFIRM's options to the two-way gate. diff --git a/src/attune_forms/theme.py b/src/attune_forms/theme.py index ca3aa4c..b6f7755 100644 --- a/src/attune_forms/theme.py +++ b/src/attune_forms/theme.py @@ -145,6 +145,52 @@ #attune-elicit-form .ae-textarea { resize:vertical; min-height:3.5rem; } """ +#: PATH — project-relative file/folder picker, emitted only for opted-in +#: text fields. Native/fallback surfaces remain ordinary text inputs. +CSS_PATH = """#attune-elicit-form .ae-path-control { display:flex; gap:.5rem; } +#attune-elicit-form .ae-path-value { flex:1; min-width:0; } +#attune-elicit-form .ae-path-open { min-width:6rem; padding:.5rem .8rem; + color:var(--ae-action,#004ac6); background:var(--ae-surface,#fff); + border:1px solid var(--ae-action,#004ac6); + border-radius:var(--ae-radius-control,8px); font-weight:650; cursor:pointer; } +#attune-elicit-form .ae-path-open:hover { background:var(--ae-action-soft,#edf4ff); } +#attune-elicit-form .ae-path-dialog { position:fixed; inset:0; z-index:1000; + display:grid; place-items:center; padding:1rem; box-sizing:border-box; + background:rgba(11,28,48,.48); } +#attune-elicit-form .ae-path-dialog[hidden] { display:none; } +#attune-elicit-form .ae-path-panel { width:min(42rem,calc(100% - 2rem)); + max-height:min(38rem,calc(100vh - 2rem)); box-sizing:border-box; padding:0; + color:var(--ae-text,#0b1c30); background:var(--ae-surface,#fff); + border:1px solid var(--ae-border,#c3c6d7); + border-radius:var(--ae-radius-panel,14px); + box-shadow:0 18px 48px rgba(11,28,48,.24); overflow:hidden; } +#attune-elicit-form .ae-path-head { display:flex; align-items:center; + justify-content:space-between; padding:1rem 1.1rem .75rem; + border-bottom:1px solid var(--ae-border,#c3c6d7); } +#attune-elicit-form .ae-path-title { display:grid; gap:.15rem; } +#attune-elicit-form .ae-path-title strong { font-size:16px; } +#attune-elicit-form .ae-path-title span { color:var(--ae-muted,#5f6470); + font-size:12px; font-weight:400; } +#attune-elicit-form .ae-path-close { width:2rem; height:2rem; padding:0; + color:var(--ae-muted,#5f6470); background:transparent; border:0; + border-radius:50%; font-size:20px; cursor:pointer; } +#attune-elicit-form .ae-path-close:hover { background:var(--ae-surface-raised,#eff4ff); } +#attune-elicit-form .ae-path-search { padding:.8rem 1.1rem; } +#attune-elicit-form .ae-path-filter { padding-left:.75rem; } +#attune-elicit-form .ae-path-list { display:grid; gap:.2rem; max-height:24rem; + overflow:auto; padding:0 1.1rem 1rem; } +#attune-elicit-form .ae-path-choice { display:flex; align-items:center; gap:.6rem; + width:100%; padding:.6rem .7rem; color:var(--ae-text,#0b1c30); + background:transparent; border:1px solid transparent; + border-radius:var(--ae-radius-control,8px); text-align:left; cursor:pointer; + font-family:var(--ae-font-mono,ui-monospace); font-size:13px; } +#attune-elicit-form .ae-path-choice:hover { color:var(--ae-action,#004ac6); + background:var(--ae-action-soft,#edf4ff); border-color:var(--ae-border,#c3c6d7); } +#attune-elicit-form .ae-path-icon { color:var(--ae-muted,#5f6470); font-size:16px; } +#attune-elicit-form .ae-path-empty { color:var(--ae-muted,#5f6470); + padding:.75rem; text-align:center; } +""" + #: CHECKS — non-list multi_select (checkbox rows). CSS_CHECKS = """#attune-elicit-form .ae-checks { display:flex; flex-direction:column; gap:.35rem; } @@ -290,6 +336,7 @@ #: Named family blocks in cascade-emission order (BASE is always first). CSS_FAMILIES: list[tuple[str, str]] = [ ("INPUT", CSS_INPUT), + ("PATH", CSS_PATH), ("CHECKS", CSS_CHECKS), ("LIST", CSS_LIST), ("CARDS", CSS_CARDS), diff --git a/src/attune_forms/widget.py b/src/attune_forms/widget.py index 1cc4040..ad332a3 100644 --- a/src/attune_forms/widget.py +++ b/src/attune_forms/widget.py @@ -522,7 +522,31 @@ def _control_text_input_html(q: FormQuestion) -> str: """Render TEXT_INPUT — also the fallback for any other type.""" maxlen = f' maxlength="{_esc(q.max_length)}"' if q.max_length else "" default = f' value="{_esc(q.default)}"' if q.default is not None else "" - return f'' + control = f'' + if not q.path_kind: + return control + control = control.replace("ae-input", "ae-input ae-path-value", 1) + choices = "".join( + f'' + for path in q.path_options + ) + empty = "" if choices else '

No project paths were supplied.

' + kind = {"file": "file", "directory": "folder", "either": "path"}.get(q.path_kind, "path") + return ( + f'
{control}' + f'
' + f'' + ) #: Per-type control renderers. PROGRESS is special-cased in @@ -683,6 +707,8 @@ def _families_for(question: FormQuestion) -> set[str]: return {"LIST"} if question.list_style else {"CHECKS"} if qtype == QuestionType.SINGLE_SELECT: return {"LIST"} if question.list_style else {"INPUT"} + if question.path_kind: + return {"INPUT", "PATH"} return {"INPUT"} # boolean, number, date, textarea, text_input, fallback @@ -805,6 +831,7 @@ def form_to_widget_html( var form = document.getElementById('{form_id}'); var btn = document.getElementById('ae-submit-{sfx}'); var err = document.getElementById('ae-error-{sfx}'); + var pathOpener = null; if (!form || !btn) return; // Ranking controls: move a row between the pool and the ranked list, // or within the ranked list. Pure DOM moves — the ranked list's order @@ -818,6 +845,40 @@ def form_to_widget_html( if (row) row.classList.toggle('ae-assume-editing', radio.value === 'edit'); }}); form.addEventListener('click', function(e) {{ + var open = e.target.closest ? e.target.closest('[data-path-open]') : null; + if (open && form.contains(open)) {{ + var field = open.closest('.ae-field'), dialog = field.querySelector('.ae-path-dialog'); + if (dialog) {{ + pathOpener = open; + dialog.hidden = false; + var filter = dialog.querySelector('[data-path-filter]'); + if (filter) filter.focus(); + }} + return; + }} + var close = e.target.closest ? e.target.closest('[data-path-close]') : null; + if (close && form.contains(close)) {{ + var dialog = close.closest('.ae-path-dialog'); + if (dialog) dialog.hidden = true; + if (pathOpener) pathOpener.focus(); + return; + }} + var choice = e.target.closest ? e.target.closest('[data-path-choice]') : null; + if (choice && form.contains(choice)) {{ + var field = choice.closest('.ae-field'); + var input = field.querySelector('[data-control]'); + var dialog = choice.closest('.ae-path-dialog'); + if (input) input.value = choice.getAttribute('data-path-choice'); + if (dialog) dialog.hidden = true; + if (input) input.focus(); + return; + }} + var backdrop = e.target.closest ? e.target.closest('.ae-path-dialog') : null; + if (backdrop && e.target === backdrop) {{ + backdrop.hidden = true; + if (pathOpener) pathOpener.focus(); + return; + }} var b = e.target.closest ? e.target.closest('[data-rank]') : null; if (!b || !form.contains(b)) return; var row = b.closest('.ae-rank-row'), box = b.closest('.ae-rank'); @@ -837,6 +898,21 @@ def form_to_widget_html( var count = box.querySelector('.ae-rank-count'); if (count) count.textContent = ranked.children.length; }}); + form.addEventListener('keydown', function(e) {{ + if (e.key !== 'Escape') return; + var dialog = form.querySelector('.ae-path-dialog:not([hidden])'); + if (!dialog) return; + dialog.hidden = true; + if (pathOpener) pathOpener.focus(); + }}); + form.addEventListener('input', function(e) {{ + if (!e.target.hasAttribute || !e.target.hasAttribute('data-path-filter')) return; + var query = e.target.value.toLowerCase(); + var dialog = e.target.closest('.ae-path-dialog'); + dialog.querySelectorAll('[data-path-choice]').forEach(function(choice) {{ + choice.hidden = choice.getAttribute('data-path-choice').toLowerCase().indexOf(query) < 0; + }}); + }}); btn.addEventListener('click', function() {{ var answers = {{}}; // The reader switches on data-collect — HOW to read the answer — diff --git a/tests/test_bridge.py b/tests/test_bridge.py index b7e2733..8fe5603 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -86,6 +86,32 @@ def test_optional_attrs_passthrough(self): q = form_from_dict(data).questions[0] assert q.default == "x" and q.help_text == "h" and q.required is False + def test_path_picker_metadata_passthrough(self): + data = { + "title": "T", + "fields": [ + { + "id": "scope", + "text": "Scope?", + "type": "text_input", + "path_kind": "either", + "path_options": ["src/a.py", "tests"], + } + ], + } + q = form_from_dict(data).questions[0] + assert q.path_kind == "either" + assert q.path_options == ["src/a.py", "tests"] + + @pytest.mark.parametrize("kind", ["filesystem", "", 1]) + def test_rejects_invalid_path_kind(self, kind): + data = { + "title": "T", + "fields": [{"id": "scope", "text": "Scope?", "type": "text_input", "path_kind": kind}], + } + with pytest.raises(FormValidationError, match="path_kind"): + form_from_dict(data) + def test_not_a_mapping(self): with pytest.raises(FormValidationError, match="mapping"): form_from_dict(["nope"]) # type: ignore[arg-type] diff --git a/tests/test_form_theme.py b/tests/test_form_theme.py index 4b5798b..18a3f93 100644 --- a/tests/test_form_theme.py +++ b/tests/test_form_theme.py @@ -14,7 +14,8 @@ #: merge, 8,158 B; 8 KB -> 10 KB ratified 2026-08-15, ranking-construct #: decisions.md D2-a — a consolidation pass was offered and NOT chosen, #: so the cap is not a ratchet: the next raise needs its own ruling). -_BUDGET_BYTES = 12288 +# Chair-ratified 2026-08-30: 12 KB -> 16 KB for the path-picker family. +_BUDGET_BYTES = 16384 _WORKSPACE_BUDGET_BYTES = 6144 #: ``var(--name)`` with NO fallback value — the pattern the theme diff --git a/tests/test_intake_template.py b/tests/test_intake_template.py index 5bb9371..6ffbe46 100644 --- a/tests/test_intake_template.py +++ b/tests/test_intake_template.py @@ -244,3 +244,27 @@ def test_nan_prefill_no_default_drops_cleanly(self) -> None: t = FormTemplate("T", "d", [FieldSlot(key="k", text="q?")]) ctx = ProviderContext(repo_root=Path("/nonexistent"), answered={"k": float("nan")}) assert build_form(t, ctx).questions[0].default is None + + +def test_path_slot_keeps_text_entry_and_exposes_provider_candidates() -> None: + PROVIDERS["_project_paths"] = lambda _ctx: ["src", "tests/unit"] + try: + template = FormTemplate( + title="Path", + description="", + fields=[ + FieldSlot( + key="scope", + text="Scope?", + control="text_input", + provider="_project_paths", + path_kind="either", + ) + ], + ) + question = build_form(template, ProviderContext(repo_root=Path("."))).questions[0] + assert question.type.value == "text_input" + assert question.path_kind == "either" + assert question.path_options == ["src", "tests/unit"] + finally: + del PROVIDERS["_project_paths"] diff --git a/tests/test_widget.py b/tests/test_widget.py index dd2ad15..4c774ba 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -84,6 +84,27 @@ def test_text_input_renders_text_field(self): html = _render([{"id": "a", "text": "A?", "type": "text_input"}]) assert '