Skip to content

Commit 298a6e9

Browse files
committed
refactor: now just a simple protocol
1 parent c9bda69 commit 298a6e9

12 files changed

Lines changed: 883 additions & 281 deletions

File tree

packages/anya-ui/README.md

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
# anya-ui
2+
3+
A rendering layer for agentic interfaces. Agents produce markdown with embedded affordances; the framework renders them to interactive DOM and reports user actions back.
4+
5+
## Where this fits
6+
7+
```
8+
Agent → Spec → anya-ui → User → Agent
9+
```
10+
11+
The agent writes a markdown document. The framework renders it. The user acts. The framework encodes what happened. The agent receives it on the next turn.
12+
13+
anya-ui doesn't know about your agent, your tools, or your backend. It knows one thing: **how to turn a spec into interactive DOM and tell you what the user did.**
14+
15+
## The spec format
16+
17+
Standard markdown for content. Fenced YAML blocks for interactive affordances.
18+
19+
````markdown
20+
# Your Tasks
21+
22+
3 items need attention today.
23+
24+
```action
25+
name: mark_done
26+
label: Mark PR #42 done
27+
params:
28+
id: 42
29+
```
30+
31+
```input
32+
name: add_task
33+
submit: Add
34+
fields:
35+
- name: title
36+
type: text
37+
placeholder: What needs doing?
38+
- name: priority
39+
type: select
40+
options: [high, medium, low]
41+
```
42+
43+
```group
44+
layout: row
45+
```
46+
47+
**Revenue**: $42k
48+
49+
**Users**: 1,204
50+
51+
```end
52+
```
53+
````
54+
55+
Four primitives:
56+
- **Content** — markdown (headings, lists, tables, links, emphasis)
57+
- **Action** — a named button the user can click
58+
- **Input** — a named form the user can fill and submit
59+
- **Group** — a layout container (`row`, `grid`, `stack`)
60+
61+
## Install
62+
63+
```bash
64+
npm install anya-ui
65+
```
66+
67+
## Usage
68+
69+
```typescript
70+
import { mount } from 'anya-ui';
71+
72+
const ui = mount(specString, document.getElementById('app'));
73+
74+
ui.on('action', (feedback) => {
75+
// { action: "mark_done", params: { id: 42 }, timestamp: ... }
76+
sendToAgent(feedback);
77+
});
78+
79+
// Agent responds with new spec
80+
ui.update(newSpecString);
81+
```
82+
83+
## Three entry points
84+
85+
| Import | Purpose | DOM required? |
86+
|--------|---------|---------------|
87+
| `anya-ui` | Full renderer + protocol | Yes |
88+
| `anya-ui/protocol` | Parse, encode, prompt builder | No |
89+
| `anya-ui/measure` | Signal collection + friction scoring | Yes |
90+
91+
Use `anya-ui/protocol` when you only need to parse specs or encode feedback — no DOM dependency. Useful for server-side tooling, React Native bridges, or custom renderers.
92+
93+
```typescript
94+
import { parse, encode, buildSystemPrompt } from 'anya-ui/protocol';
95+
```
96+
97+
## Teaching the agent
98+
99+
```typescript
100+
import { buildSystemPrompt, encodeHistory } from 'anya-ui';
101+
102+
const systemPrompt = buildSystemPrompt({
103+
actions: [
104+
{ name: 'mark_done', description: 'Mark a task as completed' },
105+
{ name: 'assign', description: 'Assign a task to someone' },
106+
],
107+
history: encodeHistory(userActions),
108+
});
109+
110+
// Pass systemPrompt to your LLM alongside the user's message
111+
```
112+
113+
The agent learns the format from the system prompt and produces valid specs naturally — fenced code blocks are in every model's training data.
114+
115+
## Encoding feedback
116+
117+
When the user acts, encode it for the agent's next turn:
118+
119+
```typescript
120+
import { encode } from 'anya-ui';
121+
122+
ui.on('action', (feedback) => {
123+
const text = encode(feedback);
124+
// "User clicked "mark_done" (id=42)"
125+
// "User submitted "add_task" with title="Write tests", priority="high""
126+
appendToConversation(text);
127+
});
128+
```
129+
130+
## Framework integration
131+
132+
anya-ui is vanilla DOM. Wrap it in your framework's lifecycle:
133+
134+
### React
135+
136+
```tsx
137+
import { useRef, useEffect } from 'react';
138+
import { mount, type AnyaInstance } from 'anya-ui';
139+
140+
function AnyaView({ spec, onAction }) {
141+
const ref = useRef<HTMLDivElement>(null);
142+
const instanceRef = useRef<AnyaInstance | null>(null);
143+
144+
useEffect(() => {
145+
if (!ref.current) return;
146+
instanceRef.current = mount(spec, ref.current);
147+
instanceRef.current.on('action', onAction);
148+
return () => instanceRef.current?.destroy();
149+
}, []);
150+
151+
useEffect(() => {
152+
instanceRef.current?.update(spec);
153+
}, [spec]);
154+
155+
return <div ref={ref} />;
156+
}
157+
```
158+
159+
### React (protocol-only)
160+
161+
If you want to render with your own React components instead of anya-ui's DOM:
162+
163+
```tsx
164+
import { parse, encode, isAction, isInput, isGroup, isContent } from 'anya-ui/protocol';
165+
import type { Spec, SpecNode } from 'anya-ui/protocol';
166+
167+
function AnyaView({ specString, onAction }) {
168+
const spec = useMemo(() => parse(specString), [specString]);
169+
170+
return (
171+
<div>
172+
{spec.nodes.map((node, i) => (
173+
<SpecNodeView key={i} node={node} onAction={onAction} />
174+
))}
175+
</div>
176+
);
177+
}
178+
179+
function SpecNodeView({ node, onAction }) {
180+
if (isContent(node)) return <Markdown>{node.markdown}</Markdown>;
181+
if (isAction(node)) return <Button onClick={() => onAction(node.action, node.params)}>{node.label}</Button>;
182+
if (isInput(node)) return <FormFromFields fields={node.fields} onSubmit={v => onAction(node.input, v)} />;
183+
if (isGroup(node)) return <Flex>{node.content.map((n, i) => <SpecNodeView key={i} node={n} onAction={onAction} />)}</Flex>;
184+
return null;
185+
}
186+
```
187+
188+
### Web Component
189+
190+
```html
191+
<script type="module">
192+
import { mount } from 'anya-ui';
193+
194+
class AnyaElement extends HTMLElement {
195+
connectedCallback() {
196+
this.instance = mount(this.getAttribute('spec'), this);
197+
this.instance.on('action', (f) => this.dispatchEvent(new CustomEvent('action', { detail: f })));
198+
}
199+
static get observedAttributes() { return ['spec']; }
200+
attributeChangedCallback(_, __, val) { this.instance?.update(val); }
201+
disconnectedCallback() { this.instance?.destroy(); }
202+
}
203+
customElements.define('anya-ui', AnyaElement);
204+
</script>
205+
```
206+
207+
## Measurement (optional)
208+
209+
Collect interaction signals and compute friction scores:
210+
211+
```typescript
212+
import { mount } from 'anya-ui';
213+
import { withMeasurement } from 'anya-ui/measure';
214+
215+
const ui = mount(spec, container);
216+
const measured = withMeasurement(ui, container.querySelector('.anya'));
217+
218+
// Raw signals: timestamped action events with timing + choice metadata
219+
const signals = measured.signals();
220+
// [{ ts, action, kind: 'click'|'submit', durationMs, choiceCount }]
221+
222+
// Computed scores: Hick-Hyman cognitive load from current DOM state
223+
const scores = measured.scores();
224+
// [{ kind: 'cognitive', score: 0.3, severity: 'low', detail: '3 interactive elements...' }]
225+
```
226+
227+
The measurement module emits **raw signals** — what happened, when, how many choices were visible. It does NOT interpret signals, detect patterns, or advise the agent. That's the orchestration layer's job.
228+
229+
## Styling
230+
231+
Import the default stylesheet or use CSS variables to theme:
232+
233+
```css
234+
@import 'anya-ui/src/anya.css';
235+
236+
:root {
237+
--anya-text: #1a1a1a;
238+
--anya-border: #d1d5db;
239+
--anya-btn-bg: #fff;
240+
--anya-btn-hover: #f3f4f6;
241+
--anya-focus: #3b82f6;
242+
}
243+
```
244+
245+
Or write your own styles targeting `.anya-action`, `.anya-input`, `.anya-field`, `.anya-group`, `.anya-layout-row`, etc.
246+
247+
## API
248+
249+
### `mount(spec: string | Spec, target: HTMLElement): AnyaInstance`
250+
251+
Render a spec into a container element.
252+
253+
### `AnyaInstance`
254+
255+
- `.update(spec)` — re-render with new spec
256+
- `.on('action', handler)` — listen for user actions, returns unsubscribe fn
257+
- `.destroy()` — remove DOM, cleanup listeners
258+
- `.getSpec()` — current parsed spec (or null after destroy)
259+
260+
### `parse(raw: string): Spec`
261+
262+
Parse a markdown+YAML string into a structured Spec object.
263+
264+
### `render(spec: Spec, opts: RenderOptions): HTMLElement`
265+
266+
Render a Spec to a detached DOM element (for custom mounting).
267+
268+
### `encode(feedback: ActionFeedback): string`
269+
270+
Format a user action as a human-readable string for agent context.
271+
272+
### `encodeHistory(history: ActionFeedback[]): string`
273+
274+
Format multiple actions as newline-separated entries.
275+
276+
### `buildSystemPrompt(opts?: PromptOptions): string`
277+
278+
Generate a system prompt that teaches an agent the spec format.
279+
280+
Options:
281+
- `actions` — list of available actions with descriptions
282+
- `inputs` — list of available inputs with descriptions
283+
- `context` — arbitrary context string appended to the prompt
284+
- `history` — recent user action history (from `encodeHistory`)
285+
286+
### `withMeasurement(instance, root): MeasuredInstance`
287+
288+
Attach signal collection (from `anya-ui/measure`).
289+
290+
- `.signals()` — collected interaction signals
291+
- `.scores()` — computed friction scores from current DOM
292+
- `.destroy()` — cleanup and destroy instance
293+
294+
## Design principles
295+
296+
1. **Protocol, not component library** — the spec is the interface contract between agent and renderer
297+
2. **Agent-native format** — markdown + YAML fenced blocks, formats agents produce at near-100% reliability
298+
3. **Graceful degradation** — spec renders as readable code blocks in any markdown viewer
299+
4. **Framework-agnostic** — vanilla DOM output, wrap in any framework; or use protocol-only and render yourself
300+
5. **Feedback is first-class** — every affordance has a name; every user action is encodable
301+
6. **Observation without opinion** — measurement emits signals, doesn't interpret them
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { build } from 'esbuild';
22

