@oharness/server 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-usage.d.ts +29 -0
- package/dist/account-usage.js +56 -0
- package/dist/account-usage.js.map +1 -0
- package/dist/files.d.ts +40 -1
- package/dist/files.js +94 -3
- package/dist/files.js.map +1 -1
- package/dist/i18n.js +126 -2
- package/dist/i18n.js.map +1 -1
- package/dist/page/client/base.js +4 -0
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +3 -1
- package/dist/page/client/browser.js.map +1 -1
- package/dist/page/client/composer.js +20 -0
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/files.d.ts +7 -0
- package/dist/page/client/files.js +412 -0
- package/dist/page/client/files.js.map +1 -0
- package/dist/page/client/index.js +4 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +1 -1
- package/dist/page/client/routines.js +19 -1
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +12 -1
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +12 -4
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/terminal.d.ts +7 -0
- package/dist/page/client/terminal.js +347 -0
- package/dist/page/client/terminal.js.map +1 -0
- package/dist/page/client/transcript.js +167 -0
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/client/usage.js +400 -70
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +258 -3
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +35 -1
- package/dist/page.js.map +1 -1
- package/dist/server.js +238 -31
- package/dist/server.js.map +1 -1
- package/dist/terminal.d.ts +34 -0
- package/dist/terminal.js +211 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +9 -8
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The embedded terminal: a real shell per project, drawn by xterm.js in a
|
|
3
|
+
* panel under the conversation.
|
|
4
|
+
*
|
|
5
|
+
* One part of the client script; see ./index.ts for how the parts join.
|
|
6
|
+
*/
|
|
7
|
+
export const TERMINAL = String.raw `
|
|
8
|
+
// --- terminal ------------------------------------------------------------------
|
|
9
|
+
// The shell lives on the server (terminal.ts) and outlives this panel: hiding
|
|
10
|
+
// it, switching tabs or reloading the page all leave it running, and coming
|
|
11
|
+
// back reattaches with the recent output replayed.
|
|
12
|
+
|
|
13
|
+
const terminalPanel = document.getElementById("terminal");
|
|
14
|
+
const terminalBody = document.getElementById("terminal-body");
|
|
15
|
+
const terminalTabs = document.getElementById("terminal-tabs");
|
|
16
|
+
const terminalWhere = document.getElementById("terminal-where");
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per project folder, its terminals as tabs: each entry holds the xterm
|
|
20
|
+
* instance, its element and its stream, and the group remembers which tab
|
|
21
|
+
* was in front so coming back to the project finds the same one.
|
|
22
|
+
*/
|
|
23
|
+
const terminalGroups = new Map();
|
|
24
|
+
/** The folder whose terminals are on show. */
|
|
25
|
+
let terminalCwd = null;
|
|
26
|
+
let terminalLib = null;
|
|
27
|
+
|
|
28
|
+
const TERMINAL_HEIGHT_KEY = "oharness.terminalHeight";
|
|
29
|
+
const TERMINAL_MIN = 120;
|
|
30
|
+
|
|
31
|
+
function terminalShownFor(cwd) {
|
|
32
|
+
return !terminalPanel.hidden && terminalCwd === cwd;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** xterm.js, fetched the first time a terminal opens rather than with the page. */
|
|
36
|
+
function loadTerminalLib() {
|
|
37
|
+
terminalLib ??= new Promise((resolve, reject) => {
|
|
38
|
+
const css = document.createElement("link");
|
|
39
|
+
css.rel = "stylesheet";
|
|
40
|
+
css.href = q("/vendor/xterm.css");
|
|
41
|
+
document.head.appendChild(css);
|
|
42
|
+
const script = (src) => new Promise((done, fail) => {
|
|
43
|
+
const node = document.createElement("script");
|
|
44
|
+
node.src = q(src);
|
|
45
|
+
node.onload = done;
|
|
46
|
+
node.onerror = () => fail(new Error("could not load " + src));
|
|
47
|
+
document.head.appendChild(node);
|
|
48
|
+
});
|
|
49
|
+
script("/vendor/xterm.js")
|
|
50
|
+
.then(() => script("/vendor/xterm-fit.js"))
|
|
51
|
+
.then(() => resolve({ Terminal: window.Terminal, FitAddon: window.FitAddon.FitAddon }))
|
|
52
|
+
.catch((err) => { terminalLib = null; reject(err); });
|
|
53
|
+
});
|
|
54
|
+
return terminalLib;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The page's own colours, read when a terminal is made. */
|
|
58
|
+
function terminalTheme() {
|
|
59
|
+
const css = getComputedStyle(document.body);
|
|
60
|
+
const v = (name, fallback) => css.getPropertyValue(name).trim() || fallback;
|
|
61
|
+
return {
|
|
62
|
+
background: v("--bg", "#111"),
|
|
63
|
+
foreground: v("--fg", "#ddd"),
|
|
64
|
+
cursor: v("--fg", "#ddd"),
|
|
65
|
+
cursorAccent: v("--bg", "#111"),
|
|
66
|
+
selectionBackground: v("--sel", "#444"),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function terminalGroup(cwd) {
|
|
71
|
+
let group = terminalGroups.get(cwd);
|
|
72
|
+
if (!group) terminalGroups.set(cwd, (group = { tabs: [], active: null, loaded: false, seq: 0 }));
|
|
73
|
+
return group;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function terminalActive() {
|
|
77
|
+
const group = terminalCwd && terminalGroups.get(terminalCwd);
|
|
78
|
+
return group ? group.active : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Show a project's terminals, starting a shell if it has none.
|
|
83
|
+
*
|
|
84
|
+
* Asked again for the project already on show it hides, which is what the
|
|
85
|
+
* tab's button wants; force keeps it open, for the menus.
|
|
86
|
+
*/
|
|
87
|
+
async function openTerminalIn(cwd, force) {
|
|
88
|
+
if (!cwd) return;
|
|
89
|
+
if (terminalShownFor(cwd) && !force) return hideTerminal();
|
|
90
|
+
track("panel_opened", { panel: "terminal" });
|
|
91
|
+
|
|
92
|
+
terminalCwd = cwd;
|
|
93
|
+
terminalWhere.textContent = projectName(cwd);
|
|
94
|
+
terminalWhere.title = cwd;
|
|
95
|
+
terminalPanel.hidden = false;
|
|
96
|
+
document.body.classList.add("terminal-open");
|
|
97
|
+
terminalApplyHeight();
|
|
98
|
+
paintTabs();
|
|
99
|
+
|
|
100
|
+
let lib;
|
|
101
|
+
try {
|
|
102
|
+
lib = await loadTerminalLib();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return void addNotice(String(err.message || err), "err");
|
|
105
|
+
}
|
|
106
|
+
if (terminalCwd !== cwd) return;
|
|
107
|
+
|
|
108
|
+
const group = terminalGroup(cwd);
|
|
109
|
+
if (!group.loaded) {
|
|
110
|
+
group.loaded = true;
|
|
111
|
+
// Shells this project already has — from before a reload, or from
|
|
112
|
+
// another window — come back as tabs rather than being started again.
|
|
113
|
+
const body = await getJson("/term/list?cwd=" + encodeURIComponent(cwd));
|
|
114
|
+
for (const found of body.terminals || []) {
|
|
115
|
+
const entry = terminalCreate(lib, cwd);
|
|
116
|
+
void terminalAttach(entry, found, true);
|
|
117
|
+
}
|
|
118
|
+
if (!group.tabs.length) await terminalNew(cwd);
|
|
119
|
+
else group.active = group.tabs[group.tabs.length - 1];
|
|
120
|
+
} else if (!group.tabs.length) {
|
|
121
|
+
await terminalNew(cwd);
|
|
122
|
+
}
|
|
123
|
+
if (terminalCwd === cwd) terminalSelect(group.active);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function hideTerminal() {
|
|
127
|
+
terminalPanel.hidden = true;
|
|
128
|
+
document.body.classList.remove("terminal-open");
|
|
129
|
+
paintTabs();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Keep the panel on the project of the tab in front. */
|
|
133
|
+
function terminalFollow(cwd) {
|
|
134
|
+
if (!terminalPanel.hidden && cwd && cwd !== terminalCwd) void openTerminalIn(cwd, true);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Another shell in the project on show, as a new tab in front. */
|
|
138
|
+
async function terminalNew(cwd) {
|
|
139
|
+
const lib = await loadTerminalLib();
|
|
140
|
+
const entry = terminalCreate(lib, cwd);
|
|
141
|
+
terminalGroup(cwd).active = entry;
|
|
142
|
+
terminalSelect(entry);
|
|
143
|
+
await terminalStart(entry);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function terminalCreate(lib, cwd) {
|
|
147
|
+
const group = terminalGroup(cwd);
|
|
148
|
+
const host = el("div", "terminal-host");
|
|
149
|
+
host.hidden = true;
|
|
150
|
+
terminalBody.appendChild(host);
|
|
151
|
+
const term = new lib.Terminal({
|
|
152
|
+
fontFamily: 'ui-monospace, "SF Mono", Menlo, Consolas, "Cascadia Mono", monospace',
|
|
153
|
+
fontSize: isPhone() ? 12 : 13,
|
|
154
|
+
lineHeight: 1.15,
|
|
155
|
+
cursorBlink: true,
|
|
156
|
+
scrollback: 5000,
|
|
157
|
+
theme: terminalTheme(),
|
|
158
|
+
});
|
|
159
|
+
const fit = new lib.FitAddon();
|
|
160
|
+
term.loadAddon(fit);
|
|
161
|
+
term.open(host);
|
|
162
|
+
const entry = {
|
|
163
|
+
cwd, host, term, fit, n: ++group.seq, name: "", id: null, source: null,
|
|
164
|
+
starting: false, exited: false, pending: "", sending: false,
|
|
165
|
+
};
|
|
166
|
+
group.tabs.push(entry);
|
|
167
|
+
|
|
168
|
+
term.onData((data) => {
|
|
169
|
+
// A shell that has exited restarts on the next key, the way a closed
|
|
170
|
+
// terminal tab is reopened by typing into it.
|
|
171
|
+
if (entry.exited) return void terminalStart(entry);
|
|
172
|
+
terminalSend(entry, data);
|
|
173
|
+
});
|
|
174
|
+
term.onResize(({ cols, rows }) => {
|
|
175
|
+
if (entry.id) void api("/term/resize", { id: entry.id, cols, rows });
|
|
176
|
+
});
|
|
177
|
+
return entry;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Bring one tab to the front of its project's panel. */
|
|
181
|
+
function terminalSelect(entry) {
|
|
182
|
+
if (!entry) return paintTerminalTabs();
|
|
183
|
+
const group = terminalGroup(entry.cwd);
|
|
184
|
+
group.active = entry;
|
|
185
|
+
for (const [cwd, other] of terminalGroups) {
|
|
186
|
+
for (const tab of other.tabs) tab.host.hidden = !(cwd === terminalCwd && tab === entry);
|
|
187
|
+
}
|
|
188
|
+
paintTerminalTabs();
|
|
189
|
+
terminalFit(entry);
|
|
190
|
+
entry.term.focus();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function paintTerminalTabs() {
|
|
194
|
+
terminalTabs.replaceChildren();
|
|
195
|
+
const group = terminalCwd && terminalGroups.get(terminalCwd);
|
|
196
|
+
if (!group) return;
|
|
197
|
+
for (const entry of group.tabs) {
|
|
198
|
+
const tab = el("div", "terminal-tab" + (entry === group.active ? " on" : ""));
|
|
199
|
+
tab.setAttribute("role", "tab");
|
|
200
|
+
tab.setAttribute("aria-selected", String(entry === group.active));
|
|
201
|
+
tab.appendChild(svgIcon("command"));
|
|
202
|
+
tab.appendChild(el("span", "terminal-tab-label", (entry.name || t("terminal.title")) + (entry.n > 1 ? " " + entry.n : "")));
|
|
203
|
+
const close = el("button", "terminal-tab-x");
|
|
204
|
+
close.type = "button";
|
|
205
|
+
close.title = t("terminal.close");
|
|
206
|
+
close.setAttribute("aria-label", t("terminal.close"));
|
|
207
|
+
close.appendChild(svgIcon("close"));
|
|
208
|
+
close.addEventListener("click", (event) => {
|
|
209
|
+
event.stopPropagation();
|
|
210
|
+
terminalClose(entry);
|
|
211
|
+
});
|
|
212
|
+
tab.appendChild(close);
|
|
213
|
+
tab.addEventListener("click", () => terminalSelect(entry));
|
|
214
|
+
// A middle click closes, as it does on a browser's tabs.
|
|
215
|
+
tab.addEventListener("auxclick", (event) => { if (event.button === 1) terminalClose(entry); });
|
|
216
|
+
terminalTabs.appendChild(tab);
|
|
217
|
+
}
|
|
218
|
+
const active = terminalTabs.querySelector(".terminal-tab.on");
|
|
219
|
+
if (active) active.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** End one shell and drop its tab; the last one closing hides the panel. */
|
|
223
|
+
function terminalClose(entry) {
|
|
224
|
+
const group = terminalGroup(entry.cwd);
|
|
225
|
+
if (entry.id) void api("/term/close", { id: entry.id });
|
|
226
|
+
if (entry.source) entry.source.close();
|
|
227
|
+
entry.id = null;
|
|
228
|
+
entry.term.dispose();
|
|
229
|
+
entry.host.remove();
|
|
230
|
+
const index = group.tabs.indexOf(entry);
|
|
231
|
+
group.tabs.splice(index, 1);
|
|
232
|
+
if (group.active === entry) group.active = group.tabs[Math.min(index, group.tabs.length - 1)] || null;
|
|
233
|
+
if (!group.tabs.length) return hideTerminal();
|
|
234
|
+
terminalSelect(group.active);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Keystrokes, in order.
|
|
239
|
+
*
|
|
240
|
+
* One request in flight at a time, with whatever was typed meanwhile sent
|
|
241
|
+
* together after it: two POSTs racing each other could land out of order, and
|
|
242
|
+
* a shell that receives "sl" for "ls" is worse than one that is a beat slow.
|
|
243
|
+
*/
|
|
244
|
+
function terminalSend(entry, data) {
|
|
245
|
+
entry.pending += data;
|
|
246
|
+
if (entry.sending || !entry.id) return;
|
|
247
|
+
entry.sending = true;
|
|
248
|
+
const flush = async () => {
|
|
249
|
+
while (entry.pending && entry.id) {
|
|
250
|
+
const chunk = entry.pending;
|
|
251
|
+
entry.pending = "";
|
|
252
|
+
await api("/term/input", { id: entry.id, data: chunk });
|
|
253
|
+
}
|
|
254
|
+
entry.sending = false;
|
|
255
|
+
};
|
|
256
|
+
void flush();
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** A shell for this tab — its first, or a new one after the last exited. */
|
|
260
|
+
async function terminalStart(entry) {
|
|
261
|
+
if (entry.starting) return;
|
|
262
|
+
entry.starting = true;
|
|
263
|
+
const replace = entry.id;
|
|
264
|
+
if (entry.source) entry.source.close();
|
|
265
|
+
entry.source = null;
|
|
266
|
+
entry.id = null;
|
|
267
|
+
if (replace) entry.term.reset();
|
|
268
|
+
terminalFit(entry);
|
|
269
|
+
const body = await api("/term/open", {
|
|
270
|
+
cwd: entry.cwd, cols: entry.term.cols, rows: entry.term.rows, ...(replace ? { replace } : {}),
|
|
271
|
+
});
|
|
272
|
+
entry.starting = false;
|
|
273
|
+
if (!body || body.error) {
|
|
274
|
+
entry.term.writeln("\x1b[31m" + ((body && body.error) || "could not start a shell") + "\x1b[0m");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
terminalAttach(entry, body, false);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Follow one server-side shell: its output in, and a resize to match. */
|
|
281
|
+
function terminalAttach(entry, info, replay) {
|
|
282
|
+
entry.id = info.id;
|
|
283
|
+
entry.name = info.name || entry.name;
|
|
284
|
+
entry.exited = false;
|
|
285
|
+
paintTerminalTabs();
|
|
286
|
+
const source = new EventSource(q("/term/stream?id=" + encodeURIComponent(info.id)));
|
|
287
|
+
entry.source = source;
|
|
288
|
+
source.onmessage = (message) => {
|
|
289
|
+
const event = JSON.parse(message.data);
|
|
290
|
+
if (event.type === "data") entry.term.write(event.data);
|
|
291
|
+
else if (event.type === "exit") {
|
|
292
|
+
entry.exited = true;
|
|
293
|
+
entry.term.writeln("\r\n\x1b[2m" + t("terminal.exited") + "\x1b[0m");
|
|
294
|
+
} else if (event.type === "closed") {
|
|
295
|
+
source.close();
|
|
296
|
+
if (entry.id === info.id) entry.id = null;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
// A reattached shell was sized for whatever window it was last drawn in.
|
|
300
|
+
if (replay) void api("/term/resize", { id: info.id, cols: entry.term.cols, rows: entry.term.rows });
|
|
301
|
+
if (entry.pending) terminalSend(entry, "");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function terminalFit(entry) {
|
|
305
|
+
if (terminalPanel.hidden || entry.host.hidden) return;
|
|
306
|
+
try { entry.fit.fit(); } catch { /* not laid out yet */ }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// --- its height ------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
/** The most the terminal may take, leaving the conversation a readable strip. */
|
|
312
|
+
function terminalMaxHeight() {
|
|
313
|
+
const main = document.getElementById("main").getBoundingClientRect().height;
|
|
314
|
+
return Math.max(TERMINAL_MIN, main - (isPhone() ? 140 : 220));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function terminalApplyHeight(px) {
|
|
318
|
+
const saved = px ?? storedSize(TERMINAL_HEIGHT_KEY);
|
|
319
|
+
if (!saved) return void terminalPanel.style.removeProperty("--terminal-height");
|
|
320
|
+
const height = clamp(saved, TERMINAL_MIN, terminalMaxHeight());
|
|
321
|
+
terminalPanel.style.setProperty("--terminal-height", height + "px");
|
|
322
|
+
return height;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let terminalDragged;
|
|
326
|
+
dragHandle(document.getElementById("terminal-resizer"), {
|
|
327
|
+
axis: "y",
|
|
328
|
+
start: () => terminalPanel.getBoundingClientRect().height,
|
|
329
|
+
move: (dx, dy, start) => { terminalDragged = terminalApplyHeight(start - dy); },
|
|
330
|
+
end: () => storeSize(TERMINAL_HEIGHT_KEY, terminalDragged),
|
|
331
|
+
reset: () => { storeSize(TERMINAL_HEIGHT_KEY); terminalApplyHeight(); },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Every size change reaches the shell as rows and columns: a drag, the window,
|
|
335
|
+
// the sidebar folding, a phone's keyboard coming up.
|
|
336
|
+
new ResizeObserver(() => {
|
|
337
|
+
const entry = terminalActive();
|
|
338
|
+
if (entry) terminalFit(entry);
|
|
339
|
+
}).observe(terminalBody);
|
|
340
|
+
window.addEventListener("resize", () => { if (!terminalPanel.hidden) terminalApplyHeight(); });
|
|
341
|
+
|
|
342
|
+
document.getElementById("terminal-hide").addEventListener("click", hideTerminal);
|
|
343
|
+
document.getElementById("terminal-new").addEventListener("click", () => {
|
|
344
|
+
if (terminalCwd) void terminalNew(terminalCwd);
|
|
345
|
+
});
|
|
346
|
+
`;
|
|
347
|
+
//# sourceMappingURL=terminal.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"terminal.js","sourceRoot":"","sources":["../../../src/page/client/terminal.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmVjC,CAAC"}
|
|
@@ -584,10 +584,174 @@ function finishTool(id, result, ms, isError) {
|
|
|
584
584
|
// A failed step is the one thing worth surfacing without a click.
|
|
585
585
|
entry.card.open = true;
|
|
586
586
|
if (entry.group) entry.group.open = true;
|
|
587
|
+
} else {
|
|
588
|
+
showSavedMedia(entry, text);
|
|
587
589
|
}
|
|
588
590
|
follow();
|
|
589
591
|
}
|
|
590
592
|
|
|
593
|
+
/**
|
|
594
|
+
* A clip as a card rather than the browser's player.
|
|
595
|
+
*
|
|
596
|
+
* The native control is a grey bar whose look the page cannot reach — it
|
|
597
|
+
* followed the colour scheme and nothing else. This draws the same few
|
|
598
|
+
* things in the page's own terms: a round play button, the file's name and
|
|
599
|
+
* where the clip is, and a bar to seek along. The audio element underneath
|
|
600
|
+
* is the real player; this is only its face. One clip plays at a time, the
|
|
601
|
+
* way a person listening to them would want.
|
|
602
|
+
*/
|
|
603
|
+
function audioCard(src, file, open) {
|
|
604
|
+
const figure = el("figure", "media-item audio");
|
|
605
|
+
const card = el("div", "audio-card");
|
|
606
|
+
const audio = el("audio");
|
|
607
|
+
audio.preload = "metadata";
|
|
608
|
+
const play = el("button", "audio-play");
|
|
609
|
+
play.type = "button";
|
|
610
|
+
play.setAttribute("aria-label", "Play");
|
|
611
|
+
play.appendChild(svgIcon("play"));
|
|
612
|
+
const meta = el("div", "audio-meta");
|
|
613
|
+
const top = el("div", "audio-top");
|
|
614
|
+
const name = el("span", "audio-name", file.slice(file.lastIndexOf("/") + 1));
|
|
615
|
+
name.title = file;
|
|
616
|
+
name.addEventListener("click", open);
|
|
617
|
+
const time = el("span", "audio-time", "0:00");
|
|
618
|
+
top.appendChild(name);
|
|
619
|
+
top.appendChild(time);
|
|
620
|
+
const track = el("div", "audio-track");
|
|
621
|
+
track.setAttribute("role", "slider");
|
|
622
|
+
track.setAttribute("aria-label", file);
|
|
623
|
+
track.tabIndex = 0;
|
|
624
|
+
const fill = el("div", "audio-fill");
|
|
625
|
+
track.appendChild(fill);
|
|
626
|
+
meta.appendChild(top);
|
|
627
|
+
meta.appendChild(track);
|
|
628
|
+
card.appendChild(play);
|
|
629
|
+
card.appendChild(meta);
|
|
630
|
+
figure.appendChild(card);
|
|
631
|
+
figure.appendChild(audio);
|
|
632
|
+
|
|
633
|
+
const clock = (seconds) => {
|
|
634
|
+
const s = Math.max(0, Math.floor(seconds || 0));
|
|
635
|
+
return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
636
|
+
};
|
|
637
|
+
const paint = () => {
|
|
638
|
+
const total = Number.isFinite(audio.duration) ? audio.duration : 0;
|
|
639
|
+
const at = audio.currentTime || 0;
|
|
640
|
+
fill.style.width = total ? Math.min(100, (at / total) * 100) + "%" : "0";
|
|
641
|
+
time.textContent = total ? clock(at) + " / " + clock(total) : clock(at);
|
|
642
|
+
track.setAttribute("aria-valuenow", String(Math.round(at)));
|
|
643
|
+
track.setAttribute("aria-valuemax", String(Math.round(total)));
|
|
644
|
+
};
|
|
645
|
+
const face = (playing) => {
|
|
646
|
+
play.replaceChildren(svgIcon(playing ? "pause" : "play"));
|
|
647
|
+
play.setAttribute("aria-label", playing ? "Pause" : "Play");
|
|
648
|
+
card.classList.toggle("playing", playing);
|
|
649
|
+
};
|
|
650
|
+
play.addEventListener("click", () => {
|
|
651
|
+
if (audio.paused) {
|
|
652
|
+
for (const other of document.querySelectorAll(".audio-card audio, .media-item.audio audio")) {
|
|
653
|
+
if (other !== audio) other.pause();
|
|
654
|
+
}
|
|
655
|
+
void audio.play();
|
|
656
|
+
} else {
|
|
657
|
+
audio.pause();
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
const seek = (fraction) => {
|
|
661
|
+
if (!Number.isFinite(audio.duration)) return;
|
|
662
|
+
audio.currentTime = Math.max(0, Math.min(1, fraction)) * audio.duration;
|
|
663
|
+
paint();
|
|
664
|
+
};
|
|
665
|
+
track.addEventListener("click", (e) => {
|
|
666
|
+
const r = track.getBoundingClientRect();
|
|
667
|
+
seek((e.clientX - r.left) / r.width);
|
|
668
|
+
});
|
|
669
|
+
track.addEventListener("keydown", (e) => {
|
|
670
|
+
if (!Number.isFinite(audio.duration)) return;
|
|
671
|
+
const step = 5 / audio.duration;
|
|
672
|
+
const now = audio.currentTime / audio.duration;
|
|
673
|
+
if (e.key === "ArrowRight") { e.preventDefault(); seek(now + step); }
|
|
674
|
+
if (e.key === "ArrowLeft") { e.preventDefault(); seek(now - step); }
|
|
675
|
+
});
|
|
676
|
+
audio.addEventListener("loadedmetadata", paint);
|
|
677
|
+
audio.addEventListener("timeupdate", paint);
|
|
678
|
+
audio.addEventListener("play", () => face(true));
|
|
679
|
+
audio.addEventListener("pause", () => face(false));
|
|
680
|
+
audio.addEventListener("ended", () => { face(false); paint(); });
|
|
681
|
+
audio.addEventListener("error", () => figure.remove());
|
|
682
|
+
audio.src = src;
|
|
683
|
+
return figure;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* What a generation made, shown in the conversation rather than named in it.
|
|
688
|
+
*
|
|
689
|
+
* The media tools save into the project and answer with where: "Saved:" and a
|
|
690
|
+
* path a line, some with a label ("layer 3 (headline): media/…"). A path is
|
|
691
|
+
* the least useful way to be told a picture exists, and the tool card it is in
|
|
692
|
+
* is folded away with the other steps — so every image, video and audio file
|
|
693
|
+
* it lists is drawn after the group, above the reply, straight from the
|
|
694
|
+
* project through the same route the files pane previews with. A file that
|
|
695
|
+
* cannot be served (outside the project, gone since) draws nothing rather than
|
|
696
|
+
* a broken frame. Clicking one opens it in the files pane.
|
|
697
|
+
*/
|
|
698
|
+
const PREVIEW_KINDS = {
|
|
699
|
+
png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", avif: "image",
|
|
700
|
+
mp4: "video", m4v: "video", mov: "video", webm: "video",
|
|
701
|
+
mp3: "audio", m4a: "audio", aac: "audio", wav: "audio", ogg: "audio", oga: "audio",
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
function savedMedia(text) {
|
|
705
|
+
const at = text.indexOf("Saved:\n");
|
|
706
|
+
if (at < 0) return [];
|
|
707
|
+
const found = [];
|
|
708
|
+
for (const line of text.slice(at + 7).split("\n")) {
|
|
709
|
+
if (!/^\s+\S/.test(line)) break;
|
|
710
|
+
// "layer 3 (headline): media/x.png" is the path after the label.
|
|
711
|
+
const file = line.trim().replace(/^.*?:\s+(?=\S+$)/, "");
|
|
712
|
+
const kind = PREVIEW_KINDS[file.slice(file.lastIndexOf(".") + 1).toLowerCase()];
|
|
713
|
+
if (kind) found.push({ file, kind });
|
|
714
|
+
}
|
|
715
|
+
return found;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function showSavedMedia(entry, text) {
|
|
719
|
+
const cwd = P && P.cwd;
|
|
720
|
+
const items = savedMedia(text);
|
|
721
|
+
if (!cwd || !items.length || !entry.group) return;
|
|
722
|
+
let strip = entry.group.mediaStrip;
|
|
723
|
+
if (!strip) {
|
|
724
|
+
strip = el("div", "media-strip");
|
|
725
|
+
entry.group.after(strip);
|
|
726
|
+
entry.group.mediaStrip = strip;
|
|
727
|
+
}
|
|
728
|
+
for (const { file, kind } of items) {
|
|
729
|
+
const src = q("/files/raw?cwd=" + encodeURIComponent(cwd) + "&path=" + encodeURIComponent(file));
|
|
730
|
+
if (kind === "audio") {
|
|
731
|
+
strip.appendChild(audioCard(src, file, () => void openExplorer(cwd, true).then(() => explorerShow(file))));
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const figure = el("figure", "media-item " + kind);
|
|
735
|
+
const media = el(kind === "image" ? "img" : kind);
|
|
736
|
+
if (kind === "image") {
|
|
737
|
+
media.alt = file;
|
|
738
|
+
media.loading = "lazy";
|
|
739
|
+
media.addEventListener("click", () => void openExplorer(cwd, true).then(() => explorerShow(file)));
|
|
740
|
+
} else {
|
|
741
|
+
media.controls = true;
|
|
742
|
+
media.preload = "metadata";
|
|
743
|
+
}
|
|
744
|
+
media.addEventListener("error", () => figure.remove());
|
|
745
|
+
media.src = src;
|
|
746
|
+
figure.appendChild(media);
|
|
747
|
+
const caption = el("figcaption", "mono", file);
|
|
748
|
+
caption.title = file;
|
|
749
|
+
caption.addEventListener("click", () => void openExplorer(cwd, true).then(() => explorerShow(file)));
|
|
750
|
+
figure.appendChild(caption);
|
|
751
|
+
strip.appendChild(figure);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
591
755
|
|
|
592
756
|
// --- tabs ------------------------------------------------------------------
|
|
593
757
|
|
|
@@ -627,6 +791,7 @@ function paintTabs() {
|
|
|
627
791
|
if (entry.busy) tab.appendChild(el("span", "dot"));
|
|
628
792
|
if (entry.cwd) tab.appendChild(el("span", "where", projectName(entry.cwd)));
|
|
629
793
|
tab.appendChild(el("span", "label", entry.title || t("session.untitled")));
|
|
794
|
+
if (entry.cwd) for (const button of tabProjectButtons(id, entry.cwd)) tab.appendChild(button);
|
|
630
795
|
tab.appendChild(browserTabButton(id));
|
|
631
796
|
|
|
632
797
|
// Icons rather than the × and + characters: a glyph sits where its font
|
|
@@ -675,6 +840,8 @@ async function focusTab(id) {
|
|
|
675
840
|
// sidebar comes with them — it groups by project and marks the session in
|
|
676
841
|
// hand, and both of those just changed.
|
|
677
842
|
if (panes.get(id)) panes.get(id).cwd = state.cwd || panes.get(id).cwd;
|
|
843
|
+
if (panes.get(id)) explorerFollow(panes.get(id).cwd);
|
|
844
|
+
if (panes.get(id)) terminalFollow(panes.get(id).cwd);
|
|
678
845
|
await loadProjects();
|
|
679
846
|
await loadSessions();
|
|
680
847
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../../src/page/client/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA
|
|
1
|
+
{"version":3,"file":"transcript.js","sourceRoot":"","sources":["../../../src/page/client/transcript.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAy1BnC,CAAC"}
|