@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.
- package/dist/analytics.d.ts +32 -29
- package/dist/analytics.js +225 -278
- package/dist/analytics.js.map +1 -1
- package/dist/i18n.js +500 -64
- package/dist/i18n.js.map +1 -1
- package/dist/icons.js +1 -0
- package/dist/icons.js.map +1 -1
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -1
- package/dist/model-order.js +1 -0
- package/dist/model-order.js.map +1 -1
- package/dist/page/client/account.js +570 -27
- package/dist/page/client/account.js.map +1 -1
- package/dist/page/client/base.js +55 -12
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +1 -1
- package/dist/page/client/composer.js +802 -59
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/computer.d.ts +27 -0
- package/dist/page/client/computer.js +358 -0
- package/dist/page/client/computer.js.map +1 -0
- package/dist/page/client/dialogs.js +168 -6
- package/dist/page/client/dialogs.js.map +1 -1
- package/dist/page/client/events.js +119 -9
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/files.js +86 -1
- package/dist/page/client/files.js.map +1 -1
- package/dist/page/client/index.js +8 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +14 -10
- package/dist/page/client/panels.js.map +1 -1
- package/dist/page/client/ripple.d.ts +13 -0
- package/dist/page/client/ripple.js +247 -0
- package/dist/page/client/ripple.js.map +1 -0
- package/dist/page/client/routines.js +432 -45
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +386 -104
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +278 -19
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/tasks.d.ts +8 -0
- package/dist/page/client/tasks.js +293 -0
- package/dist/page/client/tasks.js.map +1 -0
- package/dist/page/client/terminal.js +34 -7
- package/dist/page/client/terminal.js.map +1 -1
- package/dist/page/client/theme.d.ts +17 -0
- package/dist/page/client/theme.js +365 -0
- package/dist/page/client/theme.js.map +1 -0
- package/dist/page/client/transcript.js +196 -8
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/client/usage.js +1 -11
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +745 -76
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +94 -16
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +31 -0
- package/dist/prefs.js +13 -1
- package/dist/prefs.js.map +1 -1
- package/dist/remote-crypto.d.ts +78 -0
- package/dist/remote-crypto.js +216 -0
- package/dist/remote-crypto.js.map +1 -0
- package/dist/remote.d.ts +103 -0
- package/dist/remote.js +498 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.d.ts +70 -0
- package/dist/server.js +1738 -73
- package/dist/server.js.map +1 -1
- package/dist/starters.d.ts +92 -0
- package/dist/starters.js +213 -0
- package/dist/starters.js.map +1 -0
- package/dist/suggest.d.ts +42 -0
- package/dist/suggest.js +215 -0
- package/dist/suggest.js.map +1 -0
- package/dist/terminal.d.ts +28 -2
- package/dist/terminal.js +45 -3
- package/dist/terminal.js.map +1 -1
- package/dist/theme-assets.d.ts +39 -0
- package/dist/theme-assets.js +109 -0
- package/dist/theme-assets.js.map +1 -0
- package/dist/theme-store.d.ts +50 -0
- package/dist/theme-store.js +124 -0
- package/dist/theme-store.js.map +1 -0
- package/dist/theme-tools.d.ts +25 -0
- package/dist/theme-tools.js +243 -0
- package/dist/theme-tools.js.map +1 -0
- package/dist/turn-shots.d.ts +43 -0
- package/dist/turn-shots.js +80 -0
- package/dist/turn-shots.js.map +1 -0
- package/dist/user-theme.d.ts +143 -0
- package/dist/user-theme.js +510 -0
- package/dist/user-theme.js.map +1 -0
- 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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
//
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
//
|
|
426
|
-
//
|
|
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]
|
|
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 === "
|
|
946
|
+
if (row.type === "command") return void pickCommand(row.command);
|
|
947
|
+
if (row.type === "goal") {
|
|
553
948
|
closeAttachMenu();
|
|
554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|