@oharness/server 0.9.3 → 0.11.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 (93) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +500 -64
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/main.js +4 -0
  9. package/dist/main.js.map +1 -1
  10. package/dist/model-order.js +1 -0
  11. package/dist/model-order.js.map +1 -1
  12. package/dist/page/client/account.js +570 -27
  13. package/dist/page/client/account.js.map +1 -1
  14. package/dist/page/client/base.js +55 -12
  15. package/dist/page/client/base.js.map +1 -1
  16. package/dist/page/client/browser.js +1 -1
  17. package/dist/page/client/composer.js +802 -59
  18. package/dist/page/client/composer.js.map +1 -1
  19. package/dist/page/client/computer.d.ts +27 -0
  20. package/dist/page/client/computer.js +358 -0
  21. package/dist/page/client/computer.js.map +1 -0
  22. package/dist/page/client/dialogs.js +168 -6
  23. package/dist/page/client/dialogs.js.map +1 -1
  24. package/dist/page/client/events.js +119 -9
  25. package/dist/page/client/events.js.map +1 -1
  26. package/dist/page/client/files.js +86 -1
  27. package/dist/page/client/files.js.map +1 -1
  28. package/dist/page/client/index.js +8 -0
  29. package/dist/page/client/index.js.map +1 -1
  30. package/dist/page/client/panels.js +14 -10
  31. package/dist/page/client/panels.js.map +1 -1
  32. package/dist/page/client/ripple.d.ts +13 -0
  33. package/dist/page/client/ripple.js +247 -0
  34. package/dist/page/client/ripple.js.map +1 -0
  35. package/dist/page/client/routines.js +432 -45
  36. package/dist/page/client/routines.js.map +1 -1
  37. package/dist/page/client/shell.js +386 -104
  38. package/dist/page/client/shell.js.map +1 -1
  39. package/dist/page/client/state.js +278 -19
  40. package/dist/page/client/state.js.map +1 -1
  41. package/dist/page/client/tasks.d.ts +8 -0
  42. package/dist/page/client/tasks.js +293 -0
  43. package/dist/page/client/tasks.js.map +1 -0
  44. package/dist/page/client/terminal.js +34 -7
  45. package/dist/page/client/terminal.js.map +1 -1
  46. package/dist/page/client/theme.d.ts +17 -0
  47. package/dist/page/client/theme.js +365 -0
  48. package/dist/page/client/theme.js.map +1 -0
  49. package/dist/page/client/transcript.js +196 -8
  50. package/dist/page/client/transcript.js.map +1 -1
  51. package/dist/page/client/usage.js +1 -11
  52. package/dist/page/client/usage.js.map +1 -1
  53. package/dist/page/style.js +745 -76
  54. package/dist/page/style.js.map +1 -1
  55. package/dist/page.js +94 -16
  56. package/dist/page.js.map +1 -1
  57. package/dist/prefs.d.ts +31 -0
  58. package/dist/prefs.js +13 -1
  59. package/dist/prefs.js.map +1 -1
  60. package/dist/remote-crypto.d.ts +78 -0
  61. package/dist/remote-crypto.js +216 -0
  62. package/dist/remote-crypto.js.map +1 -0
  63. package/dist/remote.d.ts +103 -0
  64. package/dist/remote.js +498 -0
  65. package/dist/remote.js.map +1 -0
  66. package/dist/server.d.ts +70 -0
  67. package/dist/server.js +1738 -73
  68. package/dist/server.js.map +1 -1
  69. package/dist/starters.d.ts +92 -0
  70. package/dist/starters.js +213 -0
  71. package/dist/starters.js.map +1 -0
  72. package/dist/suggest.d.ts +42 -0
  73. package/dist/suggest.js +215 -0
  74. package/dist/suggest.js.map +1 -0
  75. package/dist/terminal.d.ts +28 -2
  76. package/dist/terminal.js +45 -3
  77. package/dist/terminal.js.map +1 -1
  78. package/dist/theme-assets.d.ts +39 -0
  79. package/dist/theme-assets.js +109 -0
  80. package/dist/theme-assets.js.map +1 -0
  81. package/dist/theme-store.d.ts +50 -0
  82. package/dist/theme-store.js +124 -0
  83. package/dist/theme-store.js.map +1 -0
  84. package/dist/theme-tools.d.ts +25 -0
  85. package/dist/theme-tools.js +243 -0
  86. package/dist/theme-tools.js.map +1 -0
  87. package/dist/turn-shots.d.ts +43 -0
  88. package/dist/turn-shots.js +80 -0
  89. package/dist/turn-shots.js.map +1 -0
  90. package/dist/user-theme.d.ts +143 -0
  91. package/dist/user-theme.js +510 -0
  92. package/dist/user-theme.js.map +1 -0
  93. package/package.json +2 -2
