@haikit/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 hai contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @haikit/client
2
+
3
+ Browser runtime. Plain ESM, no build step.
4
+
5
+ ```js
6
+ import { mountChat } from "@haikit/client/app.js";
7
+ mountChat({ root, registry, suggestions: ["…"] });
8
+ ```
9
+
10
+ `mountChat` is the ten-line path: it builds the shell, wires the composer,
11
+ renders the transcript and mounts your surfaces. It is a convenience, not the
12
+ API — it is built from `createChat` + `renderTranscript`, and dropping to those
13
+ when the default shell stops fitting is the expected path.
14
+
15
+ `hai.css` styles **only what the framework renders** — transcript blocks, tool
16
+ rows, the surface container, shell, composer, inspector. It deliberately does
17
+ not style the inside of your surfaces; your components emit their own class
18
+ names and your stylesheet owns them. Every colour is a `--hai-*` custom
19
+ property, so override what you like.
20
+
21
+ ## Serving it
22
+
23
+ This package ships source, not a bundle. With a bundler, import normally. With
24
+ no bundler, serve `node_modules/@haikit/client/src/` under a URL prefix — the
25
+ examples mount it at `/hai-client/`.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@haikit/client",
3
+ "version": "0.1.0",
4
+ "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
+ "keywords": [
6
+ "haikit",
7
+ "llm",
8
+ "agent",
9
+ "agentic-ui",
10
+ "generative-ui",
11
+ "tool-use",
12
+ "chat-ui",
13
+ "sse",
14
+ "ai"
15
+ ],
16
+ "type": "module",
17
+ "types": "./src/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.d.ts",
21
+ "default": "./src/index.js"
22
+ },
23
+ "./app.js": {
24
+ "types": "./src/app.d.ts",
25
+ "default": "./src/app.js"
26
+ },
27
+ "./transcript.js": {
28
+ "types": "./src/transcript.d.ts",
29
+ "default": "./src/transcript.js"
30
+ },
31
+ "./hai.css": "./src/hai.css",
32
+ "./package.json": "./package.json",
33
+ "./*": "./src/*"
34
+ },
35
+ "files": [
36
+ "src"
37
+ ],
38
+ "sideEffects": [
39
+ "*.css"
40
+ ],
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "license": "MIT",
45
+ "homepage": "https://github.com/wfoxd/haikit/tree/main/packages/client#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/wfoxd/haikit/issues"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/wfoxd/haikit.git",
52
+ "directory": "packages/client"
53
+ }
54
+ }
package/src/app.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { Chat, Registry } from "./index.js";
2
+
3
+ export interface MountChatOptions {
4
+ root: HTMLElement;
5
+ registry: Registry;
6
+ /** Base path the server's two routes are mounted at. Default `/hai`. */
7
+ endpoint?: string;
8
+ title?: string;
9
+ subtitle?: string;
10
+ /** Clickable starter prompts shown on the empty transcript. */
11
+ suggestions?: string[];
12
+ /** Show the context inspector. Default true — keep it on while developing. */
13
+ inspector?: boolean;
14
+ placeholder?: string;
15
+ emptyText?: string;
16
+ }
17
+
18
+ /**
19
+ * Builds the shell, wires the composer, renders the transcript and mounts your
20
+ * surfaces. The ten-line path to a running app.
21
+ *
22
+ * It is a convenience, not the API — everything it does is built on `createChat`
23
+ * and `renderTranscript`. Dropping to those when this shell stops fitting is the
24
+ * expected path, not a failure mode.
25
+ */
26
+ export function mountChat(options: MountChatOptions): Chat;
27
+
28
+ /** The context inspector: makes the dual channel visible. */
29
+ export function renderInspector(
30
+ el: HTMLElement,
31
+ context?: { messages?: any[]; modelTokens?: number; uiTokens?: number },
32
+ ): void;
package/src/app.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * hai-client — the default UI.
3
+ *
4
+ * `mountChat()` builds the shell, wires the composer, renders the transcript and
5
+ * (optionally) the context inspector. It is the ten-line path to a running app.
6
+ *
7
+ * import { mountChat } from "/hai-client/app.js";
8
+ * import { registry } from "./components.js";
9
+ * mountChat({ root: document.querySelector("#app"), registry });
10
+ *
11
+ * It is a convenience, not the API. Everything it does is built on the headless
12
+ * pieces — `createChat` and `renderTranscript` — and when this shell stops
13
+ * fitting, drop to those and write your own. That is the expected path, not a
14
+ * failure mode.
15
+ */
16
+
17
+ import { createChat } from "./index.js";
18
+ import { renderTranscript, h } from "./transcript.js";
19
+
20
+ /**
21
+ * @param {{
22
+ * root: HTMLElement,
23
+ * registry: Record<string, { mount: Function }>,
24
+ * endpoint?: string,
25
+ * title?: string,
26
+ * subtitle?: string,
27
+ * suggestions?: string[],
28
+ * inspector?: boolean,
29
+ * placeholder?: string,
30
+ * emptyText?: string,
31
+ * }} options
32
+ */
33
+ export function mountChat({
34
+ root,
35
+ registry,
36
+ endpoint = "/hai",
37
+ title = "hai",
38
+ subtitle = "",
39
+ suggestions = [],
40
+ inspector = true,
41
+ placeholder = "message…",
42
+ emptyText = "",
43
+ }) {
44
+ const chat = createChat({ endpoint, registry });
45
+
46
+ // ── shell ──────────────────────────────────────────────────────────
47
+ const shell = h("div", "hai-shell");
48
+
49
+ const top = h("header", "hai-top");
50
+ const modelEl = h("span", "hai-model", "…");
51
+ top.append(h("span", "hai-title", title), h("span", "hai-subtitle", subtitle), modelEl);
52
+
53
+ const transcript = h("div", "hai-transcript");
54
+ const statusEl = h("div", "hai-status");
55
+ const input = h("textarea", "hai-input");
56
+ input.rows = 1;
57
+ input.placeholder = placeholder;
58
+ const sendBtn = h("button", "hai-send", "Send");
59
+
60
+ const composer = h("div", "hai-composer");
61
+ composer.append(input, sendBtn);
62
+
63
+ const chatPane = h("section", "hai-chat");
64
+ chatPane.append(transcript, statusEl, composer);
65
+
66
+ const main = h("div", `hai-main${inspector ? " has-inspector" : ""}`);
67
+ main.append(chatPane);
68
+
69
+ let inspectorBody = null;
70
+ if (inspector) {
71
+ const aside = h("aside", "hai-inspector");
72
+ const heading = h("div", "hai-inspector-title", "Model context ");
73
+ heading.append(h("span", null, "— everything the model sees"));
74
+ inspectorBody = h("div", "hai-inspector-body");
75
+ aside.append(heading, inspectorBody);
76
+ main.append(aside);
77
+ }
78
+
79
+ shell.append(top, main);
80
+ root.replaceChildren(shell);
81
+
82
+ // ── empty state ────────────────────────────────────────────────────
83
+ const send = () => {
84
+ const text = input.value.trim();
85
+ if (!text) return;
86
+ input.value = "";
87
+ chat.send(text);
88
+ };
89
+
90
+ function renderEmpty() {
91
+ const empty = h("div", "hai-empty");
92
+ if (emptyText) empty.append(h("p", null, emptyText));
93
+ if (suggestions.length) {
94
+ const row = h("div", "hai-suggestions");
95
+ for (const s of suggestions) {
96
+ const b = h("button", "hai-suggestion", s);
97
+ b.onclick = () => { input.value = s; send(); };
98
+ row.append(b);
99
+ }
100
+ empty.append(row);
101
+ }
102
+ transcript.replaceChildren(empty);
103
+ }
104
+
105
+ // ── wiring ─────────────────────────────────────────────────────────
106
+ chat.subscribe((state) => {
107
+ if (state.blocks.length === 0) renderEmpty();
108
+ else renderTranscript(transcript, chat);
109
+
110
+ modelEl.textContent = state.model;
111
+ sendBtn.disabled = state.status === "streaming";
112
+ statusEl.dataset.status = state.status;
113
+ statusEl.textContent =
114
+ state.status === "streaming" ? "thinking…"
115
+ : state.status === "awaiting" ? "awaiting your selection in the component above"
116
+ : "";
117
+ input.placeholder =
118
+ state.status === "awaiting" ? "pick an option above — or type to override" : placeholder;
119
+
120
+ if (inspectorBody) renderInspector(inspectorBody, state.context);
121
+ });
122
+
123
+ sendBtn.onclick = send;
124
+ input.onkeydown = (e) => {
125
+ if (e.key === "Enter" && !e.shiftKey) {
126
+ e.preventDefault();
127
+ send();
128
+ }
129
+ };
130
+
131
+ renderEmpty();
132
+ return chat;
133
+ }
134
+
135
+ /**
136
+ * The context inspector. Makes the dual channel visible, which is the whole
137
+ * argument — keep it on while developing.
138
+ */
139
+ export function renderInspector(el, { messages = [], modelTokens = 0, uiTokens = 0 } = {}) {
140
+ el.replaceChildren();
141
+
142
+ const pct = uiTokens ? Math.round((1 - modelTokens / (modelTokens + uiTokens)) * 100) : 0;
143
+ const bar = h("div", "hai-ctx-bar");
144
+ bar.append(
145
+ h("div", null, `model context ~${modelTokens} tok`),
146
+ h("div", "hai-stat-dim", `browser payload ~${uiTokens} tok`),
147
+ h("div", "hai-stat-good", uiTokens ? `${pct}% kept out of context` : ""),
148
+ );
149
+ el.append(bar);
150
+
151
+ for (const m of messages) {
152
+ const box = h("div", "hai-msg");
153
+ box.append(h("div", "hai-role", m.role));
154
+ const body = h("div", "hai-mbody");
155
+ if (typeof m.content === "string") {
156
+ body.append(h("div", null, m.content));
157
+ } else {
158
+ for (const b of m.content ?? []) {
159
+ if (b.type === "text" && b.text) body.append(h("div", null, b.text));
160
+ if (b.type === "tool_use")
161
+ body.append(h("div", "hai-tu", `tool_use ${b.name} ${JSON.stringify(b.input)}`));
162
+ if (b.type === "tool_result") body.append(h("div", "hai-tr", b.content));
163
+ }
164
+ }
165
+ box.append(body);
166
+ el.append(box);
167
+ }
168
+ }
package/src/hai.css ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * hai-client — default UI.
3
+ *
4
+ * Styles ONLY what the framework renders: transcript blocks, the tool
5
+ * provenance row, the surface container, and the shell that mountChat() builds.
6
+ *
7
+ * It deliberately does not style the inside of your surfaces. Your components
8
+ * emit their own class names and your own stylesheet owns them — otherwise the
9
+ * framework starts dictating what your UI looks like.
10
+ *
11
+ * Everything is driven by custom properties. Override them in your own sheet:
12
+ *
13
+ * :root { --hai-accent: #b07cff; --hai-bg: #101014; }
14
+ */
15
+
16
+ :root {
17
+ --hai-bg: #0d1013;
18
+ --hai-panel: #14181d;
19
+ --hai-panel-2: #11161c;
20
+ --hai-line: #242c34;
21
+ --hai-fg: #dde3ea;
22
+ --hai-dim: #7c8894;
23
+ --hai-accent: #6ea8fe;
24
+ --hai-good: #63c49a;
25
+ --hai-warn: #e0b055;
26
+ --hai-bad: #e07070;
27
+ --hai-radius: 10px;
28
+ --hai-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
29
+ --hai-sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
30
+ --hai-inspector-width: 400px;
31
+ }
32
+
33
+ /* ── shell ──────────────────────────────────────────────────────────── */
34
+ .hai-shell {
35
+ height: 100vh;
36
+ display: flex;
37
+ flex-direction: column;
38
+ background: var(--hai-bg);
39
+ color: var(--hai-fg);
40
+ font: 14px/1.55 var(--hai-sans);
41
+ }
42
+ .hai-top {
43
+ display: flex;
44
+ align-items: baseline;
45
+ gap: 12px;
46
+ padding: 10px 16px;
47
+ border-bottom: 1px solid var(--hai-line);
48
+ background: var(--hai-panel);
49
+ }
50
+ .hai-title { font-weight: 700; letter-spacing: .04em; }
51
+ .hai-subtitle { color: var(--hai-dim); font-size: 12px; }
52
+ .hai-model { margin-left: auto; font: 11px var(--hai-mono); color: var(--hai-warn); }
53
+
54
+ .hai-main { flex: 1; display: grid; grid-template-columns: minmax(0, 1fr); min-height: 0; }
55
+ .hai-main.has-inspector { grid-template-columns: minmax(0, 1fr) var(--hai-inspector-width); }
56
+ .hai-chat { display: flex; flex-direction: column; min-height: 0; }
57
+
58
+ .hai-transcript { flex: 1; overflow-y: auto; padding: 18px 20px 8px; }
59
+ .hai-empty { color: var(--hai-dim); max-width: 60ch; }
60
+ .hai-suggestions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
61
+ .hai-suggestion {
62
+ background: var(--hai-panel-2);
63
+ color: var(--hai-fg);
64
+ border: 1px solid var(--hai-line);
65
+ border-radius: 999px;
66
+ padding: 5px 12px;
67
+ cursor: pointer;
68
+ font: inherit;
69
+ font-size: 13px;
70
+ }
71
+ .hai-suggestion:hover { border-color: var(--hai-accent); }
72
+
73
+ /* ── transcript blocks ──────────────────────────────────────────────── */
74
+ .hai-block { margin: 0 0 12px; max-width: 70ch; }
75
+ .hai-user .hai-body {
76
+ background: #1d2733;
77
+ border: 1px solid #27333f;
78
+ border-radius: var(--hai-radius);
79
+ padding: 8px 12px;
80
+ display: inline-block;
81
+ }
82
+ .hai-assistant .hai-body { white-space: pre-wrap; }
83
+ .hai-interaction {
84
+ color: var(--hai-good);
85
+ font-size: 13px;
86
+ display: flex;
87
+ gap: 8px;
88
+ border-left: 2px solid var(--hai-good);
89
+ padding-left: 10px;
90
+ }
91
+ .hai-error { color: var(--hai-bad); font: 12px var(--hai-mono); }
92
+
93
+ /* tool provenance row — chrome for the surface directly beneath it */
94
+ .hai-tool {
95
+ border: 1px solid var(--hai-line);
96
+ border-radius: var(--hai-radius) var(--hai-radius) 0 0;
97
+ background: var(--hai-panel);
98
+ max-width: none;
99
+ margin-bottom: 0;
100
+ }
101
+ .hai-tool summary {
102
+ display: flex;
103
+ align-items: center;
104
+ gap: 8px;
105
+ padding: 7px 12px;
106
+ cursor: pointer;
107
+ font: 12px var(--hai-mono);
108
+ list-style: none;
109
+ }
110
+ .hai-tool summary::-webkit-details-marker { display: none; }
111
+ .hai-gear { color: var(--hai-dim); }
112
+ .hai-tname { color: var(--hai-accent); }
113
+ .hai-targs { color: var(--hai-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
114
+ .hai-tstatus { color: var(--hai-dim); }
115
+ .hai-status-awaiting .hai-tstatus { color: var(--hai-warn); }
116
+ .hai-status-running .hai-tstatus { color: var(--hai-accent); }
117
+ .hai-tool-detail { padding: 0 12px 10px; border-top: 1px solid var(--hai-line); }
118
+ .hai-label { font: 11px var(--hai-mono); color: var(--hai-dim); margin: 8px 0 4px; }
119
+ .hai-digest {
120
+ margin: 0;
121
+ padding: 8px 10px;
122
+ background: #0b0e11;
123
+ border: 1px solid var(--hai-line);
124
+ border-radius: 6px;
125
+ font: 11.5px/1.5 var(--hai-mono);
126
+ color: var(--hai-good);
127
+ white-space: pre-wrap;
128
+ max-height: 180px;
129
+ overflow: auto;
130
+ }
131
+
132
+ /* surface container — your component mounts inside this */
133
+ .hai-surface {
134
+ border: 1px solid var(--hai-line);
135
+ border-top: none;
136
+ border-radius: 0 0 var(--hai-radius) var(--hai-radius);
137
+ background: var(--hai-panel-2);
138
+ margin-bottom: 14px;
139
+ overflow: hidden;
140
+ }
141
+ .hai-skeleton, .hai-surface-error { padding: 14px; font: 12px var(--hai-mono); color: var(--hai-dim); }
142
+ .hai-surface-error { color: var(--hai-bad); }
143
+
144
+ /* ── status + composer ──────────────────────────────────────────────── */
145
+ .hai-status { padding: 0 20px 4px; font-size: 12px; min-height: 18px; color: var(--hai-dim); }
146
+ .hai-status[data-status="awaiting"] { color: var(--hai-warn); }
147
+ .hai-composer { display: flex; gap: 8px; padding: 10px 20px 16px; border-top: 1px solid var(--hai-line); }
148
+ .hai-input {
149
+ flex: 1;
150
+ resize: none;
151
+ background: var(--hai-panel);
152
+ color: var(--hai-fg);
153
+ border: 1px solid var(--hai-line);
154
+ border-radius: 8px;
155
+ padding: 9px 12px;
156
+ font: inherit;
157
+ min-height: 38px;
158
+ max-height: 180px;
159
+ }
160
+ .hai-input:focus { outline: none; border-color: var(--hai-accent); }
161
+ .hai-send {
162
+ background: var(--hai-accent);
163
+ color: var(--hai-bg);
164
+ border: none;
165
+ border-radius: 8px;
166
+ padding: 0 18px;
167
+ font: inherit;
168
+ font-weight: 600;
169
+ cursor: pointer;
170
+ }
171
+ .hai-send:disabled { opacity: .4; cursor: default; }
172
+
173
+ /* ── context inspector ──────────────────────────────────────────────── */
174
+ .hai-inspector {
175
+ border-left: 1px solid var(--hai-line);
176
+ background: var(--hai-panel-2);
177
+ display: flex;
178
+ flex-direction: column;
179
+ min-height: 0;
180
+ }
181
+ .hai-inspector-title { padding: 10px 14px; border-bottom: 1px solid var(--hai-line); font-size: 12px; font-weight: 600; }
182
+ .hai-inspector-title span { color: var(--hai-dim); font-weight: 400; }
183
+ .hai-inspector-body { flex: 1; overflow-y: auto; padding: 12px 14px; }
184
+ .hai-ctx-bar {
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: 3px;
188
+ margin-bottom: 14px;
189
+ padding: 10px;
190
+ border: 1px solid var(--hai-line);
191
+ border-radius: 8px;
192
+ background: var(--hai-bg);
193
+ font: 11.5px var(--hai-mono);
194
+ }
195
+ .hai-stat-dim { color: var(--hai-dim); }
196
+ .hai-stat-good { color: var(--hai-good); }
197
+ .hai-msg { margin-bottom: 10px; border-left: 2px solid var(--hai-line); padding-left: 9px; }
198
+ .hai-role { font: 10px var(--hai-mono); text-transform: uppercase; color: var(--hai-dim); letter-spacing: .07em; }
199
+ .hai-mbody { font: 11.5px/1.5 var(--hai-mono); white-space: pre-wrap; word-break: break-word; }
200
+ .hai-tu { color: var(--hai-accent); }
201
+ .hai-tr { color: var(--hai-good); }
202
+
203
+ @media (max-width: 900px) {
204
+ .hai-main.has-inspector { grid-template-columns: minmax(0, 1fr); }
205
+ .hai-inspector { display: none; }
206
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Types for the headless browser runtime.
3
+ *
4
+ * Hand-written rather than generated, and deliberately self-contained: this
5
+ * package has no runtime dependencies and gains none by being typed. The wire
6
+ * shapes below mirror `@haikit/core`'s `Block` / `WireEvent`, which is the one
7
+ * thing crossing the server/browser line — on this side it crosses as types
8
+ * only, so duplicating them here costs nothing at runtime.
9
+ */
10
+
11
+ export type ConversationStatus = "idle" | "streaming" | "awaiting";
12
+ export type SurfaceMode = "display" | "elicit";
13
+ export type SurfaceState = "live" | "frozen";
14
+
15
+ /** Blocks the server emits, plus the two the client synthesises locally. */
16
+ export type Block =
17
+ | { kind: "user"; id: string; text: string }
18
+ | { kind: "assistant"; id: string; text: string }
19
+ | { kind: "tool"; id: string; name: string; input: unknown; status: string; ms?: number; result?: string }
20
+ | { kind: "interaction"; id: string; handle: string; label: string }
21
+ /** synthesised from `ui_open` — the slot a surface mounts into */
22
+ | { kind: "ui"; id: string; handle: string; toolId: string }
23
+ /** synthesised from `error` */
24
+ | { kind: "error"; id: string; message: string };
25
+
26
+ /** The SSE frame shape. Narrow on `type` to get the specific payload. */
27
+ export type WireEvent = { type: string } & Record<string, any>;
28
+
29
+ /**
30
+ * What `mount()` hands your component. `send` is the ONLY channel back to the
31
+ * server: it carries an action name declared in the surface contract — never a
32
+ * tool name, never a handler.
33
+ */
34
+ export interface MountCtx {
35
+ handle: string;
36
+ mode: SurfaceMode;
37
+ state: SurfaceState;
38
+ send(action: string, value: unknown): Promise<void>;
39
+ }
40
+
41
+ /**
42
+ * Returned by `mount`. `freeze` is called when the server marks the surface
43
+ * frozen — a resolved elicit turn cannot be replayed, and the component should
44
+ * reflect that.
45
+ */
46
+ export interface SurfaceInstance {
47
+ freeze?(selection?: unknown): void;
48
+ }
49
+
50
+ /** Your component. `props` is whatever the surface's `props` schema produces. */
51
+ export interface ComponentDef<P = any> {
52
+ mount(element: HTMLElement, props: P, ctx: MountCtx): SurfaceInstance | null | void;
53
+ }
54
+
55
+ /**
56
+ * The allowlist. A component the registry does not name cannot be mounted —
57
+ * an unknown name renders an error card, never improvised UI.
58
+ */
59
+ export type Registry = Record<string, ComponentDef<any>>;
60
+
61
+ export interface SurfaceRecord {
62
+ handle: string;
63
+ component: string;
64
+ version: number;
65
+ mode: SurfaceMode;
66
+ state: SurfaceState;
67
+ props: unknown | null;
68
+ instance: SurfaceInstance | null;
69
+ }
70
+
71
+ export interface ChatState {
72
+ conversationId: string | null;
73
+ model: string;
74
+ status: ConversationStatus;
75
+ blocks: Block[];
76
+ /** handle → surface record */
77
+ surfaces: Map<string, SurfaceRecord>;
78
+ context: { messages: any[]; modelTokens: number; uiTokens: number };
79
+ }
80
+
81
+ export interface Chat {
82
+ /** Mutable — read it in a subscriber, do not hold references across events. */
83
+ state: ChatState;
84
+ send(text: string): Promise<void>;
85
+ interact(handle: string, action: string, value: unknown): Promise<void>;
86
+ /** Mounts the surface for `handle` into `element`. Null if props have not arrived. */
87
+ mount(handle: string, element: HTMLElement): SurfaceInstance | null;
88
+ /** Returns an unsubscribe function. */
89
+ subscribe(fn: (state: ChatState, event: WireEvent) => void): () => void;
90
+ }
91
+
92
+ export function createChat(options: { endpoint?: string; registry: Registry }): Chat;
package/src/index.js ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * hai-client — the browser runtime.
3
+ *
4
+ * Headless: it owns the transport, the block store, and surface mounting.
5
+ * It does NOT own how your transcript looks — see transcript.js for a default
6
+ * renderer you are expected to replace.
7
+ *
8
+ * Note this package has no dependency on hai-server. The only thing crossing
9
+ * that line is your surface contract, and on this side it crosses as types.
10
+ */
11
+
12
+ /**
13
+ * @param {{endpoint?: string, registry: Record<string, {mount: Function}>}} options
14
+ */
15
+ export function createChat({ endpoint = "/hai", registry }) {
16
+ const listeners = new Set();
17
+ const state = {
18
+ conversationId: null,
19
+ model: "",
20
+ status: "idle",
21
+ blocks: [],
22
+ /** handle -> { component, version, mode, props, state, instance } */
23
+ surfaces: new Map(),
24
+ context: { messages: [], modelTokens: 0, uiTokens: 0 },
25
+ };
26
+
27
+ const notify = (event) => listeners.forEach((fn) => fn(state, event));
28
+
29
+ // ── transport: SSE over POST (EventSource cannot POST) ──────────────
30
+ async function stream(path, body) {
31
+ const res = await fetch(endpoint + path, {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json" },
34
+ body: JSON.stringify({ conversationId: state.conversationId, ...body }),
35
+ });
36
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
37
+ let buffer = "";
38
+ for (;;) {
39
+ const { value, done } = await reader.read();
40
+ if (done) break;
41
+ buffer += value;
42
+ let i;
43
+ while ((i = buffer.indexOf("\n\n")) >= 0) {
44
+ const frame = buffer.slice(0, i);
45
+ buffer = buffer.slice(i + 2);
46
+ const line = frame.split("\n").find((l) => l.startsWith("data: "));
47
+ if (line) apply(JSON.parse(line.slice(6)));
48
+ }
49
+ }
50
+ }
51
+
52
+ function apply(event) {
53
+ switch (event.type) {
54
+ case "hello":
55
+ state.conversationId = event.conversationId;
56
+ state.model = event.model;
57
+ break;
58
+
59
+ case "block_start":
60
+ state.blocks.push({ ...event.block });
61
+ break;
62
+
63
+ case "text_delta": {
64
+ const block = state.blocks.find((b) => b.id === event.id);
65
+ if (block) block.text += event.text;
66
+ break;
67
+ }
68
+
69
+ case "block_update": {
70
+ const block = state.blocks.find((b) => b.id === event.id);
71
+ if (block) Object.assign(block, event);
72
+ break;
73
+ }
74
+
75
+ case "ui_open":
76
+ state.surfaces.set(event.handle, {
77
+ handle: event.handle,
78
+ component: event.component,
79
+ version: event.version,
80
+ mode: event.mode,
81
+ state: "live",
82
+ props: null,
83
+ instance: null,
84
+ });
85
+ state.blocks.push({ kind: "ui", id: `ui:${event.handle}`, handle: event.handle, toolId: event.toolId });
86
+ break;
87
+
88
+ case "ui_props": {
89
+ const surface = state.surfaces.get(event.handle);
90
+ if (surface) surface.props = event.props;
91
+ break;
92
+ }
93
+
94
+ case "ui_state": {
95
+ const surface = state.surfaces.get(event.handle);
96
+ if (surface) {
97
+ surface.state = event.state;
98
+ surface.instance?.freeze?.(event.selection);
99
+ }
100
+ break;
101
+ }
102
+
103
+ case "status":
104
+ state.status = event.status;
105
+ break;
106
+
107
+ case "context":
108
+ state.context = event;
109
+ break;
110
+
111
+ case "error":
112
+ state.blocks.push({ kind: "error", id: `e${state.blocks.length}`, message: event.message });
113
+ state.status = "idle";
114
+ break;
115
+ }
116
+ notify(event);
117
+ }
118
+
119
+ /**
120
+ * Mount a surface into an element. Strict allowlist: an unregistered
121
+ * component renders an error card, never an improvised UI.
122
+ */
123
+ function mount(handle, element) {
124
+ const surface = state.surfaces.get(handle);
125
+ if (!surface || !surface.props) return null;
126
+
127
+ const definition = registry[surface.component];
128
+ if (!definition) {
129
+ element.textContent = `unknown component: ${surface.component}`;
130
+ element.className = "hai-surface-error";
131
+ return null;
132
+ }
133
+
134
+ element.replaceChildren();
135
+ surface.instance = definition.mount(element, surface.props, {
136
+ handle,
137
+ mode: surface.mode,
138
+ state: surface.state,
139
+ // The ONLY channel a component has to the server. It passes an action
140
+ // name declared in the contract — never a tool, never a handler.
141
+ send: (action, value) => interact(handle, action, value),
142
+ });
143
+ return surface.instance;
144
+ }
145
+
146
+ async function send(text) {
147
+ if (!text.trim() || state.status === "streaming") return;
148
+ await stream("/chat", { message: text });
149
+ }
150
+
151
+ async function interact(handle, action, value) {
152
+ if (state.status === "streaming") return;
153
+ await stream("/interact", { handle, action, value });
154
+ }
155
+
156
+ return {
157
+ state,
158
+ send,
159
+ interact,
160
+ mount,
161
+ subscribe(fn) {
162
+ listeners.add(fn);
163
+ return () => listeners.delete(fn);
164
+ },
165
+ };
166
+ }
@@ -0,0 +1,19 @@
1
+ import type { Chat } from "./index.js";
2
+
3
+ /**
4
+ * Element helper. Generic over the tag so `h("textarea", …).rows` and
5
+ * `h("button", …).disabled` typecheck — a plain `HTMLElement` return would
6
+ * force a cast at every call site.
7
+ */
8
+ export function h<K extends keyof HTMLElementTagNameMap>(
9
+ tag: K,
10
+ className?: string | null,
11
+ text?: string | null,
12
+ ): HTMLElementTagNameMap[K];
13
+
14
+ /**
15
+ * The default transcript renderer. Replaces `root`'s children on every call and
16
+ * preserves scroll-to-bottom. Deliberately plain — this is the part you are
17
+ * expected to replace.
18
+ */
19
+ export function renderTranscript(root: HTMLElement, chat: Chat): void;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A default transcript renderer. Deliberately plain — data-dense agentic UIs
3
+ * are bespoke, and this is the part you are expected to replace. It exists so
4
+ * an app can be running in ten lines.
5
+ *
6
+ * Everything uses textContent. Tool payloads are untrusted input.
7
+ */
8
+
9
+ export const h = (tag, className, text) => {
10
+ const node = document.createElement(tag);
11
+ if (className) node.className = className;
12
+ if (text != null) node.textContent = text;
13
+ return node;
14
+ };
15
+
16
+ export function renderTranscript(root, chat) {
17
+ const { state } = chat;
18
+ const atBottom = root.scrollHeight - root.scrollTop - root.clientHeight < 160;
19
+
20
+ root.replaceChildren();
21
+ for (const block of state.blocks) root.append(renderBlock(block, chat));
22
+
23
+ if (atBottom) root.scrollTop = root.scrollHeight;
24
+ }
25
+
26
+ function renderBlock(block, chat) {
27
+ switch (block.kind) {
28
+ case "user": {
29
+ const el = h("div", "hai-block hai-user");
30
+ el.append(h("div", "hai-body", block.text));
31
+ return el;
32
+ }
33
+
34
+ case "assistant": {
35
+ const el = h("div", "hai-block hai-assistant");
36
+ el.append(h("div", "hai-body", block.text ?? ""));
37
+ return el;
38
+ }
39
+
40
+ case "interaction": {
41
+ const el = h("div", "hai-block hai-interaction");
42
+ el.append(h("span", "hai-arrow", "↳"), h("span", null, block.label));
43
+ return el;
44
+ }
45
+
46
+ case "error":
47
+ return h("div", "hai-block hai-error", `error: ${block.message}`);
48
+
49
+ // Provenance chrome. The tool row and its surface are one visual unit:
50
+ // what produced this, with what arguments, and what the model got back.
51
+ case "tool": {
52
+ const el = h("details", `hai-block hai-tool hai-status-${block.status}`);
53
+ const summary = h("summary");
54
+ summary.append(
55
+ h("span", "hai-gear", "⚙"),
56
+ h("span", "hai-tname", block.name),
57
+ h("span", "hai-targs", JSON.stringify(block.input)),
58
+ h(
59
+ "span",
60
+ "hai-tstatus",
61
+ block.status === "running" ? "…" : block.status === "awaiting" ? "awaiting user" : `${block.ms}ms`,
62
+ ),
63
+ );
64
+ el.append(summary);
65
+ const detail = h("div", "hai-tool-detail");
66
+ detail.append(h("div", "hai-label", "→ tool_result — all the model receives"));
67
+ detail.append(h("pre", "hai-digest", block.result ?? "…"));
68
+ el.append(detail);
69
+ return el;
70
+ }
71
+
72
+ case "ui": {
73
+ const el = h("div", "hai-surface");
74
+ el.dataset.handle = block.handle;
75
+ const surface = chat.state.surfaces.get(block.handle);
76
+ if (surface?.props) {
77
+ queueMicrotask(() => chat.mount(block.handle, el));
78
+ } else {
79
+ el.append(h("div", "hai-skeleton", `${surface?.component ?? "surface"} · loading…`));
80
+ }
81
+ return el;
82
+ }
83
+
84
+ default:
85
+ return h("div", "hai-block", JSON.stringify(block));
86
+ }
87
+ }