@haikit/client 0.7.0 → 0.9.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/README.md CHANGED
@@ -13,11 +13,60 @@ API — it is built from `createChat` + `renderTranscript`, and dropping to thos
13
13
  when the default shell stops fitting is the expected path.
14
14
 
15
15
  `hai.css` styles **only what the framework renders** — transcript blocks, tool
16
- rows, the surface container, shell, composer, inspector. It deliberately does
16
+ rows, the surface container, shell, composer, debug drawer. It deliberately does
17
17
  not style the inside of your surfaces; your components emit their own class
18
18
  names and your stylesheet owns them. Every colour is a `--hai-*` custom
19
19
  property, so override what you like.
20
20
 
21
+ ## The default UI
22
+
23
+ `mountChat` gives you a header with **New chat** and a **Context** button that
24
+ shows how many tokens the model holds, a centred conversation, and a composer
25
+ that grows as you type.
26
+
27
+ **Context** opens the *model context debug drawer*: every message, tool call
28
+ and digest the model receives, and how much of the payload stayed out of
29
+ context. It starts closed and slides in from the right. On a wide screen the
30
+ chat makes room for it, and on a narrow one it opens over the chat. Escape or
31
+ its close button shuts it. It opens again after a reload if it was open
32
+ before. `inspector: false` leaves it out.
33
+
34
+ Each tool row says where its call stands: running, `awaiting you` while the turn
35
+ is parked on its surface, `resolved` once that surface has been answered or
36
+ typed over, or how long it took. Expand it to see the input and the
37
+ `tool_result`, which is everything the model received.
38
+
39
+ It is light or dark with the system. To pin it, put `data-hai-theme="dark"` (or
40
+ `"light"`) on the `<html>` element, or pass `theme: "dark"` to `mountChat`.
41
+
42
+ ### Tokens
43
+
44
+ Every token is set at zero specificity, so a plain `:root` rule in your own
45
+ sheet wins. Give a colour one value per scheme with `light-dark()`:
46
+
47
+ ```css
48
+ :root { --hai-accent: #b07cff; }
49
+ @supports (color: light-dark(#000, #fff)) {
50
+ :root { --hai-accent: light-dark(#6d28d9, #b07cff); }
51
+ }
52
+ ```
53
+
54
+ Keep the plain value first. A browser without `light-dark()` gets hai.css's
55
+ dark scheme, and an override it can't read would leave that token with no
56
+ colour at all.
57
+
58
+ | | |
59
+ |---|---|
60
+ | `--hai-bg` `--hai-panel` `--hai-panel-2` | page, cards, secondary panels |
61
+ | `--hai-fg` `--hai-dim` | text, secondary text |
62
+ | `--hai-line` `--hai-line-soft` | borders, row dividers |
63
+ | `--hai-accent` `--hai-on-accent` | the accent, and text drawn on it |
64
+ | `--hai-good` `--hai-warn` `--hai-bad` | status colours; each, and the accent, has a `-soft` tint for backgrounds |
65
+ | `--hai-hover` `--hai-selected` | row hover and picked-row backgrounds, for your surfaces |
66
+ | `--hai-user-bg` `--hai-code-bg` `--hai-shadow` | user bubble, digest blocks, card shadow |
67
+ | `--hai-radius` `--hai-sans` `--hai-mono` | shape and type |
68
+ | `--hai-column` `--hai-inspector-width` | conversation width, debug drawer width |
69
+
21
70
  ## Surface lifecycle
22
71
 
23
72
  The default transcript keeps each block's element for as long as what it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haikit/client",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "The haikit browser runtime: streams tool payloads into interactive UI surfaces. Plain ESM, no build step, no dependencies.",