@@ -11,6 +11,7 @@ export const COMPOSER = String.raw `
11
11
  // stays small and a half-finished upload cannot start a turn.
12
12
 
13
13
  const attachEl = document.getElementById("attachments");
14
+ const trayEl = document.getElementById("tray");
14
15
  const queuedEl = document.getElementById("queued");
15
16
  const fileInput = document.getElementById("file");
16
17
  const composerBox = document.getElementById("composer-box");
@@ -30,6 +31,158 @@ const sentPreviews = new Map();
30
31
  */
31
32
  let refs = [];
32
33
  const REF_ICON = { file: "file", folder: "folder", connector: "plug", plugin: "plugin", skill: "skill" };
34
+ /**
35
+ * Whether the next message is sent as a goal.
36
+ *
37
+ * Switched on from the + menu or a bare /goal, and off again once a message
38
+ * goes: a goal is one objective, and every message after it staying a goal
39
+ * would replace the one being worked on.
40
+ */
41
+ let goalMode = false;
42
+ /**
43
+ * Whether the next message starts a new session in a new worktree beside the
44
+ * project, instead of going to this one. Off again once it is sent, like a
45
+ * goal: it describes one message, not the conversation.
46
+ */
47
+ let worktreeMode = false;
48
+ const inputPlaceholder = () =>
49
+ t(goalMode ? "goal.placeholder" : worktreeMode ? "worktree.placeholder" : "composer.placeholder");
50
+
51
+ function setGoalMode(on) {
52
+ goalMode = on;
53
+ renderAttachments();
54
+ }
55
+
56
+ function setWorktreeMode(on) {
57
+ worktreeMode = on;
58
+ renderAttachments();
59
+ }
60
+
61
+ /**
62
+ * Make the worktree, open the session in it, and send it the message.
63
+ *
64
+ * The server does all three in one call so the first turn cannot start in the
65
+ * wrong tree; the page then moves to the new tab the way any switch does.
66
+ */
67
+ async function startInWorktree(body) {
68
+ const state = await api("/worktree", body, 5 * 60_000);
69
+ if (!state || state.error) return void addNotice((state && state.error) || t("worktree.failed"), "notice err");
70
+ await openState(state);
71
+ await loadProjects();
72
+ const wt = state.worktree || {};
73
+ addNotice(
74
+ wt.branch
75
+ ? t("worktree.startedBranch", { name: projectName(wt.root || wt.path), branch: wt.branch })
76
+ : t("worktree.started", { name: projectName(wt.root || wt.path) }),
77
+ );
78
+ }
79
+
80
+ /**
81
+ * Computer use for the conversation on screen: off, or on with what the OS
82
+ * currently lets it do. Kept on the pane, from the server's state, because it
83
+ * belongs to the conversation rather than to the next message the way goal
84
+ * mode does.
85
+ */
86
+ const computerHere = () => {
87
+ const here = panes.get(activeId);
88
+ return (here && here.computer) || null;
89
+ };
90
+
91
+ function setComputerUse(on) {
92
+ track("computer_use", { on });
93
+ void api("/computer-use", { session: activeId, on }).then((s) => {
94
+ if (s && s.error) return void addNotice(s.error, "notice err");
95
+ applyState(s);
96
+ });
97
+ }
98
+
99
+ /** While the permission card is up, look again every two seconds. */
100
+ let computerPoll = null;
101
+ function watchComputerGrants(missing) {
102
+ if (missing && !computerPoll) {
103
+ computerPoll = setInterval(() => void api("/computer-use/permissions", {}), 2000);
104
+ } else if (!missing && computerPoll) {
105
+ clearInterval(computerPoll);
106
+ computerPoll = null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * The macOS grants, asked for in place.
112
+ *
113
+ * Only on macOS and only for what is missing. Each row asks for one grant, so
114
+ * the system prompt that follows is about exactly the thing the row names.
115
+ */
116
+ function computerPermissionCard(computer) {
117
+ const perms = computer.permissions;
118
+ const card = el("div", "att computer-perm");
119
+ const head = el("div", "cp-head");
120
+ head.appendChild(svgIcon("computer"));
121
+ // The app whose switch it is: OHarness, or Electron for a development run.
122
+ const grantee = computer.grantee || "OHarness";
123
+ head.appendChild(el("span", "cp-title", t("computer.permTitle", { app: grantee })));
124
+ card.appendChild(head);
125
+ card.appendChild(el("div", "cp-body", t(grantee === "OHarness" ? "computer.permBody" : "computer.permBodyDev", { app: grantee })));
126
+ for (const [grant, label] of [["screen", "computer.permScreen"], ["accessibility", "computer.permAx"]]) {
127
+ const row = el("div", "cp-row");
128
+ row.appendChild(el("span", "cp-label", t(label)));
129
+ if (perms[grant]) {
130
+ // A tick, not a word in green: done is the quiet state, and the row's
131
+ // own label already says what was allowed.
132
+ const ok = el("span", "cp-ok", "\u2713");
133
+ ok.title = t("computer.granted");
134
+ ok.setAttribute("aria-label", t("computer.granted"));
135
+ row.appendChild(ok);
136
+ } else {
137
+ const allow = el("button", "cp-btn", t("computer.allow"));
138
+ allow.type = "button";
139
+ allow.addEventListener("click", () => void api("/computer-use/permissions", { request: true, grant }));
140
+ row.appendChild(allow);
141
+ }
142
+ card.appendChild(row);
143
+ }
144
+ const foot = el("div", "cp-foot");
145
+ const again = el("button", "cp-link", t("computer.recheck"));
146
+ again.type = "button";
147
+ again.addEventListener("click", () => void api("/computer-use/permissions", {}));
148
+ foot.appendChild(again);
149
+ if (computer.relaunch) {
150
+ const relaunch = el("button", "cp-link", t("computer.relaunch"));
151
+ relaunch.type = "button";
152
+ relaunch.title = t("computer.relaunchHint");
153
+ relaunch.addEventListener("click", () => void api("/computer-use/relaunch", {}));
154
+ foot.appendChild(relaunch);
155
+ }
156
+ card.appendChild(foot);
157
+ return card;
158
+ }
159
+
160
+ /** The tab's permission mode, as its state last said. */
161
+ function currentMode() {
162
+ const here = panes.get(activeId);
163
+ return (here && here.mode) || "default";
164
+ }
165
+
166
+ /**
167
+ * Into plan mode, and back to whatever it was before — not to Manual, which
168
+ * would quietly undo an Accept edits or Auto somebody had chosen.
169
+ */
170
+ function togglePlanMode() {
171
+ const here = panes.get(activeId);
172
+ if (!here) return;
173
+ const leaving = currentMode() === "plan";
174
+ const next = leaving ? here.beforePlan || "default" : "plan";
175
+ if (!leaving) here.beforePlan = currentMode();
176
+ track("plan_mode", { on: !leaving });
177
+ void api("/mode", { session: activeId, mode: next }).then(applyState);
178
+ }
179
+
180
+ /** Send text as the session's goal: the first message, and the finish line. */
181
+ function sendGoal(text) {
182
+ if (!text) return void addNotice(t("goal.needsText"), "notice err");
183
+ track("goal_set", {});
184
+ void api("/prompt", { session: activeId, prompt: text, attachments: [], references: [], goal: true }).then(pushNotice);
185
+ }
33
186
  const refKey = (ref) => ref.kind + ":" + ref.value;
34
187
 
35
188
  /**
@@ -46,6 +199,22 @@ function renderQueued() {
46
199
  // tab is the one streaming.
47
200
  const here = panes.get(activeId);
48
201
  const waiting = (here && here.queued) || [];
202
+ // The goal being worked toward sits over the queue: it is the reason the
203
+ // turn below it keeps going, and the × is the way to let it stop.
204
+ if (here && here.goal) {
205
+ const row = el("div", "q goal");
206
+ row.appendChild(el("span", "tag", t("goal.tag")));
207
+ row.appendChild(el("span", "n", here.goal));
208
+ const stop = el("button", "x", "×");
209
+ stop.type = "button";
210
+ stop.title = t("goal.stop");
211
+ stop.addEventListener("click", () => void api("/goal", { session: activeId }).then(applyState));
212
+ row.appendChild(stop);
213
+ queuedEl.appendChild(row);
214
+ }
215
+ // Background tasks next: work this conversation set going, still part of
216
+ // what is happening around the message being written.
217
+ appendTaskRows(queuedEl, here);
49
218
  for (const entry of waiting) {
50
219
  const row = el("div", "q" + (entry.soon ? " soon" : ""));
51
220
  // Two different promises: one joins the turn that is running, the other
@@ -80,48 +249,222 @@ function renderQueued() {
80
249
 
81
250
  function renderAttachments() {
82
251
  attachEl.replaceChildren();
252
+ // What is staged decides whether the guess still fits, and the goal and
253
+ // worktree modes repaint through here with their own placeholder.
254
+ paintSuggestion();
255
+
256
+ if (goalMode) {
257
+ const chip = el("div", "att goal");
258
+ chip.appendChild(svgIcon("goal"));
259
+ chip.appendChild(el("span", "n", t("goal.chip")));
260
+ const drop = el("button", "x", "×");
261
+ drop.type = "button";
262
+ drop.title = t("add.remove");
263
+ drop.addEventListener("click", () => setGoalMode(false));
264
+ chip.appendChild(drop);
265
+ attachEl.appendChild(chip);
266
+ }
83
267
 
84
- // References first: they were picked deliberately from the menu, while an
85
- // upload is often a screenshot that was pasted without being thought about.
268
+ if (worktreeMode) {
269
+ const chip = el("div", "att goal");
270
+ chip.appendChild(svgIcon("worktree"));
271
+ chip.appendChild(el("span", "n", t("worktree.chip")));
272
+ const drop = el("button", "x", "×");
273
+ drop.type = "button";
274
+ drop.title = t("add.remove");
275
+ drop.addEventListener("click", () => setWorktreeMode(false));
276
+ chip.appendChild(drop);
277
+ attachEl.appendChild(chip);
278
+ }
279
+
280
+ // Computer use is a state of the conversation, so its chip stays until it is
281
+ // switched off, and says what is missing when the OS has not allowed it yet.
282
+ const computer = computerHere();
283
+ let missing = false;
284
+ if (computer && computer.on) {
285
+ const chip = el("div", "att computer");
286
+ chip.appendChild(svgIcon("computer"));
287
+ const label = el("span", "n", t("computer.chip"));
288
+ // The chip is also the way back to the live card once it was folded.
289
+ label.classList.add("clickable");
290
+ label.title = t("computer.showPane");
291
+ label.addEventListener("click", computerShow);
292
+ chip.appendChild(label);
293
+ const drop = el("button", "x", "×");
294
+ drop.type = "button";
295
+ drop.title = t("computer.off");
296
+ drop.addEventListener("click", () => setComputerUse(false));
297
+ chip.appendChild(drop);
298
+ attachEl.appendChild(chip);
299
+ const perms = computer.permissions;
300
+ missing = Boolean(computer.platform === "darwin" && perms && (!perms.screen || !perms.accessibility));
301
+ if (missing) attachEl.appendChild(computerPermissionCard(computer));
302
+ else if (computer.error) attachEl.appendChild(el("div", "att computer-perm cp-body", t("computer.unavailable", { error: computer.error })));
303
+ }
304
+ watchComputerGrants(missing);
305
+
306
+ // Capabilities named for this message — a connector, a plugin, a skill —
307
+ // stay in the box beside the goal and Computer use: they are how it is
308
+ // sent. What it carries goes in the tray above.
86
309
  for (const ref of refs) {
310
+ if (ref.kind === "file" || ref.kind === "folder") continue;
87
311
  const chip = el("div", "att");
88
312
  chip.appendChild(svgIcon(REF_ICON[ref.kind]));
89
313
  chip.appendChild(el("span", "n", ref.label));
90
314
  if (ref.sub) chip.appendChild(el("span", "where", ref.sub));
91
- const drop = el("button", "x", "×");
92
- drop.type = "button";
93
- drop.title = t("add.remove");
94
- drop.addEventListener("click", () => {
315
+ chip.appendChild(dropButton(() => {
95
316
  refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
96
317
  renderAttachments();
97
- });
98
- chip.appendChild(drop);
318
+ }));
99
319
  attachEl.appendChild(chip);
100
320
  }
321
+ renderTray();
322
+ }
101
323
 
324
+ /**
325
+ * What the message carries, above the box rather than in it.
326
+ *
327
+ * Inside, a screenshot was a 26px square beside its file name, and a row of
328
+ * them pushed the text being written down the box. Out here each is a card
329
+ * of its own: an image or a video as a picture of itself, a file or a folder
330
+ * as its name and where it is. Files and folders picked from the disk first —
331
+ * picked on purpose — then what was pasted or dropped.
332
+ */
333
+ function renderTray() {
334
+ trayEl.replaceChildren();
335
+ for (const ref of refs) {
336
+ if (ref.kind !== "file" && ref.kind !== "folder") continue;
337
+ const media = ref.kind === "file" ? mediaKindOf(ref.value) : "";
338
+ const cwd = P && P.cwd;
339
+ // Inside the project the file browser serves it; outside, only a file
340
+ // picked in the dialog can be shown, from the server's list of those.
341
+ const outside = /^(\/|[A-Za-z]:[\\/])/.test(ref.value);
342
+ const src = !media ? "" : outside
343
+ ? q("/pick/preview?path=" + encodeURIComponent(ref.value))
344
+ : cwd ? q("/files/raw?cwd=" + encodeURIComponent(cwd) + "&path=" + encodeURIComponent(ref.value)) : "";
345
+ trayEl.appendChild(trayCard({
346
+ media: src ? media : "",
347
+ src,
348
+ icon: REF_ICON[ref.kind],
349
+ name: ref.label,
350
+ sub: ref.sub || "",
351
+ remove: () => {
352
+ refs = refs.filter((entry) => refKey(entry) !== refKey(ref));
353
+ renderAttachments();
354
+ },
355
+ }));
356
+ }
102
357
  for (const file of staged) {
103
- const chip = el("div", "att");
104
- if (file.preview) {
105
- const img = document.createElement("img");
106
- img.src = file.preview;
107
- img.alt = "";
108
- chip.appendChild(img);
109
- }
110
- chip.appendChild(el("span", "n", file.name));
111
- chip.appendChild(el("span", "sz", (file.bytes / 1024).toFixed(0) + "KB"));
112
- const remove = el("button", "x", "×");
113
- remove.type = "button";
114
- remove.addEventListener("click", () => {
115
- staged = staged.filter((entry) => entry.id !== file.id);
116
- // The server is holding these bytes for a prompt that is no longer
117
- // going to name them, and it has no other way to learn that.
118
- void api("/upload/drop", { id: file.id });
119
- if (file.preview) URL.revokeObjectURL(file.preview);
120
- renderAttachments();
358
+ const media = file.preview ? file.previewKind || "image" : "";
359
+ trayEl.appendChild(trayCard({
360
+ media,
361
+ src: file.preview || "",
362
+ icon: "file",
363
+ name: file.name,
364
+ sub: formatBytes(file.bytes),
365
+ remove: () => {
366
+ staged = staged.filter((entry) => entry.id !== file.id);
367
+ // The server is holding these bytes for a prompt that is no longer
368
+ // going to name them, and it has no other way to learn that.
369
+ void api("/upload/drop", { id: file.id });
370
+ if (file.preview) URL.revokeObjectURL(file.preview);
371
+ renderAttachments();
372
+ },
373
+ }));
374
+ }
375
+ }
376
+
377
+ /** An image or a video can be shown as itself; anything else is a name. */
378
+ function mediaKindOf(name) {
379
+ const ext = (String(name).split(".").pop() || "").toLowerCase();
380
+ if (["png", "jpg", "jpeg", "gif", "webp", "avif"].includes(ext)) return "image";
381
+ if (["mp4", "webm", "mov", "m4v"].includes(ext)) return "video";
382
+ return "";
383
+ }
384
+
385
+ function formatBytes(bytes) {
386
+ if (!bytes && bytes !== 0) return "";
387
+ if (bytes < 1024 * 1024) return Math.max(1, Math.round(bytes / 1024)) + " KB";
388
+ return (bytes / (1024 * 1024)).toFixed(1) + " MB";
389
+ }
390
+
391
+ function dropButton(onDrop) {
392
+ const drop = el("button", "x", "×");
393
+ drop.type = "button";
394
+ drop.title = t("add.remove");
395
+ drop.setAttribute("aria-label", t("add.remove"));
396
+ drop.addEventListener("click", (e) => {
397
+ e.stopPropagation();
398
+ onDrop();
399
+ });
400
+ return drop;
401
+ }
402
+
403
+ /**
404
+ * One card in the tray. A picture is the card — click it for the full size,
405
+ * the same preview the conversation uses — and a video plays muted on hover,
406
+ * so which clip it is can be told without opening it. A file is its icon, its
407
+ * name and a line under it.
408
+ */
409
+ function trayCard({ media, src, icon, name, sub, remove }) {
410
+ const card = el("div", "tray-card" + (media === "image" ? " tray-media image" : media === "video" ? " video" : ""));
411
+ card.title = name;
412
+ if (media === "image") {
413
+ const img = document.createElement("img");
414
+ img.src = src;
415
+ img.alt = name;
416
+ img.addEventListener("click", () => previewImage(img));
417
+ // A preview that will not load is a file card, not a broken picture.
418
+ img.addEventListener("error", () => card.replaceWith(trayCard({ media: "", src: "", icon, name, sub, remove })));
419
+ card.appendChild(img);
420
+ } else if (media === "video") {
421
+ // A clip keeps its name and size beside it: one frame of a video says
422
+ // less about which file it is than a photo does.
423
+ const thumb = el("span", "tray-thumb");
424
+ const video = document.createElement("video");
425
+ video.src = src;
426
+ video.muted = true;
427
+ video.preload = "metadata";
428
+ video.playsInline = true;
429
+ video.loop = true;
430
+ // The first frame rather than black, which is what a clip shows before it plays.
431
+ video.addEventListener("loadedmetadata", () => {
432
+ try {
433
+ video.currentTime = Math.min(0.1, video.duration || 0);
434
+ } catch {
435
+ // Not seekable: its poster frame is whatever it shows.
436
+ }
437
+ if (Number.isFinite(video.duration) && video.duration > 0) {
438
+ const s = Math.round(video.duration);
439
+ duration.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
440
+ }
121
441
  });
122
- chip.appendChild(remove);
123
- attachEl.appendChild(chip);
442
+ card.addEventListener("mouseenter", () => void video.play().catch(() => {}));
443
+ card.addEventListener("mouseleave", () => video.pause());
444
+ video.addEventListener("error", () => card.replaceWith(trayCard({ media: "", src: "", icon, name, sub, remove })));
445
+ thumb.appendChild(video);
446
+ const play = el("span", "tray-play");
447
+ play.innerHTML = '<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M5.5 3.8v8.4L12 8z" fill="currentColor"/></svg>';
448
+ thumb.appendChild(play);
449
+ card.appendChild(thumb);
450
+ const text = el("span", "tray-text");
451
+ text.appendChild(el("span", "tray-name", name));
452
+ const line = el("span", "tray-sub");
453
+ const duration = el("span", "tray-duration");
454
+ line.append(duration, document.createTextNode(sub ? (sub) : ""));
455
+ text.appendChild(line);
456
+ card.appendChild(text);
457
+ } else {
458
+ const mark = el("span", "tray-icon");
459
+ mark.appendChild(svgIcon(icon));
460
+ card.appendChild(mark);
461
+ const text = el("span", "tray-text");
462
+ text.appendChild(el("span", "tray-name", name));
463
+ if (sub) text.appendChild(el("span", "tray-sub", sub));
464
+ card.appendChild(text);
124
465
  }
466
+ card.appendChild(dropButton(remove));
467
+ return card;
125
468
  }
126
469
 
127
470
  async function upload(file) {
@@ -135,6 +478,10 @@ async function upload(file) {
135
478
 
136
479
  // A local preview rather than a round trip: the bytes are already here.
137
480
  if (body.kind === "image") body.preview = URL.createObjectURL(file);
481
+ else if ((file.type || "").startsWith("video/")) {
482
+ body.preview = URL.createObjectURL(file);
483
+ body.previewKind = "video";
484
+ }
138
485
  staged.push(body);
139
486
  renderAttachments();
140
487
  }
@@ -207,7 +554,7 @@ let fileList = { entries: [] };
207
554
  /** Whether the context endpoint has answered once, for the mention list. */
208
555
  let offerLoaded = false;
209
556
  /** Connectors and plugins, refreshed each time the menu opens. */
210
- let offer = { connectors: [], plugins: [], skills: [] };
557
+ let offer = { connectors: [], plugins: [], skills: [], agents: [] };
211
558
  /** The connectors on offer rather than the ones already here. */
212
559
  let market = [];
213
560
  /** The connector whose sign-in is open in a browser tab, if any. */
@@ -384,6 +731,25 @@ function attachRows() {
384
731
  note: plugin.description || "",
385
732
  ref: { kind: "plugin", value: plugin.name, label: plugin.name },
386
733
  }));
734
+ /**
735
+ * External agents first: naming one at the start of a message hands the
736
+ * whole message to it — Claude Code on the user's own account — so it is
737
+ * the one mention that changes who answers, and worth seeing before the
738
+ * rest. Offered only while the @ opens the message, the one place it
739
+ * routes; mid-sentence it would only be a word.
740
+ */
741
+ const atStart = mentionAt >= 0 && !input.value.slice(0, mentionAt).trim();
742
+ const agents = atStart
743
+ ? (offer.agents || [])
744
+ .filter((agent) => !query || agent.name.toLowerCase().includes(query))
745
+ .map((agent) => ({
746
+ type: "agent",
747
+ icon: "command",
748
+ label: "@" + agent.name,
749
+ note: t("mention.agentNote"),
750
+ ref: { kind: "agent", value: agent.name, label: agent.name },
751
+ }))
752
+ : [];
387
753
  const files = (fileList.entries || []).map((entry) => ({
388
754
  type: entry.dir ? "folder" : "file",
389
755
  icon: entry.dir ? "folder" : "file",
@@ -400,6 +766,7 @@ function attachRows() {
400
766
  * label on a list of one kind, which is noise.
401
767
  */
402
768
  const sections = [
769
+ [t("mention.agents"), agents],
403
770
  [t("add.skills"), skills],
404
771
  [t("add.plugins"), plugins],
405
772
  [t("mention.files"), files],
@@ -414,16 +781,35 @@ function attachRows() {
414
781
  const plugins = (offer.plugins || []).length;
415
782
  const skills = (offer.skills || []).length;
416
783
  return [
417
- // Two rows, because the dialogs behind them are two: only macOS has a
418
- // panel that takes a file and a folder in one pass, and everywhere else a
419
- // single row promising both could open only one of them. Saying which one
420
- // opens before it opens is also how someone reaching for a screenshot
421
- // stops reading at the first row.
422
- { type: "pick", kind: "files", icon: "file", label: t("add.attachFiles") },
784
+ // What goes with the message: from this machine, first. Two rows for
785
+ // files and folders, because the dialogs behind them are two: only macOS
786
+ // has a panel that takes a file and a folder in one pass, and everywhere
787
+ // else a single row promising both could open only one of them. Saying
788
+ // which one opens before it opens is also how someone reaching for a
789
+ // screenshot stops reading at the first row.
790
+ { type: "group", label: t("add.sectionAttach") },
791
+ { type: "pick", kind: "files", icon: "attach", label: t("add.attachFiles") },
423
792
  { type: "pick", kind: "folder", icon: "folder", label: t("add.attachFolders") },
424
- // A count when there is one, and nothing when there is not: "none" is a
425
- // word in the slot that otherwise holds a number, and it reads as a value
426
- // rather than as an absence.
793
+ { type: "group", label: t("add.sectionMode") },
794
+ // The permission mode of the same name, from the place people look for a
795
+ // way of working. The picker by the send button still holds all five.
796
+ { type: "plan", icon: "modePlan", label: t("add.planMode"), on: currentMode() === "plan" },
797
+ // How the agent works on it: a way of sending, and a capability of the
798
+ // conversation, rather than things sent.
799
+ { type: "goal", icon: "goal", label: t("add.goal"), on: goalMode },
800
+ // Where it happens: a tree of its own beside this one, for work that
801
+ // should not touch what is being edited here.
802
+ { type: "worktree", icon: "worktree", label: t("add.worktree"), on: worktreeMode },
803
+ // Only where there is a computer to use: the desktop app, whose server
804
+ // reports the feature in the conversation's state.
805
+ ...(IS_DESKTOP && computerHere()
806
+ ? [{ type: "computer", icon: "computer", label: t("add.computerUse"), on: Boolean(computerHere().on) }]
807
+ : []),
808
+ { type: "group", label: t("add.sectionReach") },
809
+ // What it can reach, each a list of its own. A count when there is one,
810
+ // and nothing when there is not: "none" is a word in the slot that
811
+ // otherwise holds a number, and it reads as a value rather than as an
812
+ // absence.
427
813
  {
428
814
  type: "into",
429
815
  kind: "connectors",
@@ -448,21 +834,29 @@ function attachRows() {
448
834
  sub: skills ? String(skills) : "",
449
835
  into: true,
450
836
  },
451
- { type: "upload", icon: "upload", label: t("add.upload") },
452
837
  ];
453
838
  }
454
839
 
840
+ /** Headings and dividers: in the list, but not something to pick. */
841
+ function notChoice(row) {
842
+ return row.type === "group" || row.type === "sep";
843
+ }
844
+
455
845
  function paintAttachMenu() {
456
846
  const rows = attachRows();
457
847
  if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
458
848
  // The list opens with the cursor at 0, and 0 is a heading whenever the list
459
849
  // is grouped. Nudged here rather than at every call site that sets it.
460
- while (rows[attachCursor] && rows[attachCursor].type === "group" && attachCursor < rows.length - 1) {
850
+ while (rows[attachCursor] && notChoice(rows[attachCursor]) && attachCursor < rows.length - 1) {
461
851
  attachCursor++;
462
852
  }
463
853
  attachBody.replaceChildren();
464
854
 
465
855
  rows.forEach((row, i) => {
856
+ if (row.type === "sep") {
857
+ attachBody.appendChild(el("div", "menu-sep"));
858
+ return;
859
+ }
466
860
  if (row.type === "group") {
467
861
  attachBody.appendChild(el("div", "menu-head", row.label));
468
862
  return;
@@ -473,7 +867,7 @@ function paintAttachMenu() {
473
867
  }
474
868
  const item = el(
475
869
  "div",
476
- "menu-item" + (row.ref && referenced(row.ref) ? " on" : "") + (i === attachCursor ? " cursor" : ""),
870
+ "menu-item" + ((row.ref && referenced(row.ref)) || row.on ? " on" : "") + (i === attachCursor ? " cursor" : ""),
477
871
  );
478
872
  item.setAttribute("role", "button");
479
873
  item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
@@ -491,7 +885,7 @@ function paintAttachMenu() {
491
885
  // is chosen still held a column open in front of every row that was not,
492
886
  // which put the icons a third of the way into the menu and the words
493
887
  // further still. On the right it costs nothing until there is one.
494
- if (row.ref && referenced(row.ref)) item.appendChild(el("span", "tick", "✓"));
888
+ if ((row.ref && referenced(row.ref)) || row.on) item.appendChild(el("span", "tick", "✓"));
495
889
  if (row.into) item.appendChild(el("span", "chev"));
496
890
  // An installed connector is removed from the row that says it is
497
891
  // installed, which is the row someone looking to remove it goes to.
@@ -510,7 +904,7 @@ function paintAttachMenu() {
510
904
  });
511
905
 
512
906
  // One line saying why a screen is empty, rather than an empty screen.
513
- const listed = rows.filter((row) => row.type !== "up").length;
907
+ const listed = rows.filter((row) => row.type !== "up" && row.type !== "sep").length;
514
908
  if (attachView === "connectors" && !listed) {
515
909
  attachBody.appendChild(el("div", "menu-hint", t("add.noConnectors")));
516
910
  } else if (attachView === "plugins" && !listed) {
@@ -524,7 +918,7 @@ function paintAttachMenu() {
524
918
  }
525
919
 
526
920
  function chooseAttachRow(row) {
527
- if (row.type === "hint") return;
921
+ if (row.type === "hint" || notChoice(row)) return;
528
922
  if (row.type === "up") return void showAttachView("root");
529
923
  if (row.type === "into") return void showAttachView(row.kind);
530
924
  if (row.type === "offer-ref") {
@@ -549,11 +943,27 @@ function chooseAttachRow(row) {
549
943
  closeAttachMenu();
550
944
  return void openMarket(row.kind);
551
945
  }
552
- if (row.type === "upload") {
946
+ if (row.type === "command") return void pickCommand(row.command);
947
+ if (row.type === "goal") {
553
948
  closeAttachMenu();
554
- return void fileInput.click();
949
+ setGoalMode(!goalMode);
950
+ return void input.focus();
951
+ }
952
+ if (row.type === "worktree") {
953
+ closeAttachMenu();
954
+ setWorktreeMode(!worktreeMode);
955
+ return void input.focus();
956
+ }
957
+ if (row.type === "plan") {
958
+ closeAttachMenu();
959
+ togglePlanMode();
960
+ return void input.focus();
961
+ }
962
+ if (row.type === "computer") {
963
+ closeAttachMenu();
964
+ setComputerUse(!row.on);
965
+ return void input.focus();
555
966
  }
556
- if (row.type === "command") return void insertCommand(row.command);
557
967
  if (attachView === "mention" && row.ref) {
558
968
  return void insertMention(row.ref.value);
559
969
  }
@@ -579,7 +989,14 @@ async function pickFromDisk(kind) {
579
989
  const picked = await api("/pick", kind === "folder" ? {} : { kind: "files" }, 5 * 60_000);
580
990
  // A dismissed dialog is an answer, and the only one that says nothing.
581
991
  if (picked.cancelled) return;
582
- if (picked.unavailable || picked.error) return void addNotice(t("add.noDialog"), "err");
992
+ // No dialog on this machine (a browser on another device, a desktop with no
993
+ // picker): the browser's own file chooser, which carries the bytes. It was a
994
+ // row of its own, "Upload from computer", beside this one — two rows for
995
+ // one thing, told apart only by what happened afterwards.
996
+ if (picked.unavailable || picked.error) {
997
+ if (kind === "folder") return void addNotice(t("add.noDialog"), "err");
998
+ return void fileInput.click();
999
+ }
583
1000
 
584
1001
  if (kind === "folder") {
585
1002
  // Relative when it is inside the project, which is shorter to read and is
@@ -618,11 +1035,12 @@ async function pickFromDisk(kind) {
618
1035
  * something the server had already forgotten.
619
1036
  */
620
1037
  async function withPreview(file) {
621
- if (file.kind !== "image") return file;
1038
+ const video = String(file.mediaType || "").startsWith("video/");
1039
+ if (file.kind !== "image" && !video) return file;
622
1040
  try {
623
1041
  const response = await fetch(q("/upload/preview?id=" + encodeURIComponent(file.id)));
624
1042
  if (!response.ok) return file;
625
- return { ...file, preview: URL.createObjectURL(await response.blob()) };
1043
+ return { ...file, preview: URL.createObjectURL(await response.blob()), ...(video ? { previewKind: "video" } : {}) };
626
1044
  } catch {
627
1045
  // A chip without a thumbnail is still a chip; nothing here is worth a
628
1046
  // notice the person cannot act on.
@@ -929,7 +1347,7 @@ async function installConnector(id, remove) {
929
1347
  addNotice(body.error, "err");
930
1348
  return void paintMarket();
931
1349
  }
932
- if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [] };
1350
+ if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [], agents: body.agents || [] };
933
1351
  for (const entry of market) if (entry.id === id) entry.installed = remove !== true;
934
1352
  paintMarket();
935
1353
  }
@@ -1057,7 +1475,7 @@ function moveAttachCursor(e) {
1057
1475
  let next = attachCursor;
1058
1476
  for (let i = 0; i < rows.length; i++) {
1059
1477
  next = (next + step + rows.length) % rows.length;
1060
- if (rows[next].type !== "group") break;
1478
+ if (!notChoice(rows[next])) break;
1061
1479
  }
1062
1480
  attachCursor = next;
1063
1481
  paintAttachMenu();
@@ -1137,6 +1555,14 @@ input.addEventListener("keydown", (e) => {
1137
1555
  * what a reader is checking when they glance at it.
1138
1556
  */
1139
1557
  function paintMirror() {
1558
+ paintMarks();
1559
+ // Every caller has just rewritten the text, and a rewrite from code — the
1560
+ // box emptied on send, a command inserted — fires no input event, so the
1561
+ // drawn caret would stay where the old text put it.
1562
+ placeCaret();
1563
+ }
1564
+
1565
+ function paintMarks() {
1140
1566
  if (!inputMirror) return;
1141
1567
  // Typing an @ asks for the list, but text can also arrive whole — pasted, or
1142
1568
  // restored as a draft — and that text has mentions in it too. Guarded, so
@@ -1144,7 +1570,10 @@ function paintMirror() {
1144
1570
  ensureOffer();
1145
1571
  const names = mentionNames();
1146
1572
  inputMirror.replaceChildren();
1147
- if (!names.size) return void (inputMirror.textContent = input.value);
1573
+ if (!names.size) {
1574
+ inputMirror.textContent = input.value;
1575
+ return void paintGhost();
1576
+ }
1148
1577
 
1149
1578
  const text = input.value;
1150
1579
  let at = 0;
@@ -1165,6 +1594,7 @@ function paintMirror() {
1165
1594
  }
1166
1595
  inputMirror.appendChild(document.createTextNode(text.slice(at)));
1167
1596
  if (inputMirror.querySelector("mark")) inputMirror.style.setProperty("--at-w", atWidth() + "px");
1597
+ paintGhost();
1168
1598
  }
1169
1599
 
1170
1600
  // --- the caret ------------------------------------------------------------
@@ -1227,7 +1657,10 @@ for (const event of ["input", "focus", "blur", "scroll"]) {
1227
1657
  input.addEventListener(event, placeCaret);
1228
1658
  }
1229
1659
  document.addEventListener("selectionchange", () => {
1230
- if (document.activeElement === input) placeCaret();
1660
+ if (document.activeElement !== input) return;
1661
+ placeCaret();
1662
+ // The rest of a guess is only offered from the end of the text.
1663
+ paintGhost();
1231
1664
  });
1232
1665
  window.addEventListener("resize", placeCaret);
1233
1666
 
@@ -1310,6 +1743,24 @@ const COMMANDS = [
1310
1743
  return void api("/routine", { name: arg }).then(pushNotice);
1311
1744
  },
1312
1745
  },
1746
+ {
1747
+ name: "goal",
1748
+ arg: "goal",
1749
+ hint: () => t("cmd.goal"),
1750
+ run: (arg) => {
1751
+ // Bare, it switches the composer into goal mode for the next message;
1752
+ // "clear" lets go of the one being worked toward.
1753
+ if (!arg) return void setGoalMode(true);
1754
+ if (/^(clear|off|stop)$/i.test(arg)) return void api("/goal", { session: activeId }).then(applyState);
1755
+ sendGoal(arg);
1756
+ },
1757
+ },
1758
+ {
1759
+ name: "compact",
1760
+ arg: "",
1761
+ hint: () => t("cmd.compact"),
1762
+ run: () => compactConversation(),
1763
+ },
1313
1764
  {
1314
1765
  name: "new",
1315
1766
  arg: "",
@@ -1318,6 +1769,18 @@ const COMMANDS = [
1318
1769
  },
1319
1770
  ];
1320
1771
 
1772
+ /** /compact, and the context panel's button: summarize the conversation now. */
1773
+ function compactConversation() {
1774
+ addNotice(t("cmd.compacting"), "notice");
1775
+ // A summary is a model call over the whole conversation, well past the
1776
+ // half-minute a settings toggle is given.
1777
+ api("/compact", { session: activeId }, 180000).then((body) => {
1778
+ if (body && body.error) return addNotice(body.error, "notice err");
1779
+ // A compaction posts its own notice; only the nothing-to-do case needs one.
1780
+ if (body && body.compacted === false) addNotice(t("cmd.nothingToCompact"), "notice");
1781
+ });
1782
+ }
1783
+
1321
1784
  function pushNotice(body) {
1322
1785
  if (body && body.error) addNotice(body.error, "notice err");
1323
1786
  }
@@ -1346,6 +1809,23 @@ function openCommands(command) {
1346
1809
  paintAttachMenu();
1347
1810
  }
1348
1811
 
1812
+ /**
1813
+ * A command chosen from the menu.
1814
+ *
1815
+ * One that takes nothing runs at once: inserting "/new " only to need a second
1816
+ * Enter is a step that decides nothing. One that takes an argument is written
1817
+ * into the box, ready for it.
1818
+ */
1819
+ function pickCommand(name) {
1820
+ const command = COMMANDS.find((one) => one.name === name);
1821
+ if (!command || command.arg) return void insertCommand(name);
1822
+ input.value = "";
1823
+ autosize();
1824
+ paintMirror();
1825
+ closeAttachMenu();
1826
+ command.run("");
1827
+ }
1828
+
1349
1829
  /** Insert the command, with the space its argument starts after. */
1350
1830
  function insertCommand(name) {
1351
1831
  const rest = input.value.slice(input.selectionStart);
@@ -1463,7 +1943,12 @@ input.addEventListener("input", () => {
1463
1943
  // Before the composer's own Enter handling, which would otherwise send the
1464
1944
  // message out from under a menu the user was choosing from.
1465
1945
  input.addEventListener("keydown", (e) => {
1466
- if (attachView !== "mention" || attachMenu.hidden) return;
1946
+ if ((attachView !== "mention" && attachView !== "command") || attachMenu.hidden) return;
1947
+ // A slash word no command starts with is prose — /etc, and/or — so with
1948
+ // nothing to pick, Enter goes on to send it rather than choosing nothing.
1949
+ if (attachView === "command" && (e.key === "Enter" || e.key === "Tab") && !attachRows().length) {
1950
+ return void closeAttachMenu();
1951
+ }
1467
1952
  if (e.key === "Escape") {
1468
1953
  e.stopImmediatePropagation();
1469
1954
  return void closeAttachMenu();
@@ -1524,23 +2009,43 @@ document.getElementById("composer").addEventListener("submit", (e) => {
1524
2009
  const ids = staged.map((file) => file.id);
1525
2010
  const shots = staged
1526
2011
  .filter((file) => file.preview)
1527
- .map((file) => ({ src: file.preview, name: file.name }));
2012
+ .map((file) => ({ src: file.preview, name: file.name, kind: file.previewKind || "image" }));
1528
2013
  if (shots.length) sentPreviews.set(activeId, shots);
1529
2014
  const referenced = refs.map((ref) => ({ kind: ref.kind, value: ref.value }));
2015
+ const asGoal = goalMode;
2016
+ const asWorktree = worktreeMode;
2017
+ if (asGoal && !text) return void addNotice(t("goal.needsText"), "notice err");
2018
+ if (asWorktree && !text) return void addNotice(t("worktree.placeholder"), "notice err");
1530
2019
  input.value = "";
1531
2020
  autosize();
1532
2021
  paintMirror();
1533
2022
  staged = [];
1534
2023
  refs = [];
2024
+ if (asGoal) setGoalMode(false);
2025
+ if (asWorktree) setWorktreeMode(false);
1535
2026
  renderAttachments();
1536
2027
  // The counts, never the text, the file names or the paths.
1537
- track("prompt_sent", { attachments: ids.length, references: referenced.length });
2028
+ track("prompt_sent", { attachments: ids.length, references: referenced.length, goal: asGoal, worktree: asWorktree });
2029
+ if (asWorktree) {
2030
+ // From the project the tab on screen is in, named after the first line.
2031
+ const here = panes.get(activeId);
2032
+ const task = text.split("\n")[0].trim().slice(0, 80);
2033
+ return void startInWorktree({
2034
+ ...(here && here.cwd ? { cwd: here.cwd } : {}),
2035
+ task,
2036
+ prompt: text,
2037
+ attachments: ids,
2038
+ references: referenced,
2039
+ ...(asGoal ? { goal: true } : {}),
2040
+ });
2041
+ }
1538
2042
  void api("/prompt", {
1539
2043
  session: activeId,
1540
2044
  prompt: text,
1541
2045
  attachments: ids,
1542
2046
  references: referenced,
1543
- });
2047
+ ...(asGoal ? { goal: true } : {}),
2048
+ }).then(pushNotice);
1544
2049
  });
1545
2050
 
1546
2051
  input.addEventListener("keydown", (e) => {
@@ -1552,7 +2057,82 @@ input.addEventListener("keydown", (e) => {
1552
2057
  input.addEventListener("input", () => {
1553
2058
  autosize();
1554
2059
  paintMirror();
1555
- placeCaret();
2060
+ paintSuggestion();
2061
+ });
2062
+
2063
+ // --- the next-message guess -----------------------------------------------
2064
+
2065
+ /**
2066
+ * The server's guess at what this conversation's user types next, drawn as
2067
+ * the empty box's placeholder and taken whole with Tab.
2068
+ *
2069
+ * Held per pane, so a guess written for a background tab waits there rather
2070
+ * than showing on the one on screen. Shown only where it can be acted on: an
2071
+ * idle conversation, nothing typed, and no goal or worktree mode, whose own
2072
+ * placeholder says what the next message will be sent as.
2073
+ */
2074
+ function suggestionNow() {
2075
+ const here = panes.get(activeId);
2076
+ if (!here || here.busy || staged.length || refs.length || goalMode || worktreeMode) return "";
2077
+ return here.suggestion || "";
2078
+ }
2079
+
2080
+ /** The whole guess, while the box is empty. */
2081
+ function suggestionHere() {
2082
+ return input.value ? "" : suggestionNow();
2083
+ }
2084
+
2085
+ /**
2086
+ * What is left of the guess once some of it has been typed: the text so far
2087
+ * has to be its start, letter for letter, and the caret at the end with
2088
+ * nothing selected — anywhere else, Tab finishing a sentence the caret is not
2089
+ * at would put words somewhere the user is not looking.
2090
+ */
2091
+ function ghostTail() {
2092
+ const typed = input.value;
2093
+ const guess = suggestionNow();
2094
+ if (!typed || !guess || guess.length <= typed.length || !guess.startsWith(typed)) return "";
2095
+ if (input.selectionStart !== typed.length || input.selectionEnd !== typed.length) return "";
2096
+ return guess.slice(typed.length);
2097
+ }
2098
+
2099
+ /**
2100
+ * Draw the rest of the guess after the text, on the mirror under the field.
2101
+ * The mirror lays out exactly as the field does, so the tail starts where the
2102
+ * last typed glyph ends.
2103
+ */
2104
+ function paintGhost() {
2105
+ if (!inputMirror) return;
2106
+ const old = inputMirror.querySelector(".ghost");
2107
+ if (old) old.remove();
2108
+ const tail = ghostTail();
2109
+ if (tail) inputMirror.appendChild(el("span", "ghost", tail));
2110
+ }
2111
+
2112
+ function paintSuggestion() {
2113
+ const guess = suggestionHere();
2114
+ input.placeholder = guess || inputPlaceholder();
2115
+ input.classList.toggle("suggesting", !!guess);
2116
+ paintGhost();
2117
+ }
2118
+
2119
+ input.addEventListener("keydown", (e) => {
2120
+ if ((e.key !== "Tab" && e.key !== "Escape") || e.shiftKey || e.altKey || e.metaKey || e.ctrlKey || e.isComposing) return;
2121
+ const tail = ghostTail();
2122
+ const guess = tail ? input.value + tail : suggestionHere();
2123
+ if (!guess) return;
2124
+ e.preventDefault();
2125
+ e.stopImmediatePropagation();
2126
+ if (e.key === "Escape") {
2127
+ panes.get(activeId).suggestion = null;
2128
+ paintGhost();
2129
+ return void paintSuggestion();
2130
+ }
2131
+ input.value = guess;
2132
+ input.setSelectionRange(guess.length, guess.length);
2133
+ autosize();
2134
+ paintMirror();
2135
+ paintSuggestion();
1556
2136
  });
1557
2137
 
1558
2138
  // --- the field's height ---------------------------------------------------
@@ -1722,5 +2302,168 @@ if (window.visualViewport) {
1722
2302
  window.addEventListener("resize", followKeyboard);
1723
2303
  }
1724
2304
 
2305
+ // ── Dictation ────────────────────────────────────────────────────────────
2306
+ // The mic records in the browser (MediaRecorder), the server turns it into
2307
+ // text (POST /voice/transcribe: the account's gateway, or the user's own
2308
+ // OpenAI key), and the words go into the box at the end of what is there.
2309
+ // Then POST /voice/tidy cleans them up — on a paid plan, or with the user's
2310
+ // own small model — and swaps the clean version in, unless the box was
2311
+ // touched meanwhile. Five minutes at most.
2312
+ //
2313
+ // What is going on shows in a panel floating above the box by the mic:
2314
+ // listening (the voice as bars, the time, cancel and done), working
2315
+ // (transcribing, tidying), or what went wrong. Esc cancels, Enter finishes.
2316
+ const micBtn = document.getElementById("mic");
2317
+ const pop = document.getElementById("dictate-pop");
2318
+ const popText = pop.querySelector(".dp-text");
2319
+ const popTime = pop.querySelector(".dp-time");
2320
+ const popWave = pop.querySelector(".dp-wave");
2321
+ let dictation = null;
2322
+ let popTimer = 0;
2323
+
2324
+ function dictateShow(state, text) {
2325
+ clearTimeout(popTimer);
2326
+ if (!state) { pop.hidden = true; pop.removeAttribute("data-state"); return; }
2327
+ pop.dataset.state = state;
2328
+ popText.textContent = text || "";
2329
+ pop.hidden = false;
2330
+ if (state === "error") popTimer = setTimeout(() => dictateShow(""), 8000);
2331
+ }
2332
+
2333
+ /** The voice, as bars: an analyser on the microphone, drawn every frame. */
2334
+ function drawWave(session) {
2335
+ const ctx = popWave.getContext("2d");
2336
+ const ratio = window.devicePixelRatio || 1;
2337
+ popWave.width = 168 * ratio; popWave.height = 32 * ratio;
2338
+ const bins = new Uint8Array(session.analyser.fftSize);
2339
+ const bars = 28;
2340
+ const history = new Array(bars).fill(0);
2341
+ const color = getComputedStyle(popWave).color;
2342
+ const frame = () => {
2343
+ if (dictation !== session) return;
2344
+ session.analyser.getByteTimeDomainData(bins);
2345
+ let peak = 0;
2346
+ for (const value of bins) peak = Math.max(peak, Math.abs(value - 128) / 128);
2347
+ history.shift(); history.push(Math.min(1, peak * 2.4));
2348
+ ctx.clearRect(0, 0, popWave.width, popWave.height);
2349
+ ctx.fillStyle = color;
2350
+ const step = popWave.width / bars, width = Math.max(2 * ratio, step * 0.45);
2351
+ history.forEach((level, index) => {
2352
+ const height = Math.max(3 * ratio, level * popWave.height);
2353
+ ctx.globalAlpha = 0.35 + 0.65 * (index / bars);
2354
+ ctx.fillRect(index * step + (step - width) / 2, (popWave.height - height) / 2, width, height);
2355
+ });
2356
+ session.frame = requestAnimationFrame(frame);
2357
+ };
2358
+ frame();
2359
+ }
2360
+
2361
+ async function startDictation() {
2362
+ // The first time, macOS asks before the browser answers, and the mic would
2363
+ // sit there looking dead until someone clicks Allow: say so meanwhile.
2364
+ dictateShow("working", t("dictate.opening"));
2365
+ micBtn.disabled = true;
2366
+ let stream;
2367
+ try {
2368
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
2369
+ } catch {
2370
+ micBtn.disabled = false;
2371
+ return dictateShow("error", t("dictate.denied"));
2372
+ }
2373
+ micBtn.disabled = false;
2374
+ const mimeType = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]
2375
+ .find((type) => MediaRecorder.isTypeSupported(type));
2376
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
2377
+ const chunks = [];
2378
+ recorder.ondataavailable = (event) => { if (event.data.size) chunks.push(event.data); };
2379
+ const audio = new AudioContext();
2380
+ const analyser = audio.createAnalyser();
2381
+ analyser.fftSize = 512;
2382
+ audio.createMediaStreamSource(stream).connect(analyser);
2383
+ const session = { recorder, stream, chunks, audio, analyser, startedAt: Date.now(), timer: 0, frame: 0, cancelled: false };
2384
+ recorder.onstop = () => finishDictation(session);
2385
+ dictation = session;
2386
+ recorder.start(1000);
2387
+ micBtn.classList.add("is-recording");
2388
+ micBtn.setAttribute("aria-pressed", "true");
2389
+ micBtn.title = t("composer.dictateStop");
2390
+ dictateShow("listening", t("dictate.listening"));
2391
+ const tick = () => {
2392
+ const seconds = Math.floor((Date.now() - session.startedAt) / 1000);
2393
+ popTime.textContent = Math.floor(seconds / 60) + ":" + String(seconds % 60).padStart(2, "0");
2394
+ if (seconds >= 300) stopDictation(false);
2395
+ };
2396
+ tick();
2397
+ session.timer = setInterval(tick, 500);
2398
+ drawWave(session);
2399
+ }
2400
+
2401
+ /** Done (keep what was said) or cancelled (throw it away). */
2402
+ function stopDictation(cancel) {
2403
+ const session = dictation;
2404
+ if (!session) return;
2405
+ dictation = null;
2406
+ session.cancelled = cancel;
2407
+ clearInterval(session.timer);
2408
+ cancelAnimationFrame(session.frame);
2409
+ if (session.recorder.state !== "inactive") session.recorder.stop();
2410
+ session.stream.getTracks().forEach((track) => track.stop());
2411
+ session.audio.close().catch(() => {});
2412
+ micBtn.classList.remove("is-recording");
2413
+ micBtn.setAttribute("aria-pressed", "false");
2414
+ micBtn.title = t("composer.dictate");
2415
+ if (cancel) dictateShow("");
2416
+ }
2417
+
2418
+ async function finishDictation(session) {
2419
+ if (session.cancelled) return;
2420
+ const duration = (Date.now() - session.startedAt) / 1000;
2421
+ const blob = new Blob(session.chunks, { type: session.recorder.mimeType || "audio/webm" });
2422
+ if (duration < 0.6 || !blob.size) return dictateShow("");
2423
+ const format = /mp4/.test(blob.type) ? "m4a" : /ogg/.test(blob.type) ? "ogg" : "webm";
2424
+ const audio = await new Promise((resolve) => {
2425
+ const reader = new FileReader();
2426
+ reader.onload = () => resolve(String(reader.result).replace(/^data:[^,]*,/, ""));
2427
+ reader.readAsDataURL(blob);
2428
+ });
2429
+ micBtn.disabled = true;
2430
+ dictateShow("working", t("dictate.transcribing"));
2431
+ const heard = await api("/voice/transcribe", { audio, format, duration, language: navigator.language || "" }, 90_000);
2432
+ micBtn.disabled = false;
2433
+ const words = heard && typeof heard.text === "string" ? heard.text.trim() : "";
2434
+ if (!words) {
2435
+ if (heard && heard.code === "sign_in_required") return dictateShow("error", t("dictate.signIn"));
2436
+ return heard && heard.error ? dictateShow("error", t("dictate.failed", { error: heard.error })) : dictateShow("");
2437
+ }
2438
+ const before = input.value;
2439
+ const lead = before && !/\s$/.test(before) ? " " : "";
2440
+ const placed = before + lead + words;
2441
+ input.value = placed;
2442
+ input.dispatchEvent(new Event("input"));
2443
+ input.focus();
2444
+ input.setSelectionRange(placed.length, placed.length);
2445
+ dictateShow("working", t("dictate.tidying"));
2446
+ const tidy = await api("/voice/tidy", { text: words, language: navigator.language || "" }, 20_000);
2447
+ dictateShow("");
2448
+ if (tidy && tidy.tidied === true && typeof tidy.text === "string" && input.value === placed) {
2449
+ input.value = before + lead + tidy.text;
2450
+ input.dispatchEvent(new Event("input"));
2451
+ input.setSelectionRange(input.value.length, input.value.length);
2452
+ }
2453
+ }
2454
+
2455
+ if (micBtn) {
2456
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia || !window.MediaRecorder) micBtn.hidden = true;
2457
+ micBtn.addEventListener("click", () => (dictation ? stopDictation(false) : startDictation()));
2458
+ pop.querySelector(".dp-cancel").addEventListener("click", () => stopDictation(true));
2459
+ pop.querySelector(".dp-done").addEventListener("click", () => stopDictation(false));
2460
+ pop.querySelector(".dp-close").addEventListener("click", () => dictateShow(""));
2461
+ // While listening, the keyboard ends it: Esc throws it away, Enter keeps it.
2462
+ document.addEventListener("keydown", (event) => {
2463
+ if (!dictation) return;
2464
+ if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); stopDictation(true); }
2465
+ else if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); event.stopPropagation(); stopDictation(false); }
2466
+ }, true);
2467
+ }
1725
2468
  `;
1726
2469
  //# sourceMappingURL=composer.js.map