Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 9 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
23 changes: 23 additions & 0 deletions src/attune_forms/bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,8 @@ def _derived_form_id(data: dict[str, Any]) -> str:
"inferred_from",
"top_n",
"assumptions",
"path_kind",
"path_options",
}
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/attune_forms/intake_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/attune_forms/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
2 changes: 2 additions & 0 deletions src/attune_forms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions src/attune_forms/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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),
Expand Down
78 changes: 77 additions & 1 deletion src/attune_forms/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<input type="text" data-control class="ae-input"{maxlen}{default}>'
control = f'<input type="text" data-control class="ae-input"{maxlen}{default}>'
if not q.path_kind:
return control
control = control.replace("ae-input", "ae-input ae-path-value", 1)
choices = "".join(
f'<button type="button" class="ae-path-choice" data-path-choice="{_esc(path)}">'
f'<span class="ae-path-icon" aria-hidden="true">›</span>{_esc(path)}</button>'
for path in q.path_options
)
empty = "" if choices else '<p class="ae-path-empty">No project paths were supplied.</p>'
kind = {"file": "file", "directory": "folder", "either": "path"}.get(q.path_kind, "path")
return (
f'<div class="ae-path-control">{control}'
f'<button type="button" class="ae-path-open" data-path-open>Browse…</button></div>'
f'<div class="ae-path-dialog" role="dialog" aria-modal="true" hidden>'
f'<div class="ae-path-panel"><div class="ae-path-head">'
f'<div class="ae-path-title"><strong>Choose a project {kind}</strong>'
f"<span>Paths are relative to the project root</span></div>"
f'<button type="button" class="ae-path-close" data-path-close '
f'aria-label="Close">×</button></div>'
f'<div class="ae-path-search"><input type="search" '
f'class="ae-input ae-path-filter" data-path-filter '
f'placeholder="Filter project paths…"></div>'
f'<div class="ae-path-list">{choices}{empty}</div></div></div>'
)


#: Per-type control renderers. PROGRESS is special-cased in
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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');
Expand All @@ -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 —
Expand Down
26 changes: 26 additions & 0 deletions tests/test_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion tests/test_form_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading