@oharness/server 0.7.0 → 0.8.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.
Files changed (61) hide show
  1. package/dist/browser-pane.d.ts +48 -0
  2. package/dist/browser-pane.js +185 -0
  3. package/dist/browser-pane.js.map +1 -0
  4. package/dist/i18n.js +144 -0
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/main.js +20 -0
  7. package/dist/main.js.map +1 -1
  8. package/dist/page/client/account.d.ts +8 -0
  9. package/dist/page/client/account.js +1895 -0
  10. package/dist/page/client/account.js.map +1 -0
  11. package/dist/page/client/artifacts.d.ts +7 -0
  12. package/dist/page/client/artifacts.js +767 -0
  13. package/dist/page/client/artifacts.js.map +1 -0
  14. package/dist/page/client/base.d.ts +8 -0
  15. package/dist/page/client/base.js +400 -0
  16. package/dist/page/client/base.js.map +1 -0
  17. package/dist/page/client/browser.d.ts +21 -0
  18. package/dist/page/client/browser.js +904 -0
  19. package/dist/page/client/browser.js.map +1 -0
  20. package/dist/page/client/composer.d.ts +8 -0
  21. package/dist/page/client/composer.js +1694 -0
  22. package/dist/page/client/composer.js.map +1 -0
  23. package/dist/page/client/dialogs.d.ts +7 -0
  24. package/dist/page/client/dialogs.js +724 -0
  25. package/dist/page/client/dialogs.js.map +1 -0
  26. package/dist/page/client/events.d.ts +7 -0
  27. package/dist/page/client/events.js +288 -0
  28. package/dist/page/client/events.js.map +1 -0
  29. package/dist/page/client/index.d.ts +16 -0
  30. package/dist/page/client/index.js +46 -0
  31. package/dist/page/client/index.js.map +1 -0
  32. package/dist/page/client/panels.d.ts +7 -0
  33. package/dist/page/client/panels.js +659 -0
  34. package/dist/page/client/panels.js.map +1 -0
  35. package/dist/page/client/pickers.d.ts +7 -0
  36. package/dist/page/client/pickers.js +624 -0
  37. package/dist/page/client/pickers.js.map +1 -0
  38. package/dist/page/client/routines.d.ts +6 -0
  39. package/dist/page/client/routines.js +910 -0
  40. package/dist/page/client/routines.js.map +1 -0
  41. package/dist/page/client/shell.d.ts +6 -0
  42. package/dist/page/client/shell.js +690 -0
  43. package/dist/page/client/shell.js.map +1 -0
  44. package/dist/page/client/state.d.ts +7 -0
  45. package/dist/page/client/state.js +1275 -0
  46. package/dist/page/client/state.js.map +1 -0
  47. package/dist/page/client/transcript.d.ts +8 -0
  48. package/dist/page/client/transcript.js +699 -0
  49. package/dist/page/client/transcript.js.map +1 -0
  50. package/dist/page/client/usage.d.ts +7 -0
  51. package/dist/page/client/usage.js +954 -0
  52. package/dist/page/client/usage.js.map +1 -0
  53. package/dist/page/style.d.ts +1 -0
  54. package/dist/page/style.js +2793 -0
  55. package/dist/page/style.js.map +1 -0
  56. package/dist/page.js +70 -13583
  57. package/dist/page.js.map +1 -1
  58. package/dist/server.d.ts +24 -0
  59. package/dist/server.js +511 -25
  60. package/dist/server.js.map +1 -1
  61. package/package.json +2 -2
