scrollback 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. scrollback/__init__.py +8 -0
  2. scrollback/assets/icon-256.png +0 -0
  3. scrollback/assets/icon.icns +0 -0
  4. scrollback/cli.py +1139 -0
  5. scrollback/clipboard.py +34 -0
  6. scrollback/export.py +293 -0
  7. scrollback/fts.py +307 -0
  8. scrollback/highlight.py +128 -0
  9. scrollback/katexbundle.py +81 -0
  10. scrollback/launcher_install.py +209 -0
  11. scrollback/launchers/scrollback.bat +19 -0
  12. scrollback/launchers/scrollback.command +19 -0
  13. scrollback/launchers/scrollback.desktop +10 -0
  14. scrollback/launchers/scrollback.sh +12 -0
  15. scrollback/mathspan.py +180 -0
  16. scrollback/minimd.py +205 -0
  17. scrollback/models.py +135 -0
  18. scrollback/serialize.py +83 -0
  19. scrollback/serverconfig.py +66 -0
  20. scrollback/sources/__init__.py +6 -0
  21. scrollback/sources/aider.py +244 -0
  22. scrollback/sources/base.py +117 -0
  23. scrollback/sources/claudecode.py +631 -0
  24. scrollback/sources/codex.py +281 -0
  25. scrollback/sources/opencode.py +357 -0
  26. scrollback/sources/registry.py +39 -0
  27. scrollback/store.py +384 -0
  28. scrollback/termrender.py +170 -0
  29. scrollback/web/__init__.py +1 -0
  30. scrollback/web/app.py +359 -0
  31. scrollback/web/static/app.js +1245 -0
  32. scrollback/web/static/apple-touch-icon.png +0 -0
  33. scrollback/web/static/favicon.png +0 -0
  34. scrollback/web/static/favicon.svg +41 -0
  35. scrollback/web/static/index.html +75 -0
  36. scrollback/web/static/style.css +628 -0
  37. scrollback/web/static/vendor/highlight.min.js +1213 -0
  38. scrollback/web/static/vendor/hljs-dark.min.css +10 -0
  39. scrollback/web/static/vendor/hljs-light.min.css +10 -0
  40. scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  41. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  42. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  43. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  44. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  45. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  47. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  48. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  49. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  50. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  51. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  52. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  53. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  54. scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  55. scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  56. scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  57. scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  58. scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  59. scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  60. scrollback/web/static/vendor/katex/katex.min.css +1 -0
  61. scrollback/web/static/vendor/katex/katex.min.js +1 -0
  62. scrollback/web/static/vendor/marked.min.js +6 -0
  63. scrollback/web/static/vendor/purify.min.js +3 -0
  64. scrollback/webopen.py +96 -0
  65. scrollback-0.1.0.dist-info/METADATA +391 -0
  66. scrollback-0.1.0.dist-info/RECORD +69 -0
  67. scrollback-0.1.0.dist-info/WHEEL +4 -0
  68. scrollback-0.1.0.dist-info/entry_points.txt +4 -0
  69. scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1245 @@
