@oharness/server 0.8.0 → 0.9.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.
@@ -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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkrBnC,CAAC"}
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"}