@@ -0,0 +1,699 @@
1
+ /**
2
+ * The transcript itself, the tool cards it is made of, and the session tabs
3
+ * above it. Tool presentation is shared with the approval dialog, so a call
4
+ * reads the same whether it is waiting on an answer or already done.
5
+ *
6
+ * One part of the client script; see ./index.ts for how the parts join.
7
+ */
8
+ export const TRANSCRIPT = String.raw `
9
+ // --- transcript -----------------------------------------------------------
10
+
11
+ const emptyEl = document.getElementById("empty");
12
+
13
+ function hideEmpty() {
14
+ if (emptyEl && emptyEl.parentNode) emptyEl.remove();
15
+ // The workspace goes with it: the first message is what roots a transcript
16
+ // in a tree, and after that there is nothing left to choose.
17
+ paintWorkspace(false);
18
+ }
19
+
20
+ /**
21
+ * The invitation belongs to whichever transcript is empty.
22
+ *
23
+ * It is one node that moves into the pane it is for, so a tab switch has to
24
+ * carry it: dropping it on every focus left a session nobody had typed in yet
25
+ * showing a blank rectangle, which is easy to reach now that a session can be
26
+ * started in another project while this one is mid-thought.
27
+ *
28
+ * The workspace chip answers the same question — has anything been said here
29
+ * yet — so it is painted from the same place rather than from a second guess
30
+ * about what is on screen.
31
+ */
32
+ function paintEmpty(record) {
33
+ const bare = Boolean(
34
+ record && (record.node.childElementCount === 0 || record.node.contains(emptyEl)),
35
+ );
36
+ if (bare && emptyEl) record.node.appendChild(emptyEl);
37
+ else if (!bare) hideEmpty();
38
+ paintWorkspace(bare);
39
+ }
40
+
41
+ /** Keep the chip current without deciding again whether it belongs on screen. */
42
+ function refreshWorkspace() {
43
+ if (workspaceRow && !workspaceRow.hidden) paintWorkspace(true);
44
+ }
45
+
46
+ /** The chip, and what it currently says. */
47
+ function paintWorkspace(show) {
48
+ if (!workspaceRow) return;
49
+ workspaceRow.hidden = !show;
50
+ if (!show) return;
51
+ // The project on screen leads, and a directory that is no longer there is
52
+ // left out: it cannot host a session, and offering it only produces an
53
+ // error one click later.
54
+ const here = (project) => (project.path === currentCwd ? 0 : 1);
55
+ workspacePicker.set(
56
+ projects
57
+ .filter((project) => !project.missing)
58
+ .sort((a, b) => here(a) - here(b))
59
+ .map((project) => ({ value: project.path, label: project.name, sub: project.path })),
60
+ currentCwd,
61
+ );
62
+ }
63
+
64
+ /**
65
+ * The pane events are currently being rendered into.
66
+ *
67
+ * Set per event from its session id rather than read from the visible tab: a
68
+ * background tab keeps streaming, and rendering by what is on screen would
69
+ * pour its reply into whichever transcript the user happens to be reading.
70
+ */
71
+ let P = null;
72
+
73
+ function turn(role) {
74
+ hideEmpty();
75
+ const wrap = el("div", "turn column " + role);
76
+ wrap.appendChild(el("div", "who", role === "user" ? "you" : "assistant"));
77
+ P.node.appendChild(wrap);
78
+ follow();
79
+ return wrap;
80
+ }
81
+
82
+ function follow() {
83
+ if (atBottom) log.scrollTop = log.scrollHeight;
84
+ }
85
+
86
+ log.addEventListener("scroll", () => {
87
+ atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 60;
88
+ jumpBtn.hidden = atBottom;
89
+ });
90
+
91
+ jumpBtn.addEventListener("click", () => {
92
+ atBottom = true;
93
+ jumpBtn.hidden = true;
94
+ log.scrollTop = log.scrollHeight;
95
+ });
96
+
97
+ /**
98
+ * A message as it was sent, pictures included.
99
+ *
100
+ * The images are sources, not bytes: a replayed transcript points at
101
+ * /attachment, and a message just sent from this tab points at the object
102
+ * URL the composer already made for its chip. Either way the bubble shows
103
+ * what was attached, instead of a line of text claiming something was.
104
+ */
105
+ function addUser(text, images) {
106
+ const wrap = turn("user");
107
+ const bubble = el("div", "bubble", text);
108
+ if (images && images.length) {
109
+ const strip = el("div", "shots");
110
+ for (const image of images) {
111
+ const img = document.createElement("img");
112
+ img.src = image.src.startsWith("blob:") ? image.src : q(image.src);
113
+ img.alt = image.name || "";
114
+ img.loading = "lazy";
115
+ // A full-size screenshot in a chat bubble is unreadable; the tab it
116
+ // opens is the same bytes at whatever size the screen allows.
117
+ img.addEventListener("click", () => window.open(img.src, "_blank", "noopener"));
118
+ strip.appendChild(img);
119
+ }
120
+ bubble.appendChild(strip);
121
+ }
122
+ wrap.appendChild(bubble);
123
+ P.turn = null;
124
+ P.streaming = null;
125
+ }
126
+
127
+ function assistantTurn() {
128
+ if (!P.turn) P.turn = turn("assistant");
129
+ return P.turn;
130
+ }
131
+
132
+ /**
133
+ * The actions that belong to one reply.
134
+ *
135
+ * Forking is a point in a conversation, and the only place a point exists is a
136
+ * message — so the control lives at the end of the reply it would branch from,
137
+ * not in a sidebar where it can only ever mean "from the end".
138
+ */
139
+ /**
140
+ * Close the reasoning block once the answer starts.
141
+ *
142
+ * Left open it pushes the reply off the screen, and the reasoning is rarely
143
+ * what the reader came for — but the time it took is worth keeping visible.
144
+ *
145
+ * The handle is dropped along with the spinner: a reply that reasons, calls a
146
+ * tool and reasons again is two bursts, and folding the second one into a block
147
+ * already labelled "Thought for 3s" hides it behind a stale summary.
148
+ */
149
+ function sealThinking() {
150
+ const block = P.turn && P.turn.__thinking;
151
+ if (!block || block.__sealed) return;
152
+ block.__sealed = true;
153
+ block.open = false;
154
+ const seconds = Math.round((Date.now() - (P.turn.__thinkingStart || Date.now())) / 1000);
155
+ block.querySelector(".spinner").remove();
156
+ block.querySelector(".t-label").textContent =
157
+ seconds >= 1 ? t("work.thoughtFor", { n: seconds }) : t("work.thoughtAbout");
158
+ P.turn.__thinking = null;
159
+ P.turn.__thinkingStart = 0;
160
+ }
161
+
162
+ /** A turn's action: an icon, then what it does. */
163
+ function actButton(icon, label) {
164
+ const button = el("button", "act");
165
+ button.type = "button";
166
+ button.appendChild(svgIcon(icon));
167
+ button.appendChild(el("span", "act-label", label));
168
+ return button;
169
+ }
170
+
171
+ function turnActions(wrap, keepMessages) {
172
+ if (!keepMessages || wrap.querySelector(":scope > .actions")) return;
173
+ const bar = el("div", "actions");
174
+
175
+ const fork = actButton("fork", t("turn.fork"));
176
+ fork.addEventListener("click", () => {
177
+ setNav(null);
178
+ void switchTo({ action: "fork", keepMessages });
179
+ });
180
+ bar.appendChild(fork);
181
+
182
+ const copy = actButton("copy", t("turn.copy"));
183
+ const copyLabel = copy.querySelector(".act-label");
184
+ copy.addEventListener("click", () => {
185
+ const md = wrap.querySelector(".md");
186
+ navigator.clipboard?.writeText(md ? md.textContent : wrap.textContent);
187
+ // The label alone, so the confirmation does not take the icon with it and
188
+ // leave the button jumping a notch narrower for a second.
189
+ copyLabel.textContent = t("turn.copied");
190
+ setTimeout(() => (copyLabel.textContent = t("turn.copy")), 1200);
191
+ });
192
+ bar.appendChild(copy);
193
+
194
+ wrap.appendChild(bar);
195
+ }
196
+
197
+ /**
198
+ * The collapsible activity log for the current turn.
199
+ *
200
+ * Tool calls are how the work got done, not what the work is — so they fold
201
+ * away by default and the reply and the diff stay in view. A failure opens the
202
+ * group, because then the activity *is* the thing to read.
203
+ */
204
+ function workGroup() {
205
+ const parent = assistantTurn();
206
+ let group = parent.querySelector(":scope > details.work:last-of-type");
207
+ if (!group) {
208
+ group = el("details", "work");
209
+ const summary = el("summary");
210
+ summary.appendChild(el("span", "work-label", t("work.working")));
211
+ group.appendChild(summary);
212
+ group.appendChild(el("div", "work-body"));
213
+ parent.appendChild(group);
214
+ }
215
+ return group;
216
+ }
217
+
218
+ /**
219
+ * A run of tool calls, said in a line: what was done, not how many steps it
220
+ * took.
221
+ *
222
+ * "3 steps" was true and told nobody anything; the reason to look at a folded
223
+ * group is to know whether it is the part worth opening. So the line is built
224
+ * from the calls themselves. A call that says what it is for — a command's
225
+ * description, a subagent's task — is quoted; the rest are named by what they
226
+ * do, and neighbours of one kind are counted rather than listed ("Read 3
227
+ * files"). While a call is running its phrase is in the present ("Running a
228
+ * command"), and a failure leads, in red, because that is the one thing
229
+ * nobody should have to open the group to find. Two phrases are shown and the
230
+ * rest counted, which is how Claude Code's desktop app reads.
231
+ */
232
+ function bumpWorkLabel(group) {
233
+ const cards = [...group.querySelectorAll(":scope > .work-body > details.tool")];
234
+ const phrases = [];
235
+ let failed = false;
236
+ let failures = 0;
237
+ for (const card of cards) {
238
+ const info = card.toolInfo || {};
239
+ const state = card.classList.contains("failed") ? "failed" : card.classList.contains("done") ? "done" : "running";
240
+ const input = info.input && typeof info.input === "object" ? info.input : {};
241
+ const described = typeof input.description === "string" ? input.description.trim() : "";
242
+ if (state === "failed") {
243
+ failed = true;
244
+ // First, not in sequence: counted into a "+2" it would be the one
245
+ // phrase the red line is about and the one it does not show.
246
+ phrases.splice(failures++, 0, { text: failedPhrase(info.name, input, described) });
247
+ continue;
248
+ }
249
+ if (described) {
250
+ phrases.push({ text: described });
251
+ continue;
252
+ }
253
+ const kind = workKind(info.name);
254
+ const last = phrases.at(-1);
255
+ if (last && last.kind === kind && last.state === state) {
256
+ last.count += 1;
257
+ continue;
258
+ }
259
+ phrases.push({ kind, state, count: 1, name: info.name, input });
260
+ }
261
+
262
+ const texts = phrases.map((phrase) => phrase.text || kindPhrase(phrase));
263
+ const shown = texts.slice(0, 2).map((text, i) => (i ? lowerFirst(text) : text));
264
+ let label = shown.join(t("work.sep"));
265
+ if (texts.length > 2) label += " +" + (texts.length - 2);
266
+ group.querySelector(".work-label").textContent = label || t("work.working");
267
+ group.classList.toggle("failed", failed);
268
+ }
269
+
270
+ /** Which verb a tool gets when it did not say what it was for. */
271
+ function workKind(name) {
272
+ if (name === "bash") return "command";
273
+ if (name === "read" || name === "artifact_read" || name === "mcp_read_resource") return "read";
274
+ if (name === "write" || name === "artifact_write") return "write";
275
+ if (name === "edit") return "edit";
276
+ if (name === "list" || name === "mcp_list_resources") return "list";
277
+ if (name === "glob" || name === "grep") return "search";
278
+ if (name === "recall" || name === "memory") return "recall";
279
+ if (name === "todo_write") return "plan";
280
+ if (name === "skill") return "skill";
281
+ if (name === "ask_user") return "ask";
282
+ return "other";
283
+ }
284
+
285
+ /** The last segment of a path, which is what a person recognises. */
286
+ function baseName(value) {
287
+ if (typeof value !== "string" || !value) return "";
288
+ return value.replace(/\/+$/, "").split("/").pop() || value;
289
+ }
290
+
291
+ function kindPhrase(phrase) {
292
+ const now = phrase.state === "running";
293
+ const many = phrase.count > 1;
294
+ const file = baseName(phrase.input.path);
295
+ switch (phrase.kind) {
296
+ case "command":
297
+ return now ? t("work.commandNow") : many ? t("work.commandN", { n: phrase.count }) : t("work.command");
298
+ case "read":
299
+ case "write":
300
+ case "edit":
301
+ if (now) return t("work." + phrase.kind + "Now", { file: file || "…" });
302
+ if (many) return t("work." + phrase.kind + "N", { n: phrase.count });
303
+ return t("work." + phrase.kind, { file: file || "…" });
304
+ case "list":
305
+ // No path is the working directory, and "Listed ." reads as a typo.
306
+ if (!file || file === ".") return t(now ? "work.listHereNow" : "work.listHere");
307
+ return t(now ? "work.listNow" : "work.list", { dir: file });
308
+ case "search":
309
+ return now ? t("work.searchNow") : many ? t("work.searchN", { n: phrase.count }) : t("work.search");
310
+ case "recall":
311
+ case "plan":
312
+ case "ask":
313
+ return t("work." + phrase.kind + (now ? "Now" : ""));
314
+ case "skill":
315
+ return t(now ? "work.skillNow" : "work.skill", { name: String(phrase.input.name || "") });
316
+ default:
317
+ return t(now ? "work.otherNow" : "work.other", { tool: toolTitle(phrase.name, phrase.input) });
318
+ }
319
+ }
320
+
321
+ function failedPhrase(name, input, described) {
322
+ if (name === "bash") {
323
+ const command = typeof input.command === "string" ? input.command.trim().split("\n")[0] : "";
324
+ const what = described || (command.length > 32 ? command.slice(0, 32) + "…" : command);
325
+ return t("work.failedRun", { what: what || t("work.aCommand") });
326
+ }
327
+ return t("work.failed", { tool: described || toolTitle(name, input) });
328
+ }
329
+
330
+ /**
331
+ * The second phrase continues a sentence, so it starts lower-case — unless it
332
+ * opens with a name or an acronym, which keeps its capital.
333
+ */
334
+ function lowerFirst(text) {
335
+ if (text.length > 1 && text[1] === text[1].toLowerCase() && /^[A-Z]/.test(text)) {
336
+ return text[0].toLowerCase() + text.slice(1);
337
+ }
338
+ return text;
339
+ }
340
+
341
+ // --- tool presentation -----------------------------------------------------
342
+ // Shared by the tool cards and the approval dialog, so a call reads the same
343
+ // whether it is waiting on an answer or already done.
344
+
345
+ /** Display names and marks for Cloud apps, learned from catalog answers and connection cards. */
346
+ const cloudApps = new Map();
347
+
348
+ function learnCloudApp(slug, label, logo) {
349
+ if (typeof slug !== "string" || !slug) return;
350
+ const known = cloudApps.get(slug) || {};
351
+ cloudApps.set(slug, {
352
+ label: typeof label === "string" && label ? label : known.label,
353
+ logo: typeof logo === "string" && logo ? logo : known.logo,
354
+ });
355
+ }
356
+
357
+ function cloudAppName(slug) {
358
+ const known = cloudApps.get(slug);
359
+ if (known && known.label) return known.label;
360
+ const words = String(slug || "").replace(/_/g, " ");
361
+ return words.charAt(0).toUpperCase() + words.slice(1);
362
+ }
363
+
364
+ /** The app's mark, only when it is https — see connectionCard for why. */
365
+ function cloudAppLogo(slug) {
366
+ const known = cloudApps.get(slug);
367
+ if (!known || !known.logo) return undefined;
368
+ try {
369
+ const parsed = new URL(known.logo);
370
+ return parsed.protocol === "https:" ? parsed.href : undefined;
371
+ } catch {
372
+ return undefined;
373
+ }
374
+ }
375
+
376
+ function isEmptyInput(value) {
377
+ return value === undefined || value === null ||
378
+ (typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
379
+ }
380
+
381
+ /** mcp__github__create_issue reads as "github · create_issue". */
382
+ function friendlyToolName(name) {
383
+ const match = /^mcp__(.+?)__(.+)$/.exec(name || "");
384
+ return match ? match[1] + " · " + match[2] : name || "";
385
+ }
386
+
387
+ /**
388
+ * A call's one-line name, worked out here rather than taken from the engine:
389
+ * the engine's titles are English, and a replayed transcript only carries the
390
+ * tool's name — which is how a reload used to turn "Gmail · send_email" back
391
+ * into cloud_app_execute.
392
+ */
393
+ function toolTitle(name, input, fallback) {
394
+ const args = input && typeof input === "object" ? input : {};
395
+ if (name === "cloud_app_tools") {
396
+ return typeof args.app === "string" ? t("tool.cloudAppTools", { app: cloudAppName(args.app) }) : t("tool.cloudApps");
397
+ }
398
+ if (name === "cloud_app_execute" && typeof args.app === "string") {
399
+ return cloudAppName(args.app) + (typeof args.tool === "string" ? " · " + args.tool : "");
400
+ }
401
+ if (name === "ask_user" && Array.isArray(args.questions)) {
402
+ return t("tool.ask", { header: args.questions.map((q) => q && q.header).filter(Boolean).join(" · ") });
403
+ }
404
+ // The engine's own title when a tool wrote one, like Bash(npm test). Its
405
+ // default is the bare name followed by a JSON preview, which says less than
406
+ // the readable name does and is cut off mid-string.
407
+ if (fallback && fallback !== name && !fallback.startsWith(name + "(")) return fallback;
408
+ return friendlyToolName(name);
409
+ }
410
+
411
+ /** Parameters as a table: text keeps its line breaks, short lists become chips. */
412
+ function paramTable(params) {
413
+ const table = el("div", "kv");
414
+ for (const [key, value] of Object.entries(params)) {
415
+ const cell = el("div", "kv-v");
416
+ if (value === null || value === undefined || value === "") {
417
+ cell.textContent = "—";
418
+ cell.classList.add("empty");
419
+ } else if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
420
+ cell.textContent = String(value);
421
+ } else if (Array.isArray(value) && value.length && value.every((item) => typeof item === "string" || typeof item === "number")) {
422
+ for (const item of value) cell.appendChild(el("span", "kv-chip", String(item)));
423
+ } else {
424
+ const pre = el("pre", "mono");
425
+ pre.textContent = JSON.stringify(value, null, 2);
426
+ cell.appendChild(pre);
427
+ }
428
+ table.append(el("div", "kv-k", key), cell);
429
+ }
430
+ return table;
431
+ }
432
+
433
+ /**
434
+ * One line for a catalog answer, which is otherwise a single line of JSON.
435
+ * Written against the fields rather than a schema: a shape it does not
436
+ * recognise falls back to showing the raw answer, never to a wrong sentence.
437
+ */
438
+ function catalogSummary(text) {
439
+ let data;
440
+ try { data = JSON.parse(text); } catch { return ""; }
441
+ if (!data || typeof data !== "object") return "";
442
+ if (typeof data.app === "string") learnCloudApp(data.app, data.label, data.logo);
443
+ if (Array.isArray(data.apps)) {
444
+ const names = data.apps.map((app) => {
445
+ if (typeof app === "string") return cloudAppName(app);
446
+ if (!app || typeof app !== "object") return "";
447
+ const slug = app.slug || app.app || app.id;
448
+ learnCloudApp(slug, app.label || app.name, app.logo);
449
+ return app.label || app.name || (slug ? cloudAppName(slug) : "");
450
+ }).filter(Boolean);
451
+ return names.length ? t("tool.catalogApps", { n: names.length, names: names.join(", ") }) : "";
452
+ }
453
+ if (Array.isArray(data.tools) && typeof data.app === "string") {
454
+ return t("tool.catalogTools", { app: cloudAppName(data.app), n: data.tools.length });
455
+ }
456
+ return "";
457
+ }
458
+
459
+ /**
460
+ * A card for a call the model is still writing: the name is known, the
461
+ * arguments are not. Without it a long file write was a reply that said "now
462
+ * writing the file" and then showed nothing until the whole file had streamed.
463
+ */
464
+ function pendingToolCard(id, name) {
465
+ if (P.tools.has(id)) return;
466
+ const card = el("details", "tool");
467
+ const summary = el("summary");
468
+ summary.appendChild(el("span", "dot"));
469
+ summary.appendChild(el("span", "name", toolTitle(name)));
470
+ summary.appendChild(el("span", "ms", ""));
471
+ card.appendChild(summary);
472
+ const body = el("div", "tool-body");
473
+ card.appendChild(body);
474
+
475
+ card.toolInfo = { name };
476
+ const group = workGroup();
477
+ group.querySelector(".work-body").appendChild(card);
478
+ bumpWorkLabel(group);
479
+ P.tools.set(id, { card, body, name, input: undefined, group, pending: true });
480
+ P.streaming = null;
481
+ follow();
482
+ }
483
+
484
+ /**
485
+ * Drop the cards for calls that were announced and never ran — an unknown tool
486
+ * or bad input is answered without a tool_start, and a retried request gets
487
+ * fresh ids. Left alone they would pulse forever.
488
+ */
489
+ function dropPendingTools() {
490
+ for (const [id, entry] of P.tools) {
491
+ if (!entry.pending) continue;
492
+ entry.card.remove();
493
+ P.tools.delete(id);
494
+ const count = entry.group.querySelector(".work-body").children.length;
495
+ if (count) bumpWorkLabel(entry.group);
496
+ else entry.group.remove();
497
+ }
498
+ }
499
+
500
+ function toolCard(id, title, name, input) {
501
+ const announced = P.tools.get(id);
502
+ const card = announced ? announced.card : el("details", "tool");
503
+ if (announced) {
504
+ card.querySelector(".name").textContent = toolTitle(name, input, title);
505
+ } else {
506
+ const summary = el("summary");
507
+ summary.appendChild(el("span", "dot"));
508
+ summary.appendChild(el("span", "name", toolTitle(name, input, title)));
509
+ summary.appendChild(el("span", "ms", ""));
510
+ card.appendChild(summary);
511
+ }
512
+
513
+ const body = announced ? announced.body : el("div", "tool-body");
514
+ // An app call is its arguments; the app, tool and version are already the
515
+ // title or plumbing. A call with nothing to show gets no "input" label over
516
+ // an empty {} — which was most of what a catalog lookup used to draw. A
517
+ // question has its own card.
518
+ const shown = name === "cloud_app_execute" && input && typeof input === "object" ? input.arguments : input;
519
+ if (name !== "ask_user" && !isEmptyInput(shown)) {
520
+ body.appendChild(el("div", "label", "input"));
521
+ const tabular = shown && typeof shown === "object" && !Array.isArray(shown) &&
522
+ (name.startsWith("cloud_app_") || name.startsWith("mcp__"));
523
+ if (tabular) body.appendChild(paramTable(shown));
524
+ else {
525
+ const pre = el("pre", "mono");
526
+ pre.textContent = JSON.stringify(shown, null, 2);
527
+ body.appendChild(pre);
528
+ }
529
+ }
530
+ card.toolInfo = { name, input };
531
+ if (announced) {
532
+ P.tools.set(id, { card, body, name, input, group: announced.group });
533
+ // The arguments have arrived, and with them the description or the path
534
+ // the line was waiting for.
535
+ bumpWorkLabel(announced.group);
536
+ follow();
537
+ return;
538
+ }
539
+ card.appendChild(body);
540
+
541
+ const group = workGroup();
542
+ group.querySelector(".work-body").appendChild(card);
543
+ bumpWorkLabel(group);
544
+ P.tools.set(id, { card, body, name, input, group });
545
+ P.streaming = null;
546
+ follow();
547
+ }
548
+
549
+ function finishTool(id, result, ms, isError) {
550
+ const entry = P.tools.get(id);
551
+ if (!entry) return;
552
+ entry.card.classList.add(isError ? "failed" : "done");
553
+ entry.card.querySelector(".ms").textContent = ms + "ms";
554
+ // Running becomes ran, or the group turns red.
555
+ if (entry.group) bumpWorkLabel(entry.group);
556
+
557
+ const text = (result || []).map((part) => (part.type === "text" ? part.text : "")).join("");
558
+ entry.body.appendChild(el("div", "label", isError ? "error" : "result"));
559
+
560
+ // An edit is much easier to check as a diff than as two blobs of text.
561
+ // The edit tool's parameters are oldString/newString. An earlier version of
562
+ // this read oldText/newText, so the diff branch never once ran.
563
+ const before = entry.input && entry.input.oldString;
564
+ const after = entry.input && entry.input.newString;
565
+ if (entry.name === "edit" && typeof before === "string" && typeof after === "string") {
566
+ entry.body.appendChild(renderDiff(before, after));
567
+ } else {
568
+ const pre = el("pre", "mono");
569
+ pre.textContent = text.length > 4000 ? text.slice(0, 4000) + "\n… truncated" : text;
570
+ const summary = entry.name === "cloud_app_tools" && !isError ? catalogSummary(text) : "";
571
+ if (summary) {
572
+ entry.body.appendChild(el("div", "tool-summary", summary));
573
+ const raw = el("details", "tool-raw");
574
+ raw.appendChild(el("summary", "", t("tool.rawResult")));
575
+ raw.appendChild(pre);
576
+ entry.body.appendChild(raw);
577
+ // The answer may have named the app better than its slug did.
578
+ entry.card.querySelector(".name").textContent = toolTitle(entry.name, entry.input);
579
+ } else {
580
+ entry.body.appendChild(pre);
581
+ }
582
+ }
583
+ if (isError) {
584
+ // A failed step is the one thing worth surfacing without a click.
585
+ entry.card.open = true;
586
+ if (entry.group) entry.group.open = true;
587
+ }
588
+ follow();
589
+ }
590
+
591
+
592
+ // --- tabs ------------------------------------------------------------------
593
+
594
+ /**
595
+ * One transcript per session, kept in the DOM rather than re-fetched.
596
+ *
597
+ * A background tab keeps streaming, so its nodes have to stay live: rebuilding
598
+ * from history on every switch would drop whatever arrived while it was hidden
599
+ * and restart any in-flight reply from the top.
600
+ */
601
+ const tabsEl = document.getElementById("tabs");
602
+ const panes = new Map();
603
+ let activeId = null;
604
+
605
+ function pane(id) {
606
+ let entry = panes.get(id);
607
+ if (!entry) {
608
+ const node = el("div", "pane");
609
+ node.hidden = true;
610
+ log.appendChild(node);
611
+ entry = { node, title: "", cwd: "", busy: false, queued: [], turn: null, streaming: null, tools: new Map(), thinking: null };
612
+ panes.set(id, entry);
613
+ }
614
+ return entry;
615
+ }
616
+
617
+ function paintTabs() {
618
+ tabsEl.replaceChildren();
619
+
620
+ // Every tab says which project it is in, as "project / session": the
621
+ // native title bar is gone, so the strip is the only place that names what
622
+ // is on screen, and a tab should read the same whether or not its
623
+ // neighbours share its tree.
624
+ for (const [id, entry] of panes) {
625
+ const tab = el("button", "tab" + (id === activeId ? " on" : ""));
626
+ tab.type = "button";
627
+ if (entry.busy) tab.appendChild(el("span", "dot"));
628
+ if (entry.cwd) tab.appendChild(el("span", "where", projectName(entry.cwd)));
629
+ tab.appendChild(el("span", "label", entry.title || t("session.untitled")));
630
+ tab.appendChild(browserTabButton(id));
631
+
632
+ // Icons rather than the × and + characters: a glyph sits where its font
633
+ // puts it, below the middle of the line, while the tab's other controls
634
+ // are icons centred in their boxes.
635
+ const close = el("button", "x");
636
+ close.appendChild(svgIcon("close"));
637
+ close.type = "button";
638
+ close.title = t("tabs.close");
639
+ close.setAttribute("aria-label", t("tabs.close"));
640
+ close.addEventListener("click", (e) => {
641
+ e.stopPropagation();
642
+ void closeTab(id);
643
+ });
644
+ tab.appendChild(close);
645
+
646
+ tab.addEventListener("click", () => void focusTab(id));
647
+ tabsEl.appendChild(tab);
648
+ }
649
+
650
+ const add = el("button");
651
+ add.appendChild(svgIcon("plus"));
652
+ add.id = "tab-add";
653
+ add.type = "button";
654
+ add.title = t("tabs.new");
655
+ add.setAttribute("aria-label", t("tabs.new"));
656
+ add.addEventListener("click", () => void switchTo({ action: "new" }));
657
+ tabsEl.appendChild(add);
658
+ }
659
+
660
+ async function focusTab(id) {
661
+ if (!panes.has(id)) return;
662
+ activeId = id;
663
+ for (const [key, entry] of panes) entry.node.hidden = key !== id;
664
+ setNav(null);
665
+ paintEmpty(panes.get(id));
666
+ paintTabs();
667
+ browserActivateSession();
668
+
669
+ const state = await getJson("/state?session=" + encodeURIComponent(id));
670
+ if (!state.error) applyState(state);
671
+ await api("/session", { action: "focus", id });
672
+ await loadSidePanels();
673
+ // A tab carries its tree with it: the header, the folder dialog and @ all
674
+ // follow the session being looked at rather than the last one started. The
675
+ // sidebar comes with them — it groups by project and marks the session in
676
+ // hand, and both of those just changed.
677
+ if (panes.get(id)) panes.get(id).cwd = state.cwd || panes.get(id).cwd;
678
+ await loadProjects();
679
+ await loadSessions();
680
+ }
681
+
682
+ async function closeTab(id) {
683
+ const body = await api("/session", { action: "close", id });
684
+ const entry = panes.get(id);
685
+ if (entry) entry.node.remove();
686
+ panes.delete(id);
687
+ browserForgetSession(id);
688
+
689
+ // Never leave the page with no transcript: fall back to whatever is left, or
690
+ // open a fresh session if that was the last one.
691
+ if (activeId === id) {
692
+ const next = (body.open || []).find((other) => panes.has(other)) || [...panes.keys()][0];
693
+ if (next) await focusTab(next);
694
+ else await switchTo({ action: "new" });
695
+ }
696
+ paintTabs();
697
+ }
698
+ `;
699
+ //# sourceMappingURL=transcript.js.map