33
await build({
4-
entryPoints: ['src/index.ts', 'src/measure.ts'],
4+
entryPoints: [
5+
'src/protocol.ts',
6+
'src/index.ts',
7+
'src/measure.ts',
8+
],
59
outdir: 'dist',
610
bundle: true,
711
format: 'esm',

packages/anya-ui/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141
"default": "./dist/index.js"
4242
}
4343
},
44+
"./protocol": {
45+
"import": {
46+
"types": "./dist/protocol.d.ts",
47+
"default": "./dist/protocol.js"
48+
}
49+
},
4450
"./measure": {
4551
"import": {
4652
"types": "./dist/measure.d.ts",
@@ -50,7 +56,7 @@
5056
"./package.json": "./package.json"
5157
},
5258
"scripts": {
53-
"build": "node build.mjs && npx dts-bundle-generator -o dist/index.d.ts src/index.ts --project tsconfig.json --no-banner && npx dts-bundle-generator -o dist/measure.d.ts src/measure.ts --project tsconfig.json --no-banner",
59+
"build": "npx tsx build.ts && npx dts-bundle-generator -o dist/protocol.d.ts src/protocol.ts --project tsconfig.json --no-banner && npx dts-bundle-generator -o dist/index.d.ts src/index.ts --project tsconfig.json --no-banner && npx dts-bundle-generator -o dist/measure.d.ts src/measure.ts --project tsconfig.json --no-banner",
5460
"clean": "rm -rf dist",
5561
"dev": "tsc -p tsconfig.json --watch",
5662
"lint": "tsc -p tsconfig.json --noEmit",
@@ -68,6 +74,7 @@
6874
"dts-bundle-generator": "^9.5.0",
6975
"esbuild": "^0.28.0",
7076
"jsdom": "^26.0.0",
77+
"tsx": "^4.19.0",
7178
"typescript": "^5.7.0",
7279
"vitest": "^4.0.18"
7380
}

packages/anya-ui/src/index.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ export function mount(input: Spec | string, target: HTMLElement): AnyaInstance {
2121
let tree = render(spec, { onAction });
2222
target.appendChild(tree);
2323

24-
let lastInput: Spec | string = input;
24+
let lastSpec = spec;
2525

2626
return {
2727
update(newInput) {
28-
if (newInput === lastInput) return;
29-
lastInput = newInput;
30-
spec = typeof newInput === 'string' ? parse(newInput) : newInput;
31-
const newTree = render(spec, { onAction });
28+
const newSpec = typeof newInput === 'string' ? parse(newInput) : newInput;
29+
if (newSpec === lastSpec) return;
30+
spec = newSpec;
31+
lastSpec = newSpec;
32+
const newTree = render(newSpec, { onAction });
3233
target.replaceChild(newTree, tree);
3334
tree = newTree;
3435
},
@@ -47,12 +48,11 @@ export function mount(input: Spec | string, target: HTMLElement): AnyaInstance {
4748
};
4849
}
4950

50-
export { parse } from './parse';
5151
export { render } from './render';
52-
export { encode, encodeHistory } from './encode';
53-
export { buildSystemPrompt } from './prompt';
5452
export type { RenderOptions } from './render';
55-
export type { PromptOptions } from './prompt';
53+
54+
// Re-export the full protocol layer
55+
export { parse, encode, encodeHistory, buildSystemPrompt, isAction, isInput, isGroup, isContent } from './protocol';
5656
export type {
5757
Spec,
5858
SpecNode,
@@ -62,5 +62,5 @@ export type {
6262
GroupNode,
6363
FieldDef,
6464
ActionFeedback,
65-
} from './spec';
66-
export { isAction, isInput, isGroup, isContent } from './spec';
65+
PromptOptions,
66+
} from './protocol';

0 commit comments

Comments
 (0)