1
+ "use strict";
2
+
3
+ // ====================================================================
4
+ // helpers
5
+ // ====================================================================
6
+
7
+ const $ = (sel) => document.querySelector(sel);
8
+ const el = (tag, props = {}, ...kids) => {
9
+ const n = document.createElement(tag);
10
+ for (const [k, v] of Object.entries(props)) {
11
+ if (k === "class") n.className = v;
12
+ else if (k === "dataset") Object.assign(n.dataset, v);
13
+ else if (k.startsWith("on")) n.addEventListener(k.slice(2), v);
14
+ else if (v !== null && v !== undefined && v !== false) n.setAttribute(k, v);
15
+ }
16
+ for (const kid of kids) {
17
+ if (kid == null) continue;
18
+ n.append(kid.nodeType ? kid : document.createTextNode(kid));
19
+ }
20
+ return n;
21
+ };
22
+
23
+ const fmtDate = (iso) => {
24
+ if (!iso) return "?";
25
+ return new Date(iso).toLocaleString(undefined, {
26
+ year: "2-digit", month: "2-digit", day: "2-digit",
27
+ hour: "2-digit", minute: "2-digit",
28
+ });
29
+ };
30
+
31
+ // Compact relative time for list rows: "3m", "2h", "5d", "3w", "4mo", "2y".
32
+ const fmtRelative = (iso) => {
33
+ if (!iso) return "?";
34
+ const then = new Date(iso).getTime();
35
+ if (Number.isNaN(then)) return "?";
36
+ const s = Math.max(0, (Date.now() - then) / 1000);
37
+ if (s < 60) return "just now";
38
+ const m = s / 60;
39
+ if (m < 60) return `${Math.floor(m)}m ago`;
40
+ const h = m / 60;
41
+ if (h < 24) return `${Math.floor(h)}h ago`;
42
+ const d = h / 24;
43
+ if (d < 7) return `${Math.floor(d)}d ago`;
44
+ if (d < 30) return `${Math.floor(d / 7)}w ago`;
45
+ if (d < 365) return `${Math.floor(d / 30)}mo ago`;
46
+ return `${Math.floor(d / 365)}y ago`;
47
+ };
48
+
49
+ const fmtTokens = (n) => {
50
+ if (n == null) return "";
51
+ if (n < 1000) return String(n);
52
+ if (n < 1e6) return (n / 1e3).toFixed(1) + "k";
53
+ return (n / 1e6).toFixed(1) + "M";
54
+ };
55
+
56
+ const baseName = (p) => (p ? p.split("/").filter(Boolean).slice(-1)[0] || p : "");
57
+
58
+ // ---- math spans (delimited LaTeX) ----------------------------------------
59
+ // Mirror of the Python `mathspan` module: detect $...$, $$...$$, \(...\), and
60
+ // \[...\] and shield them from the Markdown pass (which would otherwise mangle
61
+ // `\`, `_`, `*`, `^`). Placeholders use private-use-area sentinels that marked
62
+ // treats as inert text and DOMPurify preserves.
63
+ const MATH_PH_OPEN = "\uE000MATH";
64
+ const MATH_PH_CLOSE = "\uE001";
65
+ const MATH_PH_RE = /\uE000MATH(\d+)\uE001/g;
66
+
67
+ const _esc = (s) => s.replace(/[&<>"']/g, (c) =>
68
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
69
+
70
+ function findMathSpans(text) {
71
+ // Exclude fenced + inline code regions, then collect non-overlapping spans.
72
+ const code = [];
73
+ const fence = /^[ \t]*(`{3,}|~{3,})[\s\S]*?(?:^[ \t]*\1[ \t]*$|$(?![\s\S]))/gm;
74
+ let m;
75
+ while ((m = fence.exec(text))) code.push([m.index, m.index + m[0].length]);
76
+ const inFence = (p) => code.some(([a, b]) => a <= p && p < b);
77
+ const inlineCode = /(`+)[\s\S]+?\1/g;
78
+ while ((m = inlineCode.exec(text))) {
79
+ if (!inFence(m.index)) code.push([m.index, m.index + m[0].length]);
80
+ }
81
+ const inCode = (s, e) => code.some(([a, b]) => s < b && a < e);
82
+
83
+ const patterns = [
84
+ [/\$\$([\s\S]+?)\$\$/g, true],
85
+ [/\\\[([\s\S]+?)\\\]/g, true],
86
+ [/\\\(([\s\S]+?)\\\)/g, false],
87
+ [/\$(?!\s)([^$\n]*[^$\s])\$(?!\d)/g, false],
88
+ ];
89
+ const cands = [];
90
+ for (const [re, display] of patterns) {
91
+ re.lastIndex = 0;
92
+ while ((m = re.exec(text))) {
93
+ if (inCode(m.index, m.index + m[0].length)) continue;
94
+ cands.push({ start: m.index, end: m.index + m[0].length, body: m[1], display, raw: m[0] });
95
+ }
96
+ }
97
+ cands.sort((a, b) => a.start - b.start || (b.end - b.start) - (a.end - a.start));
98
+ const chosen = [];
99
+ let claimed = -1;
100
+ for (const c of cands) {
101
+ if (c.start >= claimed) { chosen.push(c); claimed = c.end; }
102
+ }
103
+ return chosen;
104
+ }
105
+
106
+ function protectMath(text) {
107
+ const spans = findMathSpans(text);
108
+ if (!spans.length) return { masked: text, tokens: [] };
109
+ let out = "";
110
+ let last = 0;
111
+ spans.forEach((s, i) => {
112
+ out += text.slice(last, s.start) + MATH_PH_OPEN + i + MATH_PH_CLOSE;
113
+ last = s.end;
114
+ });
115
+ out += text.slice(last);
116
+ return { masked: out, tokens: spans };
117
+ }
118
+
119
+ // Replace placeholders in the *sanitized HTML string* with per-mode markup.
120
+ function restoreMathHtml(html, tokens, mode) {
121
+ if (!tokens.length) return html;
122
+ return html.replace(MATH_PH_RE, (_, idx) => {
123
+ const s = tokens[+idx];
124
+ if (mode === "rendered") {
125
+ const cls = s.display ? "math-tex math-display" : "math-tex";
126
+ return `<span class="${cls}" data-display="${s.display}">${_esc(s.body)}</span>`;
127
+ }
128
+ if (mode === "latex") return `<code class="math-src">${_esc(s.raw)}</code>`;
129
+ return _esc(s.raw); // raw: verbatim source
130
+ });
131
+ }
132
+
133
+ // ---- markdown rendering (vendored marked + highlight.js) -----------------
134
+
135
+ let _mdReady = false;
136
+ function setupMarkdown() {
137
+ if (_mdReady || typeof marked === "undefined") return _mdReady;
138
+ marked.setOptions({
139
+ gfm: true,
140
+ breaks: true,
141
+ highlight: (code, lang) => {
142
+ if (typeof hljs === "undefined") return code;
143
+ try {
144
+ if (lang && hljs.getLanguage(lang)) return hljs.highlight(code, { language: lang }).value;
145
+ return hljs.highlightAuto(code).value;
146
+ } catch { return code; }
147
+ },
148
+ });
149
+ _mdReady = true;
150
+ return true;
151
+ }
152
+
153
+ function renderMarkdownInto(node, text) {
154
+ // Render `text` as markdown into `node`. Transcript text is UNTRUSTED (the
155
+ // model/user can write arbitrary HTML/script into a message), so the marked
156
+ // output MUST be sanitized before it touches innerHTML. We require both
157
+ // marked and DOMPurify; if either is missing we fall back to plain text so
158
+ // we never inject unsanitized HTML.
159
+ if (setupMarkdown() && typeof DOMPurify !== "undefined") {
160
+ node.classList.add("md");
161
+ // Shield delimited-math spans before Markdown so the renderer can't
162
+ // mangle them; the placeholders are restored after sanitizing.
163
+ const mode = state.math || "raw";
164
+ const { masked, tokens } = protectMath(text);
165
+ const dirty = restoreMathHtml(marked.parse(masked), tokens, mode);
166
+ node.innerHTML = DOMPurify.sanitize(dirty, {
167
+ // Allow normal markdown output but strip scripts, event handlers, and
168
+ // dangerous URI schemes. Forbid iframe/object/embed/form outright.
169
+ FORBID_TAGS: ["script", "style", "iframe", "object", "embed", "form"],
170
+ FORBID_ATTR: ["style"],
171
+ });
172
+ // Highlight any code blocks marked.highlight missed (older marked APIs).
173
+ if (typeof hljs !== "undefined") {
174
+ node.querySelectorAll("pre code:not(.hljs)").forEach((b) => {
175
+ try { hljs.highlightElement(b); } catch { /* ignore */ }
176
+ });
177
+ }
178
+ if (mode === "rendered") typesetMath(node);
179
+ } else {
180
+ node.textContent = text;
181
+ }
182
+ }
183
+
184
+ // Typeset every .math-tex placeholder under `root` with KaTeX (vendored). The
185
+ // LaTeX body lives in textContent (escaped on the way in), so it is inert
186
+ // until KaTeX reads it. Failures degrade to showing the source.
187
+ function typesetMath(root) {
188
+ if (typeof katex === "undefined") return;
189
+ root.querySelectorAll(".math-tex").forEach((node) => {
190
+ if (node.dataset.mathDone) return;
191
+ const src = node.textContent;
192
+ try {
193
+ katex.render(src, node, {
194
+ displayMode: node.dataset.display === "true",
195
+ throwOnError: false,
196
+ output: "html",
197
+ });
198
+ node.dataset.mathDone = "1";
199
+ } catch {
200
+ node.textContent = src; // leave the source visible on failure
201
+ }
202
+ });
203
+ }
204
+
205
+ const debounce = (fn, ms) => {
206
+ let t;
207
+ return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
208
+ };
209
+
210
+ async function getJSON(url) {
211
+ const r = await fetch(url);
212
+ if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
213
+ return r.json();
214
+ }
215
+
216
+ function toast(msg) {
217
+ const t = $("#toast");
218
+ t.textContent = msg;
219
+ t.hidden = false;
220
+ clearTimeout(toast._t);
221
+ toast._t = setTimeout(() => (t.hidden = true), 2200);
222
+ }
223
+
224
+ const _SRC_COLORS = {
225
+ opencode: "var(--opencode)",
226
+ claudecode: "var(--claudecode)",
227
+ codex: "var(--codex)",
228
+ aider: "var(--aider)",
229
+ };
230
+ const _SRC_SOFTS = {
231
+ opencode: "var(--opencode-soft)",
232
+ claudecode: "var(--claudecode-soft)",
233
+ codex: "var(--codex-soft)",
234
+ aider: "var(--aider-soft)",
235
+ };
236
+ const srcColor = (name) => _SRC_COLORS[name] || "var(--focus)";
237
+ const srcSoft = (name) => _SRC_SOFTS[name] || "var(--focus-soft)";
238
+
239
+ // ====================================================================
240
+ // state
241
+ // ====================================================================
242
+
243
+ const PAGE = 50; // session list page size
244
+ const MSG_PAGE = 40; // transcript message window size
245
+
246
+ const state = {
247
+ sources: [],
248
+ enabled: new Set(),
249
+ // search scope: which targets the query is matched against.
250
+ scope: { titles: true, contents: false },
251
+ query: "",
252
+ since: "",
253
+ until: "",
254
+ // list pagination
255
+ list: { offset: 0, hasMore: false, loading: false, kind: "sessions" },
256
+ // open transcript
257
+ current: null, // {source, id}
258
+ msg: { offset: 0, hasMore: false, loading: false },
259
+ reasoning: false,
260
+ tools: true,
261
+ math: "raw", // raw | latex | rendered (persisted like theme)
262
+ headAutoCollapsed: false, // transient: auto-collapse state when no manual pref
263
+ };
264
+
265
+ // ====================================================================
266
+ // theme
267
+ // ====================================================================
268
+
269
+ function applyHljsTheme(theme) {
270
+ const dark = $("#hljs-dark"), light = $("#hljs-light");
271
+ if (!dark || !light) return;
272
+ dark.disabled = theme !== "dark";
273
+ light.disabled = theme !== "light";
274
+ }
275
+ function initTheme() {
276
+ const saved = localStorage.getItem("scrollback-theme");
277
+ const theme = saved || (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark");
278
+ document.documentElement.dataset.theme = theme;
279
+ applyHljsTheme(theme);
280
+ }
281
+ function toggleTheme() {
282
+ const next = document.documentElement.dataset.theme === "light" ? "dark" : "light";
283
+ document.documentElement.dataset.theme = next;
284
+ localStorage.setItem("scrollback-theme", next);
285
+ applyHljsTheme(next);
286
+ }
287
+
288
+ // math render mode (raw | latex | rendered), persisted like the theme.
289
+ const MATH_MODES = ["raw", "latex", "rendered"];
290
+ function initMath() {
291
+ const saved = localStorage.getItem("scrollback-math");
292
+ state.math = MATH_MODES.includes(saved) ? saved : "raw";
293
+ }
294
+ function setMath(mode) {
295
+ if (!MATH_MODES.includes(mode)) return;
296
+ state.math = mode;
297
+ localStorage.setItem("scrollback-math", mode);
298
+ // Repaint any export buttons whose label reflects the math mode.
299
+ document.querySelectorAll(".btn.math-aware").forEach(
300
+ (b) => b.dispatchEvent(new CustomEvent("scrollback:math")));
301
+ rerenderMessages();
302
+ }
303
+
304
+ // ====================================================================
305
+ // sources + filter chips
306
+ // ====================================================================
307
+
308
+ async function loadSources() {
309
+ state.sources = await getJSON("/api/sources");
310
+ // Only sources with data are enabled/filterable; unavailable ones render
311
+ // greyed-out so users can see what scrollback could read.
312
+ state.sources.forEach((s) => { if (s.available) state.enabled.add(s.name); });
313
+ const wrap = $("#srcfilter");
314
+ wrap.replaceChildren(
315
+ ...state.sources.map((s) => {
316
+ if (!s.available) {
317
+ return el("button", {
318
+ class: "src-toggle src-unavailable",
319
+ disabled: "disabled",
320
+ "aria-pressed": "false",
321
+ dataset: { source: s.name },
322
+ title: `${s.label || s.name}: no sessions found on this machine`,
323
+ style: `--src:${srcColor(s.name)};--src-soft:${srcSoft(s.name)}`,
324
+ },
325
+ el("span", { class: "dot" }),
326
+ s.label || s.name
327
+ );
328
+ }
329
+ return el("button", {
330
+ class: "src-toggle",
331
+ "aria-pressed": "true",
332
+ dataset: { source: s.name },
333
+ title: s.location || s.name,
334
+ style: `--src:${srcColor(s.name)};--src-soft:${srcSoft(s.name)}`,
335
+ onclick: (e) => toggleSource(e.currentTarget, s.name),
336
+ },
337
+ el("span", { class: "dot" }),
338
+ el("span", { class: "check" }, "\u2713"),
339
+ s.label || s.name
340
+ );
341
+ })
342
+ );
343
+ }
344
+
345
+ function toggleSource(btn, name) {
346
+ if (state.enabled.has(name)) state.enabled.delete(name);
347
+ else state.enabled.add(name);
348
+ const on = state.enabled.has(name);
349
+ btn.setAttribute("aria-pressed", on ? "true" : "false");
350
+ btn.querySelector(".check").textContent = on ? "\u2713" : "";
351
+ resetAndLoad();
352
+ }
353
+
354
+ function enabledParam() {
355
+ // If exactly one source is enabled, pass it to the API for efficiency.
356
+ return state.enabled.size === 1 ? [...state.enabled][0] : null;
357
+ }
358
+
359
+ // ====================================================================
360
+ // query / search
361
+ // ====================================================================
362
+
363
+ const searchInput = $("#search-input");
364
+ const scopeTitlesBtn = $("#scope-titles");
365
+ const scopeContentsBtn = $("#scope-contents");
366
+
367
+ function updateScopeButtons() {
368
+ scopeTitlesBtn.setAttribute("aria-pressed", String(state.scope.titles));
369
+ scopeContentsBtn.setAttribute("aria-pressed", String(state.scope.contents));
370
+ // Placeholder reflects the active scope so intent is always clear.
371
+ const where =
372
+ state.scope.titles && state.scope.contents ? "titles + contents"
373
+ : state.scope.contents ? "message contents"
374
+ : "titles";
375
+ searchInput.placeholder = `search ${where}\u2026`;
376
+ }
377
+
378
+ function toggleScope(which) {
379
+ state.scope[which] = !state.scope[which];
380
+ // Never allow an empty scope; fall back to the other target.
381
+ if (!state.scope.titles && !state.scope.contents) {
382
+ state.scope[which === "titles" ? "contents" : "titles"] = true;
383
+ }
384
+ updateScopeButtons();
385
+ resetAndLoad();
386
+ }
387
+
388
+ const onSearchInput = debounce(() => {
389
+ state.query = searchInput.value.trim();
390
+ resetAndLoad();
391
+ }, 200);
392
+
393
+ // ====================================================================
394
+ // session list (paginated + infinite scroll)
395
+ // ====================================================================
396
+
397
+ const railEl = $("#rail");
398
+ const sessionsEl = $("#sessions");
399
+
400
+ function resetAndLoad() {
401
+ state.list.offset = 0;
402
+ state.list.hasMore = false;
403
+ sessionsEl.replaceChildren(el("li", { class: "loading" }, "loading\u2026"));
404
+ loadListPage(true);
405
+ }
406
+
407
+ async function loadListPage(reset = false) {
408
+ if (state.list.loading) return;
409
+ state.list.loading = true;
410
+ try {
411
+ const q = state.query;
412
+ const wantContents = state.scope.contents && q;
413
+ const wantTitles = state.scope.titles;
414
+ if (wantContents && wantTitles && q) {
415
+ await loadCombined(reset); // titles + contents
416
+ } else if (wantContents) {
417
+ await loadSearch(reset); // contents only
418
+ } else {
419
+ await loadSessions(reset); // titles only (or no query)
420
+ }
421
+ } catch (err) {
422
+ if (reset) sessionsEl.replaceChildren(el("li", { class: "loading" }, "error: " + err.message));
423
+ } finally {
424
+ state.list.loading = false;
425
+ }
426
+ }
427
+
428
+ async function loadSessions(reset) {
429
+ state.list.kind = "sessions";
430
+ const p = new URLSearchParams({ offset: String(state.list.offset), limit: String(PAGE), fold: "true" });
431
+ if (state.query) p.set("q", state.query);
432
+ if (state.since) p.set("since", state.since);
433
+ if (state.until) p.set("until", state.until);
434
+ const src = enabledParam();
435
+ if (src) p.set("source", src);
436
+
437
+ const data = await getJSON("/api/sessions?" + p.toString());
438
+ let rows = data.sessions.filter((s) => state.enabled.has(s.source));
439
+ state.list.hasMore = data.has_more;
440
+ state.list.offset += data.sessions.length;
441
+
442
+ if (reset) {
443
+ sessionsEl.replaceChildren();
444
+ $("#count").textContent = `${rows.length}${data.has_more ? "+" : ""} sessions`;
445
+ } else {
446
+ const prev = parseInt($("#count").dataset.n || "0", 10) + rows.length;
447
+ $("#count").textContent = `${prev}${data.has_more ? "+" : ""} sessions`;
448
+ }
449
+ $("#count").dataset.n = String((parseInt($("#count").dataset.n || "0", 10)) + rows.length);
450
+ rows.forEach((s) => sessionsEl.append(sessionRow(s)));
451
+ if (!sessionsEl.children.length) sessionsEl.append(emptyListNode());
452
+ }
453
+
454
+ function emptyListNode() {
455
+ // No sources at all -> onboarding help; sources present -> just no matches.
456
+ if (!state.sources.length) {
457
+ return el("li", { class: "empty-list" },
458
+ el("div", { class: "empty-list-title" }, "No AI-agent sessions found"),
459
+ el("div", { class: "empty-list-body" },
460
+ "scrollback reads, by default:"),
461
+ el("ul", { class: "empty-list-paths" },
462
+ el("li", {}, el("code", {}, "~/.local/share/opencode/opencode.db")),
463
+ el("li", {}, el("code", {}, "~/.claude/projects/"))),
464
+ el("div", { class: "empty-list-body" },
465
+ "Run ", el("code", {}, "scrollback doctor"), " to see what was detected."));
466
+ }
467
+ return el("li", { class: "loading" }, "no sessions");
468
+ }
469
+
470
+ async function loadSearch(reset) {
471
+ state.list.kind = "search";
472
+ // Search is not offset-paginated server-side; fetch a generous cap once.
473
+ if (!reset) return;
474
+ const p = new URLSearchParams({ q: state.query, limit: "200" });
475
+ if (state.since) p.set("since", state.since);
476
+ if (state.until) p.set("until", state.until);
477
+ const hits = (await getJSON("/api/search?" + p.toString()))
478
+ .filter((h) => state.enabled.has(h.source));
479
+ state.list.hasMore = false;
480
+ $("#count").textContent = `${hits.length} content matches`;
481
+ $("#count").dataset.n = String(hits.length);
482
+ sessionsEl.replaceChildren();
483
+ if (!hits.length) { sessionsEl.append(el("li", { class: "loading" }, "no matches")); return; }
484
+ hits.forEach((h) => sessionsEl.append(searchRow(h)));
485
+ }
486
+
487
+ async function loadCombined(reset) {
488
+ state.list.kind = "search"; // single-shot, no infinite scroll
489
+ if (!reset) return;
490
+ // Fetch title matches and content matches in parallel.
491
+ const sp = new URLSearchParams({ offset: "0", limit: "200", fold: "true", q: state.query });
492
+ if (state.since) sp.set("since", state.since);
493
+ if (state.until) sp.set("until", state.until);
494
+ const src = enabledParam();
495
+ if (src) sp.set("source", src);
496
+
497
+ const cp = new URLSearchParams({ q: state.query, limit: "200" });
498
+ if (state.since) cp.set("since", state.since);
499
+ if (state.until) cp.set("until", state.until);
500
+
501
+ const [titleData, contentHits] = await Promise.all([
502
+ getJSON("/api/sessions?" + sp.toString()),
503
+ getJSON("/api/search?" + cp.toString()),
504
+ ]);
505
+ const titleRows = titleData.sessions.filter((s) => state.enabled.has(s.source));
506
+ const titleIds = new Set(titleRows.map((s) => s.source + ":" + s.id));
507
+ // Drop content hits whose session already appears as a title match.
508
+ const hits = contentHits.filter(
509
+ (h) => state.enabled.has(h.source) && !titleIds.has(h.source + ":" + h.session_id)
510
+ );
511
+
512
+ state.list.hasMore = false;
513
+ $("#count").textContent = `${titleRows.length} title + ${hits.length} content`;
514
+ $("#count").dataset.n = String(titleRows.length + hits.length);
515
+ sessionsEl.replaceChildren();
516
+ if (!titleRows.length && !hits.length) {
517
+ sessionsEl.append(el("li", { class: "loading" }, "no matches"));
518
+ return;
519
+ }
520
+ if (titleRows.length) {
521
+ sessionsEl.append(el("li", { class: "group-label" }, "title matches"));
522
+ titleRows.forEach((s) => sessionsEl.append(sessionRow(s)));
523
+ }
524
+ if (hits.length) {
525
+ sessionsEl.append(el("li", { class: "group-label" }, "content matches"));
526
+ hits.forEach((h) => sessionsEl.append(searchRow(h)));
527
+ }
528
+ }
529
+
530
+ railEl.addEventListener("scroll", () => {
531
+ if (state.list.kind !== "sessions" || !state.list.hasMore || state.list.loading) return;
532
+ if (railEl.scrollTop + railEl.clientHeight >= railEl.scrollHeight - 200) {
533
+ loadListPage(false);
534
+ }
535
+ });
536
+
537
+ function sessionRow(s) {
538
+ const li = el("li", {
539
+ class: "session" + (isCurrent(s) ? " active" : ""),
540
+ style: `--src:${srcColor(s.source)}`,
541
+ dataset: { source: s.source, id: s.id },
542
+ onclick: (e) => { if (e.target.closest(".s-children-toggle")) return; openSession(s.source, s.id); },
543
+ },
544
+ el("div", { class: "s-title", title: s.title }, s.title || "(untitled)"),
545
+ metaLine(s)
546
+ );
547
+
548
+ if (s.children && s.children.length) {
549
+ const childWrap = el("ul", { class: "s-children", hidden: true });
550
+ s.children.forEach((c) => childWrap.append(childRow(c)));
551
+ const toggle = el("button", { class: "s-children-toggle",
552
+ onclick: () => {
553
+ const open = childWrap.hidden;
554
+ childWrap.hidden = !open;
555
+ toggle.firstChild.textContent = open ? "\u25be" : "\u25b8";
556
+ },
557
+ }, el("span", {}, "\u25b8"), ` ${s.children.length} subagent${s.children.length === 1 ? "" : "s"}`);
558
+ li.append(toggle, childWrap);
559
+ }
560
+ return li;
561
+ }
562
+
563
+ function childRow(c) {
564
+ return el("li", {
565
+ class: "s-child" + (isCurrent(c) ? " active" : ""),
566
+ style: `--src:${srcColor(c.source)}`,
567
+ dataset: { source: c.source, id: c.id },
568
+ onclick: () => openSession(c.source, c.id),
569
+ },
570
+ el("div", { class: "s-title", title: c.title }, c.title || "(untitled)"),
571
+ metaLine(c)
572
+ );
573
+ }
574
+
575
+ function metaLine(s) {
576
+ return el("div", { class: "s-meta" },
577
+ el("span", { class: "s-src" }, s.source),
578
+ el("span", { title: fmtDate(s.updated) }, fmtRelative(s.updated)),
579
+ s.message_count != null ? el("span", {}, `${s.message_count} msgs`) : null,
580
+ s.tokens_input != null ? el("span", { class: "s-badge", title: "tokens in/out" },
581
+ `${fmtTokens(s.tokens_input)}/${fmtTokens(s.tokens_output)}`) : null,
582
+ s.directory ? el("span", { class: "s-dir", title: s.directory }, baseName(s.directory)) : null
583
+ );
584
+ }
585
+
586
+ function searchRow(h) {
587
+ return el("li", {
588
+ class: "session",
589
+ style: `--src:${srcColor(h.source)}`,
590
+ dataset: { source: h.source, id: h.session_id },
591
+ onclick: () => openSession(h.source, h.session_id, h.message_id),
592
+ },
593
+ el("div", { class: "s-title", title: h.title }, h.title || "(untitled)"),
594
+ el("div", { class: "s-meta" },
595
+ el("span", { class: "s-src" }, h.source),
596
+ el("span", {}, `[${h.role}]`),
597
+ h.tool_name ? el("span", {}, h.tool_name) : null),
598
+ snippetNode(h.snippet, state.query)
599
+ );
600
+ }
601
+
602
+ function snippetNode(snippet, q) {
603
+ const div = el("div", { class: "s-snippet" });
604
+ const lc = snippet.toLowerCase();
605
+ const ql = q.trim().toLowerCase();
606
+ let i = 0, pos = ql ? lc.indexOf(ql) : -1;
607
+ if (pos !== -1) {
608
+ while (pos !== -1) {
609
+ div.append(snippet.slice(i, pos));
610
+ div.append(el("mark", {}, snippet.slice(pos, pos + ql.length)));
611
+ i = pos + ql.length;
612
+ pos = lc.indexOf(ql, i);
613
+ }
614
+ div.append(snippet.slice(i));
615
+ } else div.append(snippet);
616
+ return div;
617
+ }
618
+
619
+ function isCurrent(s) {
620
+ return state.current && state.current.source === s.source && state.current.id === s.id;
621
+ }
622
+ function markActiveRow() {
623
+ document.querySelectorAll(".session.active, .s-child.active").forEach((n) => n.classList.remove("active"));
624
+ if (!state.current) return;
625
+ const sel = `[data-source="${CSS.escape(state.current.source)}"][data-id="${CSS.escape(state.current.id)}"]`;
626
+ document.querySelectorAll(sel).forEach((n) => n.classList.add("active"));
627
+ }
628
+
629
+ // ====================================================================
630
+ // transcript reader (meta + windowed messages)
631
+ // ====================================================================
632
+
633
+ let transcriptMeta = null;
634
+
635
+ async function openSession(source, id, focusMessageId) {
636
+ state.current = { source, id };
637
+ state.msg = { offset: 0, hasMore: false, loading: false };
638
+ state.headAutoCollapsed = false; // a freshly opened session starts expanded
639
+ const hash = `#${source}/${id}`;
640
+ if (location.hash !== hash) history.replaceState(null, "", hash);
641
+ markActiveRow();
642
+
643
+ $("#empty").hidden = true;
644
+ const t = $("#transcript");
645
+ t.hidden = false;
646
+ t.replaceChildren(el("div", { class: "loading" }, "loading transcript\u2026"));
647
+ $("#reader").scrollTop = 0;
648
+
649
+ let meta;
650
+ try {
651
+ meta = await getJSON(`/api/sessions/${enc(source)}/${enc(id)}/meta`);
652
+ } catch (err) {
653
+ t.replaceChildren(el("div", { class: "loading" }, "error: " + err.message));
654
+ return;
655
+ }
656
+ transcriptMeta = meta;
657
+ renderHeader(meta);
658
+ await loadMessages(true, focusMessageId);
659
+ }
660
+
661
+ function enc(s) { return encodeURIComponent(s); }
662
+
663
+ function renderHeader(meta) {
664
+ const t = $("#transcript");
665
+ t.style.setProperty("--src", srcColor(meta.source));
666
+ const copyId = el("button", { class: "copy-id", title: "copy session id",
667
+ onclick: () => { navigator.clipboard.writeText(meta.id); toast("session id copied"); } },
668
+ meta.short_id + " \u29c9");
669
+
670
+ // Collapse/expand toggle: frees vertical space for the transcript by hiding
671
+ // the meta / find / action rows, leaving just the title. Persisted.
672
+ const collapseBtn = el("button", { class: "head-collapse", id: "head-collapse",
673
+ title: "Collapse / expand the session header",
674
+ "aria-label": "Collapse or expand the session header",
675
+ onclick: () => toggleHeaderCollapsed() });
676
+
677
+ // Compact summary shown only while the header is collapsed, so a little
678
+ // context (source + message count) survives the collapse.
679
+ const miniMeta = el("span", { class: "t-mini-meta" },
680
+ el("span", { class: "src" }, meta.source),
681
+ el("span", {}, `${meta.message_count} msgs`));
682
+
683
+ const head = el("div", { class: "t-head" },
684
+ el("div", { class: "t-titlebar" },
685
+ el("h1", { class: "t-title" }, meta.title || "(untitled)"),
686
+ miniMeta,
687
+ collapseBtn),
688
+ el("div", { class: "t-meta" },
689
+ el("span", { class: "src" }, meta.source),
690
+ copyId,
691
+ meta.model ? el("span", {}, "model: " + meta.model) : null,
692
+ meta.git_branch ? el("span", {}, "branch: " + meta.git_branch) : null,
693
+ meta.tokens_input != null ? el("span", {}, `tokens ${fmtTokens(meta.tokens_input)}/${fmtTokens(meta.tokens_output)}`) : null,
694
+ el("span", {}, fmtDate(meta.created)),
695
+ el("span", {}, `${meta.message_count} messages`),
696
+ meta.directory ? el("span", {}, meta.directory) : null
697
+ ),
698
+ findBar(),
699
+ actionBar(meta)
700
+ );
701
+ const body = el("div", { class: "t-body", id: "t-body" });
702
+ body.addEventListener("scroll", () => {
703
+ // Load more messages as the (frozen-header) message body nears its bottom.
704
+ if (state.current && state.msg.hasMore && !state.msg.loading) {
705
+ if (body.scrollTop + body.clientHeight >= body.scrollHeight - 400) loadMessages(false);
706
+ }
707
+ autoCollapseOnScroll(body.scrollTop);
708
+ });
709
+ t.replaceChildren(head, body);
710
+ applyHeaderCollapsed();
711
+ }
712
+
713
+ // Header collapse: a manual preference (persisted, "1"/"0") always wins. When
714
+ // no manual preference is set we are in AUTO mode -- the header collapses once
715
+ // the transcript is scrolled down and expands again near the top.
716
+ function headerPref() {
717
+ return localStorage.getItem("scrollback-head-collapsed"); // "1" | "0" | null
718
+ }
719
+ function isHeaderCollapsed() {
720
+ const pref = headerPref();
721
+ if (pref === "1") return true;
722
+ if (pref === "0") return false;
723
+ return state.headAutoCollapsed === true; // auto mode
724
+ }
725
+ function applyHeaderCollapsed() {
726
+ const t = $("#transcript");
727
+ if (!t) return;
728
+ const on = isHeaderCollapsed();
729
+ t.classList.toggle("head-collapsed", on);
730
+ const btn = $("#head-collapse");
731
+ if (btn) {
732
+ btn.textContent = on ? "\u25be" : "\u25b4"; // down (expand) / up (collapse)
733
+ btn.setAttribute("aria-expanded", String(!on));
734
+ btn.title = on ? "Expand the session header (h)" : "Collapse the session header (h)";
735
+ }
736
+ }
737
+ function toggleHeaderCollapsed() {
738
+ // A manual toggle pins the opposite of the current visible state.
739
+ localStorage.setItem("scrollback-head-collapsed", isHeaderCollapsed() ? "0" : "1");
740
+ applyHeaderCollapsed();
741
+ }
742
+ function autoCollapseOnScroll(scrollTop) {
743
+ if (headerPref() !== null) return; // manual preference set -> no auto behaviour
744
+ const want = scrollTop > 120;
745
+ if (want !== state.headAutoCollapsed) {
746
+ state.headAutoCollapsed = want;
747
+ applyHeaderCollapsed();
748
+ }
749
+ }
750
+
751
+ function findBar() {
752
+ const input = el("input", { id: "find-input", type: "search", placeholder: "find in transcript\u2026",
753
+ autocomplete: "off", spellcheck: "false",
754
+ oninput: debounce(() => runFind(input.value), 150),
755
+ onkeydown: (e) => { if (e.key === "Enter") { e.preventDefault(); stepFind(e.shiftKey ? -1 : 1); } } });
756
+ return el("div", { class: "t-find" },
757
+ input,
758
+ el("span", { class: "find-count", id: "find-count" }, ""),
759
+ el("button", { class: "btn", onclick: () => stepFind(-1) }, "\u2191"),
760
+ el("button", { class: "btn", onclick: () => stepFind(1) }, "\u2193"));
761
+ }
762
+
763
+ // A checkbox-style toggle: a leading box (checked/unchecked) + a label, so
764
+ // it is obvious at a glance what is shown vs hidden.
765
+ function checkToggle(label, key) {
766
+ const render = (btn) => {
767
+ const on = state[key];
768
+ btn.classList.toggle("on", on);
769
+ btn.setAttribute("aria-pressed", String(on));
770
+ btn.replaceChildren(
771
+ el("span", { class: "chk", "aria-hidden": "true" }, on ? "\u2611" : "\u2610"),
772
+ label,
773
+ );
774
+ };
775
+ const btn = el("button", {
776
+ class: "toggle", role: "checkbox",
777
+ title: `Show or hide ${label}`,
778
+ onclick: (e) => { state[key] = !state[key]; render(e.currentTarget); rerenderMessages(); },
779
+ });
780
+ render(btn);
781
+ return btn;
782
+ }
783
+
784
+ // Which exports the math mode actually changes. Markdown / copy / JSON are
785
+ // always verbatim LaTeX source, so the mode is inert for them.
786
+ const MATH_AWARE_FMT = new Set(["html"]);
787
+ // Short suffix naming the active math mode, for buttons it affects.
788
+ const MATH_SUFFIX = { raw: "", latex: " \u00b7 LaTeX", rendered: " \u00b7 typeset" };
789
+
790
+ function actionBar(meta) {
791
+ // -- VIEW zone: how the transcript is shown on screen ------------------
792
+ const show = el("div", { class: "ctrl-grp" },
793
+ el("span", { class: "ctrl-label" }, "show"),
794
+ checkToggle("reasoning", "reasoning"),
795
+ checkToggle("tool calls", "tools"));
796
+
797
+ const mathSel = el("select", { class: "select", id: "math-select",
798
+ title: "How LaTeX math is displayed on screen, and in print / HTML export",
799
+ onchange: (e) => setMath(e.currentTarget.value) },
800
+ el("option", { value: "raw" }, "source ($\u2026$)"),
801
+ el("option", { value: "latex" }, "LaTeX (paste-ready)"),
802
+ el("option", { value: "rendered" }, "typeset"));
803
+ mathSel.value = state.math;
804
+ const math = el("div", { class: "ctrl-grp" },
805
+ el("label", { class: "ctrl-label", for: "math-select" }, "math"),
806
+ mathSel);
807
+
808
+ const view = el("div", { class: "bar-zone" },
809
+ el("span", { class: "zone-label" }, "view"), show, math);
810
+
811
+ // -- EXPORT zone: actions that produce a file / clipboard -------------
812
+ // A button whose label reflects the math mode when math applies to it.
813
+ const exp = (fmt, base) => {
814
+ const aware = MATH_AWARE_FMT.has(fmt);
815
+ const btn = el("button", {
816
+ class: "btn" + (aware ? " math-aware" : ""),
817
+ onclick: () => downloadExport(meta, fmt),
818
+ });
819
+ const paint = () => {
820
+ btn.textContent = "\u2193 " + base + (aware ? MATH_SUFFIX[state.math] : "");
821
+ };
822
+ paint();
823
+ if (aware) btn.addEventListener("scrollback:math", paint);
824
+ return btn;
825
+ };
826
+
827
+ const printBtn = el("button", { class: "btn math-aware", onclick: () => printSession(meta) });
828
+ const paintPrint = () => { printBtn.textContent = "\u2399 print" + MATH_SUFFIX[state.math]; };
829
+ paintPrint();
830
+ printBtn.addEventListener("scrollback:math", paintPrint);
831
+
832
+ const exportRow = el("div", { class: "action-grp" },
833
+ el("button", { class: "btn", title: "Copy as Markdown (LaTeX kept as source)",
834
+ onclick: () => copySession(meta, "markdown") },
835
+ "copy ", el("span", { class: "k" }, "md")),
836
+ printBtn,
837
+ exp("html", "html"), exp("markdown", "md"), exp("json", "json"));
838
+
839
+ const exportZone = el("div", { class: "bar-zone" },
840
+ el("span", { class: "zone-label" }, "export"), exportRow,
841
+ el("span", { class: "zone-note", title:
842
+ "Math display affects on-screen view, print, and HTML export. "
843
+ + "Markdown, copy, and JSON always keep LaTeX as verbatim source." },
844
+ "\u24d8 md / copy / json keep LaTeX source"));
845
+
846
+ return el("div", { class: "t-actions" }, view, exportZone);
847
+ }
848
+
849
+ let loadedMessages = []; // accumulates message objects as we page
850
+
851
+ async function loadMessages(reset, focusMessageId) {
852
+ if (state.msg.loading) return;
853
+ state.msg.loading = true;
854
+ const body = $("#t-body");
855
+ if (reset) { loadedMessages = []; body.replaceChildren(el("div", { class: "loading" }, "loading messages\u2026")); }
856
+ try {
857
+ const { source, id } = state.current;
858
+ const p = new URLSearchParams({ offset: String(state.msg.offset), limit: String(MSG_PAGE) });
859
+ const data = await getJSON(`/api/sessions/${enc(source)}/${enc(id)}/messages?` + p.toString());
860
+ state.msg.hasMore = data.has_more;
861
+ state.msg.offset += data.messages.length;
862
+ loadedMessages.push(...data.messages);
863
+ renderMessages(reset);
864
+ if (focusMessageId) {
865
+ const node = body.querySelector(`[data-mid="${CSS.escape(focusMessageId)}"]`);
866
+ if (node) node.scrollIntoView({ block: "center" });
867
+ }
868
+ } catch (err) {
869
+ if (reset) body.replaceChildren(el("div", { class: "loading" }, "error: " + err.message));
870
+ } finally {
871
+ state.msg.loading = false;
872
+ }
873
+ }
874
+
875
+ function renderMessages(reset) {
876
+ const body = $("#t-body");
877
+ if (reset) body.replaceChildren();
878
+ else body.querySelector(".load-more")?.remove();
879
+
880
+ const start = body.querySelectorAll(".msg").length;
881
+ for (let i = start; i < loadedMessages.length; i++) {
882
+ const node = messageNode(loadedMessages[i]);
883
+ if (node) body.append(node);
884
+ }
885
+ if (state.msg.hasMore) {
886
+ body.append(el("button", { class: "load-more",
887
+ onclick: () => loadMessages(false) },
888
+ `load more (${state.msg.offset} of ${transcriptMeta.message_count} loaded)`));
889
+ }
890
+ }
891
+
892
+ function rerenderMessages() {
893
+ // Re-render already-loaded messages in place (toggle reasoning/tools).
894
+ const body = $("#t-body");
895
+ if (!body) return;
896
+ body.querySelectorAll(".msg").forEach((n) => n.remove());
897
+ const more = body.querySelector(".load-more");
898
+ loadedMessages.forEach((m) => { const n = messageNode(m); if (n) more ? body.insertBefore(n, more) : body.append(n); });
899
+ if (findState.term) runFind(findState.term);
900
+ }
901
+
902
+ function messageToText(m) {
903
+ // Plain-text rendering of a single message, honouring current toggles.
904
+ const lines = [];
905
+ for (const p of m.parts) {
906
+ if (p.type === "text" && p.text) lines.push(p.text);
907
+ else if (p.type === "reasoning" && state.reasoning && p.text) lines.push("[reasoning] " + p.text);
908
+ else if (p.type === "tool" && state.tools && p.text)
909
+ lines.push(`[${p.tool_name || p.tool_status || "tool"}] ${p.text}`);
910
+ }
911
+ return lines.join("\n\n");
912
+ }
913
+
914
+ async function copyMessage(m, btn) {
915
+ const text = messageToText(m);
916
+ try {
917
+ await navigator.clipboard.writeText(text);
918
+ const prev = btn.textContent;
919
+ btn.textContent = "\u2713";
920
+ setTimeout(() => (btn.textContent = prev), 1100);
921
+ toast(`copied message (${text.length} chars)`);
922
+ } catch (err) { toast("copy failed: " + err.message); }
923
+ }
924
+
925
+ function messageNode(m) {
926
+ const parts = [];
927
+ for (const p of m.parts) {
928
+ if (p.type === "text" && p.text) {
929
+ const box = el("div", { class: "part text" });
930
+ renderMarkdownInto(box, p.text);
931
+ parts.push(box);
932
+ } else if (p.type === "reasoning" && state.reasoning && p.text)
933
+ parts.push(el("div", { class: "part reasoning" }, el("span", { class: "tag" }, "reasoning"), el("pre", {}, p.text)));
934
+ else if (p.type === "tool" && state.tools && p.text) {
935
+ const err = p.tool_status === "error";
936
+ parts.push(el("div", { class: "part tool" + (err ? " is-error" : "") },
937
+ el("span", { class: "tag" }, p.tool_name || p.tool_status || "tool"), el("pre", {}, p.text)));
938
+ }
939
+ }
940
+ if (!parts.length) return null;
941
+ const cls = m.role === "user" ? "user" : "assistant";
942
+ const copyBtn = el("button", {
943
+ class: "msg-copy", title: "copy this message",
944
+ onclick: (e) => { e.stopPropagation(); copyMessage(m, e.currentTarget); },
945
+ }, "\u29c9");
946
+ return el("div", { class: "msg " + cls, dataset: { mid: m.id } },
947
+ copyBtn,
948
+ el("div", { class: "m-role" }, el("span", {}, m.role),
949
+ m.created ? el("span", { class: "m-time" }, fmtDate(m.created)) : null),
950
+ ...parts);
951
+ }
952
+
953
+ // (message-body scroll handler is attached per-render in renderHeader, since
954
+ // the .t-body element is recreated for each opened session)
955
+
956
+ // ====================================================================
957
+ // in-transcript find
958
+ // ====================================================================
959
+
960
+ const findState = { term: "", hits: [], idx: -1 };
961
+
962
+ function clearFindMarks() {
963
+ document.querySelectorAll("mark.find-hit").forEach((m) => {
964
+ const parent = m.parentNode;
965
+ parent.replaceChild(document.createTextNode(m.textContent), m);
966
+ parent.normalize();
967
+ });
968
+ }
969
+
970
+ function runFind(term) {
971
+ clearFindMarks();
972
+ findState.term = term;
973
+ findState.hits = [];
974
+ findState.idx = -1;
975
+ const tl = term.trim().toLowerCase();
976
+ if (!tl) { $("#find-count").textContent = ""; return; }
977
+ const body = $("#t-body");
978
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
979
+ const targets = [];
980
+ let node;
981
+ while ((node = walker.nextNode())) {
982
+ if (node.parentElement.closest("mark")) continue;
983
+ if (node.nodeValue.toLowerCase().includes(tl)) targets.push(node);
984
+ }
985
+ for (const text of targets) {
986
+ const frag = document.createDocumentFragment();
987
+ const val = text.nodeValue;
988
+ const low = val.toLowerCase();
989
+ let i = 0, pos = low.indexOf(tl);
990
+ while (pos !== -1) {
991
+ if (pos > i) frag.append(val.slice(i, pos));
992
+ const mark = el("mark", { class: "find-hit" }, val.slice(pos, pos + tl.length));
993
+ frag.append(mark);
994
+ findState.hits.push(mark);
995
+ i = pos + tl.length;
996
+ pos = low.indexOf(tl, i);
997
+ }
998
+ if (i < val.length) frag.append(val.slice(i));
999
+ text.parentNode.replaceChild(frag, text);
1000
+ }
1001
+ $("#find-count").textContent = findState.hits.length ? `${findState.hits.length} found` : "no matches";
1002
+ if (findState.hits.length) stepFind(1);
1003
+ }
1004
+
1005
+ function stepFind(dir) {
1006
+ if (!findState.hits.length) return;
1007
+ if (findState.idx >= 0) findState.hits[findState.idx]?.classList.remove("current");
1008
+ findState.idx = (findState.idx + dir + findState.hits.length) % findState.hits.length;
1009
+ const cur = findState.hits[findState.idx];
1010
+ cur.classList.add("current");
1011
+ cur.scrollIntoView({ block: "center" });
1012
+ $("#find-count").textContent = `${findState.idx + 1} / ${findState.hits.length}`;
1013
+ }
1014
+
1015
+ // ====================================================================
1016
+ // export / copy
1017
+ // ====================================================================
1018
+
1019
+ // True when running inside the native pywebview window (no browser download
1020
+ // manager or working window.print()); the Python bridge handles those.
1021
+ function nativeApi() {
1022
+ return (window.pywebview && window.pywebview.api) || null;
1023
+ }
1024
+
1025
+ function exportUrl(meta, fmt, download) {
1026
+ const p = new URLSearchParams({
1027
+ format: fmt, reasoning: String(state.reasoning), tools: String(state.tools),
1028
+ math: state.math,
1029
+ });
1030
+ if (download) p.set("download", "true");
1031
+ return `/api/export/${enc(meta.source)}/${enc(meta.id)}?${p}`;
1032
+ }
1033
+
1034
+ const _EXT = { markdown: "md", md: "md", json: "json", html: "html", text: "txt", txt: "txt" };
1035
+
1036
+ async function downloadExport(meta, fmt) {
1037
+ const ext = _EXT[fmt] || "txt";
1038
+ const fname = `${meta.source}_${meta.short_id}.${ext}`;
1039
+ let text;
1040
+ try {
1041
+ const r = await fetch(exportUrl(meta, fmt, false));
1042
+ if (!r.ok) throw new Error(`${r.status}`);
1043
+ text = await r.text();
1044
+ } catch (err) { toast("export failed: " + err.message); return; }
1045
+
1046
+ const api = nativeApi();
1047
+ if (api) {
1048
+ // Native window: save via a real OS dialog through the Python bridge.
1049
+ try {
1050
+ const res = await api.save_file(fname, text);
1051
+ toast(res.startsWith("saved") ? `saved ${fname}` : res);
1052
+ } catch (err) { toast("save failed: " + err.message); }
1053
+ return;
1054
+ }
1055
+ // Browser: force a real download via a Blob URL + the download attribute
1056
+ // (reliable regardless of the response Content-Type).
1057
+ const blob = new Blob([text], { type: "application/octet-stream" });
1058
+ const objUrl = URL.createObjectURL(blob);
1059
+ const a = el("a", { href: objUrl, download: fname });
1060
+ document.body.append(a);
1061
+ a.click();
1062
+ a.remove();
1063
+ setTimeout(() => URL.revokeObjectURL(objUrl), 2000);
1064
+ }
1065
+
1066
+ async function copySession(meta, fmt) {
1067
+ try {
1068
+ const r = await fetch(exportUrl(meta, fmt, false));
1069
+ const text = await r.text();
1070
+ await navigator.clipboard.writeText(text);
1071
+ toast(`copied ${text.length} chars (${fmt})`);
1072
+ } catch (err) { toast("copy failed: " + err.message); }
1073
+ }
1074
+
1075
+ async function printSession(meta) {
1076
+ const api = nativeApi();
1077
+ if (api) {
1078
+ // The native webview can't reliably open the OS print dialog; open a
1079
+ // dedicated print page in the user's real browser, which can print.
1080
+ const q = `/print/${enc(meta.source)}/${enc(meta.id)}?` +
1081
+ new URLSearchParams({ reasoning: String(state.reasoning), tools: String(state.tools), math: state.math });
1082
+ try {
1083
+ await api.open_external(q);
1084
+ toast("opened print view in your browser");
1085
+ } catch (err) { toast("print failed: " + err.message); }
1086
+ return;
1087
+ }
1088
+
1089
+ // Browser: load the print-friendly HTML in a hidden iframe and print it.
1090
+ toast("preparing print\u2026");
1091
+ let html;
1092
+ try {
1093
+ const r = await fetch(exportUrl(meta, "html", false));
1094
+ html = await r.text();
1095
+ } catch (err) { toast("print failed: " + err.message); return; }
1096
+
1097
+ const frame = el("iframe", { class: "print-frame", "aria-hidden": "true" });
1098
+ document.body.append(frame);
1099
+ const doc = frame.contentWindow.document;
1100
+ doc.open();
1101
+ doc.write(html);
1102
+ doc.close();
1103
+ const go = () => {
1104
+ frame.contentWindow.focus();
1105
+ frame.contentWindow.print();
1106
+ setTimeout(() => frame.remove(), 1000);
1107
+ };
1108
+ if (doc.readyState === "complete") setTimeout(go, 150);
1109
+ else frame.onload = () => setTimeout(go, 150);
1110
+ }
1111
+
1112
+ // ====================================================================
1113
+ // keyboard navigation
1114
+ // ====================================================================
1115
+
1116
+ function rowList() {
1117
+ return [...document.querySelectorAll(".session, .s-child")].filter((n) => n.offsetParent !== null);
1118
+ }
1119
+ function moveSelection(dir) {
1120
+ const rows = rowList();
1121
+ if (!rows.length) return;
1122
+ let idx = rows.findIndex((r) => r.classList.contains("kbd-sel"));
1123
+ rows.forEach((r) => r.classList.remove("kbd-sel"));
1124
+ idx = Math.max(0, Math.min(rows.length - 1, (idx === -1 ? 0 : idx + dir)));
1125
+ const row = rows[idx];
1126
+ row.classList.add("kbd-sel");
1127
+ row.style.outline = "1px solid var(--focus)";
1128
+ setTimeout(() => (row.style.outline = ""), 600);
1129
+ row.scrollIntoView({ block: "nearest" });
1130
+ }
1131
+ function openSelection() {
1132
+ const row = document.querySelector(".session.kbd-sel, .s-child.kbd-sel") || rowList()[0];
1133
+ if (row) openSession(row.dataset.source, row.dataset.id);
1134
+ }
1135
+
1136
+ document.addEventListener("keydown", (e) => {
1137
+ const typing = ["INPUT", "TEXTAREA"].includes(document.activeElement.tagName);
1138
+ if (e.key === "/" && !typing) { e.preventDefault(); searchInput.focus(); searchInput.select(); return; }
1139
+ if (typing) {
1140
+ if (e.key === "Escape") document.activeElement.blur();
1141
+ return;
1142
+ }
1143
+ if (e.key === "j") { e.preventDefault(); moveSelection(1); }
1144
+ else if (e.key === "k") { e.preventDefault(); moveSelection(-1); }
1145
+ else if (e.key === "Enter") { e.preventDefault(); openSelection(); }
1146
+ else if (e.key === "f" && state.current) {
1147
+ e.preventDefault(); $("#find-input")?.focus();
1148
+ }
1149
+ else if (e.key === "h" && state.current) {
1150
+ e.preventDefault(); toggleHeaderCollapsed();
1151
+ }
1152
+ });
1153
+
1154
+ // ====================================================================
1155
+ // boot
1156
+ // ====================================================================
1157
+
1158
+ function clearAll() {
1159
+ // Reset to the initial "home" state: no query, default scope, all sources
1160
+ // on, no date filters, no open transcript.
1161
+ searchInput.value = "";
1162
+ state.query = "";
1163
+ state.scope = { titles: true, contents: false };
1164
+ updateScopeButtons();
1165
+
1166
+ state.enabled = new Set(state.sources.map((s) => s.name));
1167
+ document.querySelectorAll(".src-toggle").forEach((b) => {
1168
+ b.setAttribute("aria-pressed", "true");
1169
+ const c = b.querySelector(".check");
1170
+ if (c) c.textContent = "\u2713";
1171
+ });
1172
+
1173
+ state.since = ""; state.until = "";
1174
+ const since = $("#since"), until = $("#until");
1175
+ if (since) since.value = "";
1176
+ if (until) until.value = "";
1177
+
1178
+ // Close the open transcript.
1179
+ state.current = null;
1180
+ $("#transcript").hidden = true;
1181
+ $("#empty").hidden = false;
1182
+ history.replaceState(null, "", location.pathname);
1183
+
1184
+ resetAndLoad();
1185
+ $("#rail").scrollTop = 0;
1186
+ }
1187
+
1188
+ searchInput.addEventListener("input", onSearchInput);
1189
+ scopeTitlesBtn.addEventListener("click", () => toggleScope("titles"));
1190
+ scopeContentsBtn.addEventListener("click", () => toggleScope("contents"));
1191
+ $("#home-btn").addEventListener("click", clearAll);
1192
+ $("#brand").addEventListener("click", clearAll);
1193
+ $("#brand").style.cursor = "pointer";
1194
+ $("#theme-toggle").addEventListener("click", toggleTheme);
1195
+ $("#since").addEventListener("change", (e) => { state.since = e.target.value; resetAndLoad(); });
1196
+ $("#until").addEventListener("change", (e) => { state.until = e.target.value; resetAndLoad(); });
1197
+
1198
+ function openFromHash() {
1199
+ const h = decodeURIComponent(location.hash.replace(/^#/, ""));
1200
+ const slash = h.indexOf("/");
1201
+ if (slash > 0) openSession(h.slice(0, slash), h.slice(slash + 1));
1202
+ }
1203
+ function prefillSearchFromUrl() {
1204
+ const q = new URLSearchParams(location.search).get("q");
1205
+ if (q) {
1206
+ // A ?q= deep link implies a content search.
1207
+ searchInput.value = q;
1208
+ state.query = q;
1209
+ state.scope.contents = true;
1210
+ return true;
1211
+ }
1212
+ return false;
1213
+ }
1214
+
1215
+ // Heartbeat: when the server is launched with auto-shutdown, ping it on an
1216
+ // interval so it knows the window is still open. When the window/tab closes,
1217
+ // pings stop and the server shuts itself down (freeing the port).
1218
+ async function startHeartbeat() {
1219
+ let cfg;
1220
+ try {
1221
+ cfg = await getJSON("/api/heartbeat-config");
1222
+ } catch {
1223
+ return;
1224
+ }
1225
+ if (!cfg || !cfg.enabled) return;
1226
+ const ms = Math.max((cfg.interval || 3) * 1000, 1000);
1227
+ const ping = () => { fetch("/api/heartbeat", { method: "POST", keepalive: true }).catch(() => {}); };
1228
+ ping();
1229
+ setInterval(ping, ms);
1230
+ }
1231
+
1232
+ (async function boot() {
1233
+ initTheme();
1234
+ initMath();
1235
+ try {
1236
+ await loadSources();
1237
+ prefillSearchFromUrl();
1238
+ updateScopeButtons();
1239
+ await loadListPage(true);
1240
+ if (location.hash) openFromHash();
1241
+ startHeartbeat();
1242
+ } catch (err) {
1243
+ sessionsEl.replaceChildren(el("li", { class: "loading" }, "failed to load: " + err.message));
1244
+ }
1245
+ })();