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'