5
5
  "keywords": [
6
6
  "haikit",
package/src/app.d.ts CHANGED
@@ -9,10 +9,20 @@ export interface MountChatOptions {
9
9
  subtitle?: string;
10
10
  /** Clickable starter prompts shown on the empty transcript. */
11
11
  suggestions?: string[];
12
- /** Show the context inspector. Default true — keep it on while developing. */
12
+ /**
13
+ * Include the model context debug drawer, opened from the header. Default
14
+ * true — keep it while developing. It starts closed, and opens again after a
15
+ * reload if it was open before.
16
+ */
13
17
  inspector?: boolean;
14
18
  placeholder?: string;
15
19
  emptyText?: string;
20
+ /**
21
+ * Pin the page to a colour scheme, by setting `data-hai-theme` on the root
22
+ * element until the chat closes. Unset, it follows the system's light or
23
+ * dark setting, or a `data-hai-theme` already in the page's markup.
24
+ */
25
+ theme?: "light" | "dark";
16
26
  }
17
27
 
18
28
  /**
@@ -30,7 +40,7 @@ export interface MountChatOptions {
30
40
  */
31
41
  export function mountChat(options: MountChatOptions): Chat;
32
42
 
33
- /** The context inspector: makes the dual channel visible. */
43
+ /** What the model context debug drawer shows: makes the dual channel visible. */
34
44
  export function renderInspector(
35
45
  el: HTMLElement,
36
46
  context?: { messages?: any[]; modelTokens?: number; uiTokens?: number },
package/src/app.js CHANGED
@@ -2,7 +2,8 @@
2
2
  * hai-client — the default UI.
3
3
  *
4
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.
5
+ * (optionally) the model context debug drawer. It is the ten-line path to a
6
+ * running app.
6
7
  *
7
8
  * import { mountChat } from "/hai-client/app.js";
8
9
  * import { registry } from "./components.js";
@@ -16,6 +17,48 @@
16
17
 
17
18
  import { createChat } from "./index.js";
18
19
  import { renderTranscript, closeTranscript, h } from "./transcript.js";
20
+ import { icon } from "./icons.js";
21
+
22
+ /** Below this width the drawer opens over the chat rather than beside it. */
23
+ const WIDE = "(min-width: 900px)";
24
+
25
+ /** The drawer's name, as its heading shows it and assistive technology says it. */
26
+ const DRAWER = "Model context debug drawer";
27
+
28
+ /** Where the drawer remembers being open, so a reload while developing keeps it. */
29
+ const REMEMBER = "hai:debug-drawer";
30
+
31
+ /** Numbers the inspector's ids, so two shells on a page never share one. */
32
+ let shells = 0;
33
+
34
+ /**
35
+ * The page's scheme pin is one attribute shared by every chat on it. It
36
+ * follows the newest themed chat still open; when the last one closes, the
37
+ * page gets back the pin it had before the first. A pin the page set itself
38
+ * in the meantime is the page's, and closing a chat leaves it alone.
39
+ */
40
+ const pinning = { chats: [], pageOwn: undefined, ours: undefined };
41
+
42
+ function pin(chat) {
43
+ if (!pinning.chats.length) pinning.pageOwn = document.documentElement.dataset.haiTheme;
44
+ pinning.chats.push(chat);
45
+ setPin(chat.theme);
46
+ }
47
+
48
+ function unpin(chat) {
49
+ const i = pinning.chats.indexOf(chat);
50
+ if (i < 0) return;
51
+ pinning.chats.splice(i, 1);
52
+ if (document.documentElement.dataset.haiTheme !== pinning.ours) return;
53
+ setPin(pinning.chats.at(-1)?.theme ?? pinning.pageOwn);
54
+ }
55
+
56
+ function setPin(theme) {
57
+ const page = document.documentElement;
58
+ if (theme === undefined) delete page.dataset.haiTheme;
59
+ else page.dataset.haiTheme = theme;
60
+ pinning.ours = theme;
61
+ }
19
62
 
20
63
  /**
21
64
  * @param {{
@@ -28,6 +71,7 @@ import { renderTranscript, closeTranscript, h } from "./transcript.js";
28
71
  * inspector?: boolean,
29
72
  * placeholder?: string,
30
73
  * emptyText?: string,
74
+ * theme?: "light" | "dark",
31
75
  * }} options
32
76
  */
33
77
  export function mountChat({
@@ -40,25 +84,49 @@ export function mountChat({
40
84
  inspector = true,
41
85
  placeholder = "message…",
42
86
  emptyText = "",
87
+ theme,
43
88
  }) {
44
89
  const chat = createChat({ endpoint, registry });
90
+ const id = `hai-${++shells}`;
91
+
92
+ // Unset, the page follows the system. A pin goes on the root, where hai.css
93
+ // sets the tokens: a minifier that lowers light-dark() resolves them there,
94
+ // so a pin on the shell alone would not reach them.
95
+ const pinned = theme === "light" || theme === "dark" ? { theme } : null;
96
+ if (pinned) pin(pinned);
45
97
 
46
98
  // ── shell ──────────────────────────────────────────────────────────
47
99
  const shell = h("div", "hai-shell");
48
100
 
49
101
  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);
102
+ const brand = h("div", "hai-brand");
103
+ brand.append(icon("brand", "hai-icon hai-mark"), h("span", "hai-title", title));
104
+ if (subtitle) brand.append(h("span", "hai-subtitle", subtitle));
105
+
106
+ const modelEl = h("span", "hai-model");
107
+ modelEl.hidden = true;
108
+ const newBtn = button("hai-btn", "plus", "New chat");
109
+ newBtn.onclick = () => chat.reset();
110
+ const actions = h("div", "hai-actions");
111
+ actions.append(modelEl, newBtn);
112
+ top.append(brand, actions);
52
113
 
53
114
  const transcript = h("div", "hai-transcript");
54
115
  const statusEl = h("div", "hai-status");
116
+ statusEl.setAttribute("role", "status");
55
117
  const input = h("textarea", "hai-input");
56
118
  input.rows = 1;
57
119
  input.placeholder = placeholder;
58
- const sendBtn = h("button", "hai-send", "Send");
120
+ input.setAttribute("aria-label", "Message");
121
+ const sendBtn = h("button", "hai-send");
122
+ sendBtn.type = "button";
123
+ sendBtn.setAttribute("aria-label", "Send");
124
+ sendBtn.append(icon("send"));
59
125
 
126
+ const box = h("div", "hai-composer-box");
127
+ box.append(input, sendBtn);
60
128
  const composer = h("div", "hai-composer");
61
- composer.append(input, sendBtn);
129
+ composer.append(box);
62
130
 
63
131
  const chatPane = h("section", "hai-chat");
64
132
  chatPane.append(transcript, statusEl, composer);
@@ -67,19 +135,103 @@ export function mountChat({
67
135
  main.append(chatPane);
68
136
 
69
137
  let inspectorBody = null;
138
+ let ctxCount = null;
139
+ let releaseDrawer = () => {};
70
140
  if (inspector) {
141
+ // The model context debug drawer: closed until asked for, it slides in
142
+ // from the right, beside the chat on a wide screen and over it on a
143
+ // narrow one. The header button that opens it says how much the model
144
+ // holds, open or not.
71
145
  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"));
146
+ aside.id = `${id}-inspector`;
147
+ aside.setAttribute("aria-label", DRAWER);
148
+ const head = h("div", "hai-inspector-head");
149
+ const heading = h("div", "hai-inspector-title", DRAWER);
150
+ heading.append(h("span", null, "everything the model sees"));
151
+ const hide = h("button", "hai-icon-btn");
152
+ hide.type = "button";
153
+ hide.setAttribute("aria-label", `Close ${DRAWER.toLowerCase()}`);
154
+ hide.append(icon("close"));
155
+ head.append(heading, hide);
74
156
  inspectorBody = h("div", "hai-inspector-body");
75
- aside.append(heading, inspectorBody);
157
+ aside.append(head, inspectorBody);
76
158
  main.append(aside);
159
+
160
+ const toggle = button("hai-btn hai-ctx-toggle", "panel", "Context");
161
+ ctxCount = h("span", "hai-ctx-count");
162
+ toggle.append(ctxCount);
163
+ toggle.title = DRAWER;
164
+ toggle.setAttribute("aria-controls", aside.id);
165
+ actions.append(toggle);
166
+
167
+ const wideQuery = typeof matchMedia === "function" ? matchMedia(WIDE) : null;
168
+ const wide = () => !wideQuery || wideQuery.matches;
169
+ const isOpen = () => main.classList.contains("inspector-open");
170
+ // Over the chat, on a narrow screen, the drawer is all there is to reach:
171
+ // the chat behind it is inert until it closes or the screen widens.
172
+ const sync = () => {
173
+ chatPane.inert = isOpen() && !wide();
174
+ };
175
+ const set = (open) => {
176
+ main.classList.toggle("inspector-open", open);
177
+ toggle.setAttribute("aria-expanded", String(open));
178
+ // out of reach as soon as it starts to close, not once it has slid away
179
+ aside.inert = !open;
180
+ sync();
181
+ };
182
+ const show = (open) => {
183
+ set(open);
184
+ try {
185
+ localStorage.setItem(REMEMBER, open ? "open" : "closed");
186
+ } catch {}
187
+ };
188
+ toggle.onclick = () => {
189
+ const open = !isOpen();
190
+ show(open);
191
+ if (open) hide.focus();
192
+ };
193
+ hide.onclick = () => {
194
+ show(false);
195
+ toggle.focus();
196
+ };
197
+ // a tap on the chat it covers closes it, as a tap outside a dialog does,
198
+ // and focus goes back to Context, as it does from the close button
199
+ main.addEventListener("click", (e) => {
200
+ if (e.target === main && chatPane.inert) hide.onclick();
201
+ });
202
+ // Escape closes it from anywhere in the shell, unless something there
203
+ // used the key first, such as a surface closing a menu of its own. Focus
204
+ // goes back to Context from inside the drawer; a reader typing in the
205
+ // composer keeps their place.
206
+ shell.addEventListener("keydown", (e) => {
207
+ if (e.key !== "Escape" || e.defaultPrevented || !isOpen()) return;
208
+ const inside = aside.contains(document.activeElement);
209
+ show(false);
210
+ if (inside) toggle.focus();
211
+ });
212
+ wideQuery?.addEventListener("change", sync);
213
+ releaseDrawer = () => wideQuery?.removeEventListener("change", sync);
214
+
215
+ // Open again after a reload if it was open before, but only beside the
216
+ // chat: over it, on a narrow screen, it would hide the page it debugs.
217
+ let remembered = null;
218
+ try {
219
+ remembered = localStorage.getItem(REMEMBER);
220
+ } catch {}
221
+ set(remembered === "open" && wide());
77
222
  }
78
223
 
79
224
  shell.append(top, main);
80
225
  root.replaceChildren(shell);
81
226
 
82
- // ── empty state ────────────────────────────────────────────────────
227
+ // ── composer ───────────────────────────────────────────────────────
228
+ // The input grows with what is typed, up to the height hai.css allows.
229
+ const fit = () => {
230
+ input.style.height = "auto";
231
+ input.style.height = `${input.scrollHeight}px`;
232
+ };
233
+ input.addEventListener("input", fit);
234
+
83
235
  const send = () => {
84
236
  const text = input.value.trim();
85
237
  if (!text) return;
@@ -88,21 +240,29 @@ export function mountChat({
88
240
  // this already knows: keep the text for the new conversation instead
89
241
  if (chat.state.expired) return;
90
242
  input.value = "";
243
+ fit();
91
244
  // A message queued behind a long turn can still be turned away later, if
92
245
  // the conversation goes out of date before its turn comes. Give the text
93
246
  // back then, unless something new has been typed since.
94
247
  sending.then((sent) => {
95
- if (!sent && !input.value) input.value = text;
248
+ if (!sent && !input.value) {
249
+ input.value = text;
250
+ fit();
251
+ }
96
252
  });
97
253
  };
98
254
 
255
+ // ── empty state ────────────────────────────────────────────────────
99
256
  function emptyState() {
100
257
  const empty = h("div", "hai-empty");
258
+ if (!emptyText && !suggestions.length) return empty;
259
+ empty.append(icon("brand", "hai-icon hai-empty-mark"));
101
260
  if (emptyText) empty.append(h("p", null, emptyText));
102
261
  if (suggestions.length) {
103
262
  const row = h("div", "hai-suggestions");
104
263
  for (const s of suggestions) {
105
264
  const b = h("button", "hai-suggestion", s);
265
+ b.type = "button";
106
266
  b.onclick = () => { input.value = s; send(); };
107
267
  row.append(b);
108
268
  }
@@ -116,13 +276,31 @@ export function mountChat({
116
276
  // The suggestions follow it until the user engages — a first message, or an
117
277
  // answer to something init showed. What the conversation opened with (an
118
278
  // init tool's row, a welcome surface) doesn't replace them.
279
+ const engaged = () => chat.state.blocks.some((b) => b.kind === "user" || b.kind === "interaction");
119
280
  const render = () => {
120
281
  renderTranscript(transcript, chat);
121
- if (!chat.state.blocks.some((b) => b.kind === "user" || b.kind === "interaction")) {
122
- transcript.append(emptyState());
123
- }
282
+ if (!engaged()) transcript.append(emptyState());
124
283
  };
125
284
 
285
+ // ── status ─────────────────────────────────────────────────────────
286
+ // A live region: it changes only when the status does, so a screen reader
287
+ // hears "Thinking" once a turn, not once per streamed word.
288
+ let shownStatus = null;
289
+ function showStatus(status) {
290
+ if (status === shownStatus) return;
291
+ shownStatus = status;
292
+ statusEl.dataset.status = status;
293
+ statusEl.replaceChildren();
294
+ if (status === "streaming") {
295
+ const dots = h("span", "hai-dots");
296
+ dots.setAttribute("aria-hidden", "true");
297
+ dots.append(h("i"), h("i"), h("i"));
298
+ statusEl.append(dots, "Thinking");
299
+ } else if (status === "awaiting") {
300
+ statusEl.append(h("span", "hai-pulse"), "Waiting on your selection above");
301
+ }
302
+ }
303
+
126
304
  // ── wiring ─────────────────────────────────────────────────────────
127
305
  chat.subscribe((state, event) => {
128
306
  // chat.close(): the surfaces are unmounted and the chat lets go of this
@@ -130,6 +308,8 @@ export function mountChat({
130
308
  if (event.type === "closed") {
131
309
  closeTranscript(transcript);
132
310
  shell.remove();
311
+ releaseDrawer();
312
+ if (pinned) unpin(pinned);
133
313
  return;
134
314
  }
135
315
 
@@ -140,52 +320,88 @@ export function mountChat({
140
320
  render();
141
321
 
142
322
  modelEl.textContent = state.model;
323
+ modelEl.hidden = !state.model;
324
+ // There is something to start over from once the user has engaged, or
325
+ // once the server has a conversation: init may already be asking.
326
+ newBtn.disabled = !engaged() && !state.expired && !state.conversationId;
143
327
  input.disabled = state.expired !== null;
144
328
  sendBtn.disabled = state.status === "streaming" || state.expired !== null;
145
- statusEl.dataset.status = state.status;
146
- statusEl.textContent =
147
- state.status === "streaming" ? "thinking…"
148
- : state.status === "awaiting" ? "awaiting your selection in the component above"
149
- : "";
329
+ showStatus(state.expired ? "idle" : state.status);
150
330
  input.placeholder =
151
331
  state.expired ? "start a new conversation to continue"
152
332
  : state.status === "awaiting" ? "pick an option above — or type to override"
153
333
  : placeholder;
154
334
 
155
- if (inspectorBody) renderInspector(inspectorBody, state.context);
335
+ if (inspectorBody) {
336
+ renderInspector(inspectorBody, state.context);
337
+ const tokens = state.context.modelTokens;
338
+ ctxCount.textContent = tokens ? `~${tokens.toLocaleString()} tok` : "";
339
+ }
156
340
  });
157
341
 
158
342
  sendBtn.onclick = send;
159
343
  input.onkeydown = (e) => {
160
- if (e.key === "Enter" && !e.shiftKey) {
344
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
161
345
  e.preventDefault();
162
346
  send();
163
347
  }
164
348
  };
165
349
 
350
+ newBtn.disabled = true;
166
351
  render();
352
+ // a server without an init tool sends nothing until the first message
353
+ if (inspectorBody) renderInspector(inspectorBody, chat.state.context);
167
354
  chat.start();
168
355
  return chat;
169
356
  }
170
357
 
358
+ /** A header button: an icon, and a label that narrow screens keep for screen
359
+ * readers only. */
360
+ function button(className, iconName, label) {
361
+ const b = h("button", className);
362
+ b.type = "button";
363
+ b.append(icon(iconName), h("span", "hai-btn-label", label));
364
+ return b;
365
+ }
366
+
171
367
  /**
172
- * The context inspector. Makes the dual channel visible, which is the whole
173
- * argument — keep it on while developing.
368
+ * What the model context debug drawer shows. Makes the dual channel visible,
369
+ * which is the whole argument — keep it on while developing.
174
370
  */
175
371
  export function renderInspector(el, { messages = [], modelTokens = 0, uiTokens = 0 } = {}) {
372
+ // following the newest context, as the transcript does, unless scrolled up
373
+ const following = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
176
374
  el.replaceChildren();
177
375
 
178
- const pct = uiTokens ? Math.round((1 - modelTokens / (modelTokens + uiTokens)) * 100) : 0;
179
- const bar = h("div", "hai-ctx-bar");
180
- bar.append(
181
- h("div", null, `model context ~${modelTokens} tok`),
182
- h("div", "hai-stat-dim", `browser payload ~${uiTokens} tok`),
183
- h("div", "hai-stat-good", uiTokens ? `${pct}% kept out of context` : ""),
376
+ if (!messages.length && !modelTokens && !uiTokens) {
377
+ el.append(
378
+ h("p", "hai-inspector-empty", "Nothing yet. Every message, tool call and digest the model receives shows up here."),
379
+ );
380
+ return;
381
+ }
382
+
383
+ const total = modelTokens + uiTokens;
384
+ const pct = uiTokens ? Math.round((1 - modelTokens / total) * 100) : 0;
385
+ const stats = h("div", "hai-ctx");
386
+ if (uiTokens) {
387
+ const headline = h("div", "hai-ctx-headline");
388
+ headline.append(h("span", "hai-ctx-pct", `${pct}%`), h("span", null, "kept out of context"));
389
+ stats.append(headline);
390
+ }
391
+ const meter = h("div", "hai-meter");
392
+ meter.setAttribute("aria-hidden", "true");
393
+ const share = h("span", "hai-meter-model");
394
+ share.style.width = `${total ? (modelTokens / total) * 100 : 100}%`;
395
+ meter.append(share);
396
+ stats.append(
397
+ meter,
398
+ stat("hai-key-model", "model context", modelTokens),
399
+ stat("hai-key-ui", "browser payload", uiTokens),
184
400
  );
185
- el.append(bar);
401
+ el.append(stats);
186
402
 
187
403
  for (const m of messages) {
188
- const box = h("div", "hai-msg");
404
+ const box = h("div", `hai-msg hai-msg-${m.role}`);
189
405
  box.append(h("div", "hai-role", m.role));
190
406
  const body = h("div", "hai-mbody");
191
407
  if (typeof m.content === "string") {
@@ -193,12 +409,36 @@ export function renderInspector(el, { messages = [], modelTokens = 0, uiTokens =
193
409
  } else {
194
410
  for (const b of m.content ?? []) {
195
411
  if (b.type === "text" && b.text) body.append(h("div", null, b.text));
196
- if (b.type === "tool_use")
197
- body.append(h("div", "hai-tu", `tool_use ${b.name} ${JSON.stringify(b.input)}`));
198
- if (b.type === "tool_result") body.append(h("div", "hai-tr", b.content));
412
+ if (b.type === "tool_use") {
413
+ const call = h("div", "hai-tu");
414
+ call.append(h("span", "hai-kind", `tool_use · ${b.name}`), h("div", null, JSON.stringify(b.input)));
415
+ body.append(call);
416
+ }
417
+ if (b.type === "tool_result") {
418
+ const result = h("div", "hai-tr");
419
+ result.append(h("span", "hai-kind", "tool_result"), h("div", null, plain(b.content)));
420
+ body.append(result);
421
+ }
199
422
  }
200
423
  }
201
424
  box.append(body);
202
425
  el.append(box);
203
426
  }
427
+
428
+ if (following) el.scrollTop = el.scrollHeight;
429
+ }
430
+
431
+ function stat(key, label, tokens) {
432
+ const row = h("div", "hai-ctx-stat");
433
+ const name = h("span", "hai-ctx-label");
434
+ name.append(h("i", `hai-key ${key}`), label);
435
+ row.append(name, h("span", "hai-ctx-tok", `~${tokens.toLocaleString()} tok`));
436
+ return row;
437
+ }
438
+
439
+ /** A tool_result's content as text: a string, or an array of content blocks. */
440
+ function plain(content) {
441
+ if (typeof content === "string") return content;
442
+ if (Array.isArray(content)) return content.map((c) => c?.text ?? JSON.stringify(c)).join("\n");
443
+ return JSON.stringify(content);
204
444
  }