@haikit/client 0.8.0 → 0.9.1

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