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,128 @@
1
+ """A tiny, dependency-free syntax highlighter for code blocks in exports.
2
+
3
+ Scope is intentionally narrow: the languages that dominate AI coding
4
+ transcripts (bash/shell, python, javascript/jsx/typescript, json), plus a
5
+ generic fallback that highlights strings/comments/numbers. The goal is a
6
+ *self-contained* HTML export -- colourful code with no JS and no external
7
+ dependency, suitable for printing or offline viewing.
8
+
9
+ Safety: the public `highlight(code, lang)` takes RAW (unescaped) source,
10
+ HTML-escapes it, and only ever emits `<span class="hl-...">` wrappers. It
11
+ never emits attributes derived from the input, so transcript content
12
+ cannot inject markup.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import html as _html
18
+ import re
19
+
20
+ # Token CSS classes (paired with the palette in CSS_LIGHT / CSS_DARK).
21
+ _KW = "hl-kw"
22
+ _STR = "hl-str"
23
+ _COM = "hl-com"
24
+ _NUM = "hl-num"
25
+ _FUNC = "hl-fn"
26
+
27
+ _PY_KEYWORDS = {
28
+ "def", "class", "return", "if", "elif", "else", "for", "while", "try",
29
+ "except", "finally", "with", "as", "import", "from", "in", "not", "and",
30
+ "or", "is", "lambda", "None", "True", "False", "pass", "break", "continue",
31
+ "raise", "yield", "global", "nonlocal", "assert", "del", "async", "await",
32
+ }
33
+ _JS_KEYWORDS = {
34
+ "function", "return", "if", "else", "for", "while", "do", "switch", "case",
35
+ "break", "continue", "const", "let", "var", "new", "class", "extends",
36
+ "import", "from", "export", "default", "try", "catch", "finally", "throw",
37
+ "typeof", "instanceof", "in", "of", "this", "super", "async", "await",
38
+ "yield", "null", "undefined", "true", "false", "void", "delete",
39
+ }
40
+ _SH_KEYWORDS = {
41
+ "if", "then", "elif", "else", "fi", "for", "while", "do", "done", "case",
42
+ "esac", "in", "function", "return", "export", "local", "cd", "echo",
43
+ "set", "unset", "source",
44
+ }
45
+
46
+ _LANG_KEYWORDS = {
47
+ "python": _PY_KEYWORDS, "py": _PY_KEYWORDS,
48
+ "javascript": _JS_KEYWORDS, "js": _JS_KEYWORDS, "jsx": _JS_KEYWORDS,
49
+ "typescript": _JS_KEYWORDS, "ts": _JS_KEYWORDS, "tsx": _JS_KEYWORDS,
50
+ "bash": _SH_KEYWORDS, "sh": _SH_KEYWORDS, "shell": _SH_KEYWORDS, "zsh": _SH_KEYWORDS,
51
+ }
52
+
53
+ _COMMENT_PREFIX = {
54
+ "python": "#", "py": "#", "bash": "#", "sh": "#", "shell": "#", "zsh": "#",
55
+ "yaml": "#", "yml": "#", "toml": "#", "ruby": "#", "r": "#",
56
+ }
57
+
58
+ # A single tokenizer pass: strings, comments, numbers, identifiers, other.
59
+ _TOKEN = re.compile(
60
+ r"""
61
+ (?P<dstr>"(?:\\.|[^"\\])*") |
62
+ (?P<sstr>'(?:\\.|[^'\\])*') |
63
+ (?P<tstr>`(?:\\.|[^`\\])*`) |
64
+ (?P<num>\b\d+\.?\d*\b) |
65
+ (?P<ident>[A-Za-z_][A-Za-z0-9_]*) |
66
+ (?P<ws>\s+) |
67
+ (?P<other>.)
68
+ """,
69
+ re.VERBOSE,
70
+ )
71
+
72
+
73
+ def highlight(code: str, lang: str | None) -> str:
74
+ """Return HTML (escaped) for `code`, with `<span>` highlight wrappers."""
75
+ lang = (lang or "").lower()
76
+ keywords = _LANG_KEYWORDS.get(lang)
77
+ comment_prefix = _COMMENT_PREFIX.get(lang, "#" if keywords is _SH_KEYWORDS else None)
78
+
79
+ out: list[str] = []
80
+ for raw_line in code.split("\n"):
81
+ out.append(_highlight_line(raw_line, keywords, lang, comment_prefix))
82
+ return "\n".join(out)
83
+
84
+
85
+ def _highlight_line(line: str, keywords, lang: str, comment_prefix: str | None) -> str:
86
+ # Whole-line comment handling for # / // styles (kept simple + safe).
87
+ stripped = line.lstrip()
88
+ indent = line[: len(line) - len(stripped)]
89
+ if comment_prefix and stripped.startswith(comment_prefix):
90
+ return indent + _wrap(_COM, _html.escape(stripped))
91
+ if lang in ("js", "jsx", "ts", "tsx", "javascript", "typescript") and stripped.startswith("//"):
92
+ return indent + _wrap(_COM, _html.escape(stripped))
93
+
94
+ pieces: list[str] = []
95
+ for m in _TOKEN.finditer(line):
96
+ kind = m.lastgroup
97
+ val = m.group()
98
+ if kind in ("dstr", "sstr", "tstr"):
99
+ pieces.append(_wrap(_STR, _html.escape(val)))
100
+ elif kind == "num":
101
+ pieces.append(_wrap(_NUM, _html.escape(val)))
102
+ elif kind == "ident" and keywords and val in keywords:
103
+ pieces.append(_wrap(_KW, _html.escape(val)))
104
+ else:
105
+ pieces.append(_html.escape(val))
106
+ return "".join(pieces)
107
+
108
+
109
+ def _wrap(cls: str, escaped_text: str) -> str:
110
+ return f'<span class="{cls}">{escaped_text}</span>'
111
+
112
+
113
+ # Inlined palette for the export. Theme-aware via prefers-color-scheme so the
114
+ # single self-contained file looks right in both light and dark.
115
+ HL_CSS = """
116
+ .md pre code .hl-kw { color: #cf6cd6; }
117
+ .md pre code .hl-str { color: #6aab73; }
118
+ .md pre code .hl-com { color: #8a8f99; font-style: italic; }
119
+ .md pre code .hl-num { color: #d39a4f; }
120
+ .md pre code .hl-fn { color: #4c95d6; }
121
+ @media (prefers-color-scheme: light) {
122
+ .md pre code .hl-kw { color: #a626a4; }
123
+ .md pre code .hl-str { color: #50a14f; }
124
+ .md pre code .hl-com { color: #9aa0a6; }
125
+ .md pre code .hl-num { color: #b76b01; }
126
+ .md pre code .hl-fn { color: #4078f2; }
127
+ }
128
+ """
@@ -0,0 +1,81 @@
1
+ """Inline the vendored KaTeX into a self-contained HTML export.
2
+
3
+ The static HTML export must typeset math **offline** -- a researcher saves or
4
+ prints a transcript and the equations have to render with no network and no
5
+ sibling asset files. So when an export is rendered in ``math="rendered"``
6
+ mode we embed everything KaTeX needs directly in the document:
7
+
8
+ - the KaTeX stylesheet, with every ``@font-face`` ``url(...)`` rewritten to a
9
+ base64 ``data:`` URI so the fonts travel inside the file;
10
+ - the KaTeX JavaScript;
11
+ - a small boot script that typesets each ``.math-tex`` placeholder emitted by
12
+ :func:`scrollback.minimd.render`.
13
+
14
+ The assets are the same files served to the live web app
15
+ (``web/static/vendor/katex``); they are read once and cached per process.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import functools
22
+ import re
23
+ from pathlib import Path
24
+
25
+ _KATEX_DIR = Path(__file__).parent / "web" / "static" / "vendor" / "katex"
26
+ _FONT_URL_RE = re.compile(r"url\(fonts/([A-Za-z0-9_.-]+)\.(woff2|woff|ttf)\)")
27
+ _FONT_MIME = {"woff2": "font/woff2", "woff": "font/woff", "ttf": "font/ttf"}
28
+
29
+
30
+ def available() -> bool:
31
+ """True if the vendored KaTeX JS + CSS are present."""
32
+ return (_KATEX_DIR / "katex.min.js").is_file() and (
33
+ _KATEX_DIR / "katex.min.css"
34
+ ).is_file()
35
+
36
+
37
+ @functools.lru_cache(maxsize=1)
38
+ def _inlined_css() -> str:
39
+ css = (_KATEX_DIR / "katex.min.css").read_text(encoding="utf-8")
40
+ fonts_dir = _KATEX_DIR / "fonts"
41
+
42
+ def _sub(m: re.Match[str]) -> str:
43
+ name, ext = m.group(1), m.group(2)
44
+ path = fonts_dir / f"{name}.{ext}"
45
+ if not path.is_file():
46
+ # Drop references to fonts we do not ship (only woff2 is vendored);
47
+ # browsers fall back to the woff2 face listed alongside.
48
+ return "url()"
49
+ data = base64.b64encode(path.read_bytes()).decode("ascii")
50
+ return f"url(data:{_FONT_MIME[ext]};base64,{data})"
51
+
52
+ return _FONT_URL_RE.sub(_sub, css)
53
+
54
+
55
+ @functools.lru_cache(maxsize=1)
56
+ def _katex_js() -> str:
57
+ return (_KATEX_DIR / "katex.min.js").read_text(encoding="utf-8")
58
+
59
+
60
+ def head_assets() -> str:
61
+ """Return ``<style>`` (KaTeX CSS, fonts inlined) for the document head."""
62
+ if not available():
63
+ return ""
64
+ return f"<style>{_inlined_css()}</style>"
65
+
66
+
67
+ def autorender_script() -> str:
68
+ """Return the ``<script>`` block: KaTeX plus the typeset-on-load boot code."""
69
+ if not available():
70
+ return ""
71
+ boot = (
72
+ "<script>"
73
+ "window.addEventListener('load',function(){"
74
+ "if(typeof katex==='undefined')return;"
75
+ "document.querySelectorAll('.math-tex').forEach(function(n){"
76
+ "try{katex.render(n.textContent,n,{displayMode:n.dataset.display==='true',"
77
+ "throwOnError:false,output:'html'});}catch(e){}"
78
+ "});});"
79
+ "</script>"
80
+ )
81
+ return f"<script>{_katex_js()}</script>\n{boot}"
@@ -0,0 +1,209 @@
1
+ """Install double-clickable launchers for pip-installed users.
2
+
3
+ The bundled launcher templates live in `scrollback/launchers/`. For a
4
+ source checkout users could double-click them directly, but a
5
+ `pip install` user needs them copied somewhere reachable. This module
6
+ implements `scrollback install-launcher`:
7
+
8
+ * macOS -> copies `scrollback.command` to ~/Desktop (or `--dest`), and
9
+ optionally builds a real `scrollback.app` bundle.
10
+ * Linux -> installs `scrollback.desktop` into the application menu, and
11
+ copies `scrollback.sh` to ~/Desktop (or `--dest`).
12
+ * Windows-> copies `scrollback.bat` to the Desktop (or `--dest`).
13
+
14
+ It never overwrites without telling the user, and it prints exactly what
15
+ it created so there is no hidden state.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import shlex
22
+ import stat
23
+ import sys
24
+ from importlib import resources
25
+ from pathlib import Path
26
+
27
+ _PKG = "scrollback.launchers"
28
+
29
+
30
+ def _read_bundled(name: str) -> str:
31
+ return resources.files(_PKG).joinpath(name).read_text(encoding="utf-8")
32
+
33
+
34
+ def _desktop_dir() -> Path:
35
+ d = Path.home() / "Desktop"
36
+ return d if d.is_dir() else Path.home()
37
+
38
+
39
+ def _make_executable(path: Path) -> None:
40
+ mode = path.stat().st_mode
41
+ path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
42
+
43
+
44
+ def _write(path: Path, content: str, *, executable: bool = False) -> None:
45
+ path.write_text(content, encoding="utf-8")
46
+ if executable:
47
+ _make_executable(path)
48
+
49
+
50
+ def install(dest: Path | None = None, *, desktop: bool = False,
51
+ app_bundle: bool = False) -> list[Path]:
52
+ """Install the OS-appropriate launcher(s). Returns the paths created.
53
+
54
+ The two selectors choose *which* artifacts to create:
55
+
56
+ - ``desktop`` -- the Desktop launcher (macOS ``.command`` / Windows
57
+ ``.bat`` / Linux ``.desktop`` + ``.sh``).
58
+ - ``app_bundle`` -- a double-clickable ``.app`` (macOS only).
59
+
60
+ If **neither** is requested, both are created (the "give me everything"
61
+ default). On platforms without an ``.app``, ``app_bundle`` falls back to
62
+ the Desktop launcher, so the flag never errors.
63
+ """
64
+ want_desktop = desktop or not (desktop or app_bundle)
65
+ want_app = app_bundle or not (desktop or app_bundle)
66
+
67
+ created: list[Path] = []
68
+ if sys.platform == "darwin":
69
+ created += _install_macos(dest, desktop=want_desktop, app_bundle=want_app)
70
+ elif sys.platform == "win32":
71
+ # No .app on Windows; any selection maps to the Desktop launcher.
72
+ created += _install_windows(dest)
73
+ else:
74
+ created += _install_linux(dest)
75
+ return created
76
+
77
+
78
+ def _install_macos(dest: Path | None, *, desktop: bool, app_bundle: bool) -> list[Path]:
79
+ created: list[Path] = []
80
+ if desktop:
81
+ target_dir = dest or _desktop_dir()
82
+ target_dir.mkdir(parents=True, exist_ok=True)
83
+ cmd = target_dir / "scrollback.command"
84
+ # Generate with the interpreter path baked in (Finder/GUI launches run
85
+ # with a minimal PATH that excludes conda/venv bins).
86
+ _write(cmd, _command_script(), executable=True)
87
+ created.append(cmd)
88
+ if app_bundle:
89
+ created.append(_build_macos_app(dest))
90
+ return created
91
+
92
+
93
+ def _command_script() -> str:
94
+ """A .command launcher that opens a Terminal window and shows status."""
95
+ # shlex.quote so an interpreter path with spaces/quotes/$ is shell-safe.
96
+ py = shlex.quote(sys.executable or "python3")
97
+ return (
98
+ "#!/bin/bash\n"
99
+ "# Generated by `scrollback install-launcher`. Double-click to run.\n"
100
+ f"PY={py}\n"
101
+ 'if [ ! -x "$PY" ]; then PY="$(command -v python3 || command -v python)"; fi\n'
102
+ 'echo "Starting scrollback (close the app window to stop)"\n'
103
+ # --app: native window if pywebview is present (no lingering terminal);
104
+ # otherwise it falls back to a browser window that auto-stops the
105
+ # server when closed.
106
+ 'exec "$PY" -m scrollback.cli web --app\n'
107
+ )
108
+
109
+
110
+ def _runner_script() -> str:
111
+ """A self-contained launch script with the interpreter path baked in.
112
+
113
+ Uses the absolute path of the Python that is running `install-launcher`
114
+ (which, by construction, has scrollback installed). This survives the
115
+ minimal PATH that macOS/GUI launchers run with. Errors are logged to
116
+ ~/Library/Logs so a silent failure can be diagnosed.
117
+ """
118
+ # The script is intentionally dumb: it only locates the interpreter and
119
+ # delegates everything (including how to open a window) to Python, which
120
+ # owns all platform-specific logic. `--app` opens a native window when
121
+ # pywebview is available (no terminal; closing it stops the server), and
122
+ # otherwise falls back to a browser window with heartbeat auto-shutdown.
123
+ py = shlex.quote(sys.executable or "python3")
124
+ return (
125
+ "#!/bin/bash\n"
126
+ "# Generated by `scrollback install-launcher`.\n"
127
+ f"PY={py}\n"
128
+ 'LOG="$HOME/Library/Logs/scrollback-launcher.log"\n'
129
+ 'mkdir -p "$(dirname "$LOG")" 2>/dev/null\n'
130
+ 'if [ ! -x "$PY" ]; then PY="$(command -v python3 || command -v python)"; fi\n'
131
+ 'exec "$PY" -m scrollback.cli web --app >> "$LOG" 2>&1\n'
132
+ )
133
+
134
+
135
+ def _build_macos_app(dest: Path | None) -> Path:
136
+ """Create a minimal double-clickable scrollback.app bundle."""
137
+ base = dest or (Path.home() / "Applications")
138
+ base.mkdir(parents=True, exist_ok=True)
139
+ app = base / "scrollback.app"
140
+ macos = app / "Contents" / "MacOS"
141
+ macos.mkdir(parents=True, exist_ok=True)
142
+
143
+ # Launcher script inside the bundle. macOS launches .app bundles with a
144
+ # minimal PATH that excludes conda/venv bins, so we bake the ABSOLUTE
145
+ # interpreter path (known at install time) instead of relying on PATH.
146
+ runner = macos / "scrollback"
147
+ _write(runner, _runner_script(), executable=True)
148
+
149
+ from . import __version__
150
+
151
+ # Copy the bundled .icns into Contents/Resources so Finder/Dock show it.
152
+ icon_line = ""
153
+ try:
154
+ icns = resources.files("scrollback.assets").joinpath("icon.icns").read_bytes()
155
+ resources_dir = app / "Contents" / "Resources"
156
+ resources_dir.mkdir(parents=True, exist_ok=True)
157
+ (resources_dir / "scrollback.icns").write_bytes(icns)
158
+ icon_line = " <key>CFBundleIconFile</key><string>scrollback</string>\n"
159
+ except (OSError, ModuleNotFoundError, FileNotFoundError):
160
+ pass # icon is optional; bundle still works without it
161
+
162
+ info = app / "Contents" / "Info.plist"
163
+ info.write_text(
164
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
165
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
166
+ '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
167
+ '<plist version="1.0"><dict>\n'
168
+ " <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>\n"
169
+ " <key>CFBundleName</key><string>scrollback</string>\n"
170
+ " <key>CFBundleDisplayName</key><string>scrollback</string>\n"
171
+ " <key>CFBundleIdentifier</key><string>dev.scrollback.app</string>\n"
172
+ f" <key>CFBundleVersion</key><string>{__version__}</string>\n"
173
+ f" <key>CFBundleShortVersionString</key><string>{__version__}</string>\n"
174
+ " <key>CFBundlePackageType</key><string>APPL</string>\n"
175
+ " <key>CFBundleExecutable</key><string>scrollback</string>\n"
176
+ + icon_line
177
+ + " <key>LSUIElement</key><false/>\n"
178
+ "</dict></plist>\n",
179
+ encoding="utf-8",
180
+ )
181
+ return app
182
+
183
+
184
+ def _install_windows(dest: Path | None) -> list[Path]:
185
+ target_dir = dest or _desktop_dir()
186
+ target_dir.mkdir(parents=True, exist_ok=True)
187
+ bat = target_dir / "scrollback.bat"
188
+ _write(bat, _read_bundled("scrollback.bat"))
189
+ return [bat]
190
+
191
+
192
+ def _install_linux(dest: Path | None) -> list[Path]:
193
+ created: list[Path] = []
194
+ # 1) application-menu entry
195
+ apps_dir = (
196
+ dest
197
+ if dest
198
+ else Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
199
+ / "applications"
200
+ )
201
+ apps_dir.mkdir(parents=True, exist_ok=True)
202
+ desktop = apps_dir / "scrollback.desktop"
203
+ _write(desktop, _read_bundled("scrollback.desktop"), executable=True)
204
+ created.append(desktop)
205
+ # 2) a double-clickable copy on the Desktop
206
+ sh = _desktop_dir() / "scrollback.sh"
207
+ _write(sh, _read_bundled("scrollback.sh"), executable=True)
208
+ created.append(sh)
209
+ return created
@@ -0,0 +1,19 @@
1
+ @echo off
2
+ REM Double-clickable launcher for Windows.
3
+ REM Starts the scrollback web app in a standalone browser window.
4
+
5
+ where scrollback >nul 2>nul
6
+ if %errorlevel%==0 (
7
+ scrollback web --window
8
+ goto :eof
9
+ )
10
+
11
+ python -c "import scrollback" >nul 2>nul
12
+ if %errorlevel%==0 (
13
+ python -m scrollback.cli web --window
14
+ goto :eof
15
+ )
16
+
17
+ echo scrollback is not installed.
18
+ echo Install it with: pip install "scrollback[web]"
19
+ pause
@@ -0,0 +1,19 @@
1
+ #!/bin/bash
2
+ # Double-clickable launcher for macOS (Finder: double-click this file).
3
+ # Starts the scrollback web app and opens it in your default browser.
4
+ #
5
+ # Install a copy you can double-click with: scrollback install-launcher
6
+ # First-time setup: right-click -> Open (to bypass Gatekeeper once).
7
+ #
8
+ # If `scrollback` is not on PATH, this falls back to `python3 -m scrollback`.
9
+
10
+ if command -v scrollback >/dev/null 2>&1; then
11
+ exec scrollback web
12
+ elif python3 -c "import scrollback" >/dev/null 2>&1; then
13
+ exec python3 -m scrollback.cli web
14
+ else
15
+ echo "scrollback is not installed."
16
+ echo "Install it with: pip install \"scrollback[web]\""
17
+ echo
18
+ read -r -p "Press Return to close."
19
+ fi
@@ -0,0 +1,10 @@
1
+ [Desktop Entry]
2
+ Type=Application
3
+ Name=scrollback
4
+ Comment=Browse your AI coding-agent sessions
5
+ Exec=scrollback web --window
6
+ Terminal=false
7
+ Categories=Development;Utility;
8
+ # Install: copy to ~/.local/share/applications/ (and `chmod +x` if needed),
9
+ # or run `xdg-desktop-menu install scrollback.desktop`. Then launch from your
10
+ # application menu like any other app.
@@ -0,0 +1,12 @@
1
+ #!/bin/bash
2
+ # Launcher for Linux / BSD. Make executable (chmod +x scrollback.sh) and run,
3
+ # or wire it into a .desktop entry (see scrollback.desktop).
4
+
5
+ if command -v scrollback >/dev/null 2>&1; then
6
+ exec scrollback web --window
7
+ elif python3 -c "import scrollback" >/dev/null 2>&1; then
8
+ exec python3 -m scrollback.cli web --window
9
+ else
10
+ echo "scrollback is not installed. Install with: pip install \"scrollback[web]\""
11
+ exit 1
12
+ fi
scrollback/mathspan.py ADDED
@@ -0,0 +1,180 @@
1
+ """Detect and protect delimited-LaTeX math spans in transcript text.
2
+
3
+ A model often replies with LaTeX -- inline (`$\\nabla\\cdot u = 0$`,
4
+ `\\(x^2\\)`) or display (`$$E=mc^2$$`, `\\[\\int_0^1 x\\,dx\\]`). Left to the
5
+ Markdown renderer, the `\\`, `_`, `*`, and `^` inside such a span get
6
+ mangled (a `_` becomes emphasis, a stray `\\` is dropped, ...), corrupting
7
+ the equation. This module finds those spans so the rest of the pipeline can
8
+ shield them.
9
+
10
+ Scope is *delimited* LaTeX only. The three forms math appears in are:
11
+ delimited LaTeX (handled here, because it can be detected reliably),
12
+ Unicode math (`\u2207\u00b7u`), and plain ASCII (`x^2 + y^2`). The latter two are
13
+ deliberately out of scope -- detecting them would mean guessing, with false
14
+ positives in ordinary prose and code.
15
+
16
+ The detection is intentionally conservative:
17
+
18
+ - Display delimiters (`$$...$$`, `\\[...\\]`) and the escaped-paren inline
19
+ form (`\\(...\\)`) are unambiguous and always recognised.
20
+ - The single-`$...$` inline form is recognised only when it does not look
21
+ like ordinary currency/prose: no whitespace directly inside the
22
+ delimiters, no digit immediately after the closing `$` (so `$5` and
23
+ `$3.50` and `it cost $5 to $10` are left alone), and the body is
24
+ non-empty and single-line.
25
+ - Nothing inside a fenced/inline code span is treated as math; the caller
26
+ is responsible for not handing code to this module (both `minimd` and the
27
+ browser pass only prose runs / use code placeholders first).
28
+
29
+ The public surface is small:
30
+
31
+ - `find_spans(text)` -> list of `Span` (start, end, body, mode, raw).
32
+ - `protect(text)` -> `(masked_text, tokens)` where each math span is
33
+ replaced by an opaque placeholder safe to pass through Markdown.
34
+ - `restore(html, tokens, render)` -> the placeholders swapped back, each
35
+ rendered by the supplied `render(body, display) -> str` callback.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import re
41
+ from dataclasses import dataclass
42
+
43
+ # Placeholder wrapper. Uses NUL bytes so it cannot collide with real
44
+ # transcript text and so the Markdown renderers treat it as inert inline
45
+ # text (no special characters inside).
46
+ _PH_OPEN = "\x00MATH"
47
+ _PH_CLOSE = "\x00"
48
+ _PH_RE = re.compile(r"\x00MATH(\d+)\x00")
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Span:
53
+ """A detected math span within the source text."""
54
+
55
+ start: int # index of the first delimiter char
56
+ end: int # index just past the last delimiter char
57
+ body: str # the LaTeX between the delimiters (delimiters stripped)
58
+ display: bool # True for display math ($$ / \[), False for inline
59
+ raw: str # the full matched text including delimiters
60
+
61
+
62
+ # Ordered by precedence: longer / unambiguous delimiters first so e.g. `$$`
63
+ # is consumed before the single-`$` rule ever sees it.
64
+ #
65
+ # Each entry is (compiled_regex, display, body_group). The regexes are
66
+ # matched against the *whole* text with finditer; overlap is prevented by
67
+ # scanning left to right and skipping matches that start inside an already
68
+ # claimed span (see find_spans).
69
+ _DISPLAY_DOLLAR = re.compile(r"\$\$(.+?)\$\$", re.DOTALL)
70
+ _DISPLAY_BRACKET = re.compile(r"\\\[(.+?)\\\]", re.DOTALL)
71
+ _INLINE_PAREN = re.compile(r"\\\((.+?)\\\)", re.DOTALL)
72
+ # Single-dollar inline: no whitespace just inside, single line, non-empty,
73
+ # not immediately followed by a digit (currency guard). The body may not
74
+ # contain a `$`.
75
+ _INLINE_DOLLAR = re.compile(r"\$(?!\s)([^$\n]*[^$\s])\$(?![\d])")
76
+
77
+ _PATTERNS: tuple[tuple[re.Pattern[str], bool], ...] = (
78
+ (_DISPLAY_DOLLAR, True),
79
+ (_DISPLAY_BRACKET, True),
80
+ (_INLINE_PAREN, False),
81
+ (_INLINE_DOLLAR, False),
82
+ )
83
+
84
+ # Code regions, which must never be treated as math. Fenced blocks first so
85
+ # their (possibly `$`-containing) bodies are claimed before inline spans.
86
+ _FENCE_BLOCK = re.compile(r"(?m)^[ \t]*(`{3,}|~{3,}).*?\n.*?(?:^[ \t]*\1[ \t]*$|\Z)", re.DOTALL)
87
+ _INLINE_CODE = re.compile(r"(`+)(?:.+?)\1")
88
+
89
+
90
+ def _code_ranges(text: str) -> list[tuple[int, int]]:
91
+ ranges: list[tuple[int, int]] = []
92
+ for m in _FENCE_BLOCK.finditer(text):
93
+ ranges.append((m.start(), m.end()))
94
+
95
+ def _in_fence(pos: int) -> bool:
96
+ return any(a <= pos < b for a, b in ranges)
97
+
98
+ for m in _INLINE_CODE.finditer(text):
99
+ if not _in_fence(m.start()):
100
+ ranges.append((m.start(), m.end()))
101
+ return ranges
102
+
103
+
104
+ def find_spans(text: str) -> list[Span]:
105
+ """Return the non-overlapping math spans in `text`, left to right.
106
+
107
+ Spans overlapping a fenced or inline code region are excluded -- code is
108
+ never math.
109
+ """
110
+ code = _code_ranges(text)
111
+
112
+ def _in_code(start: int, end: int) -> bool:
113
+ return any(start < b and a < end for a, b in code)
114
+
115
+ candidates: list[Span] = []
116
+ for pattern, display in _PATTERNS:
117
+ for m in pattern.finditer(text):
118
+ if _in_code(m.start(), m.end()):
119
+ continue
120
+ candidates.append(
121
+ Span(
122
+ start=m.start(),
123
+ end=m.end(),
124
+ body=m.group(1),
125
+ display=display,
126
+ raw=m.group(0),
127
+ )
128
+ )
129
+ # Resolve overlaps: sort by start, then prefer the longer match at the
130
+ # same start (display `$$` over inline `$`). Greedily accept spans that
131
+ # do not overlap one already accepted.
132
+ candidates.sort(key=lambda s: (s.start, -(s.end - s.start)))
133
+ chosen: list[Span] = []
134
+ claimed_until = -1
135
+ for span in candidates:
136
+ if span.start >= claimed_until:
137
+ chosen.append(span)
138
+ claimed_until = span.end
139
+ return chosen
140
+
141
+
142
+ def protect(text: str) -> tuple[str, list[Span]]:
143
+ """Replace math spans in `text` with inert placeholders.
144
+
145
+ Returns `(masked, tokens)`. Feed `masked` through the Markdown renderer
146
+ (the placeholders survive untouched), then call `restore` with the same
147
+ `tokens` to swap the rendered math back in.
148
+ """
149
+ spans = find_spans(text)
150
+ if not spans:
151
+ return text, []
152
+ out: list[str] = []
153
+ last = 0
154
+ for i, span in enumerate(spans):
155
+ out.append(text[last:span.start])
156
+ out.append(f"{_PH_OPEN}{i}{_PH_CLOSE}")
157
+ last = span.end
158
+ out.append(text[last:])
159
+ return "".join(out), spans
160
+
161
+
162
+ def restore(rendered: str, tokens: list[Span], render) -> str:
163
+ """Swap placeholders in `rendered` back for `render(span)`.
164
+
165
+ `render` is a callback `(span: Span) -> str` returning the HTML (or
166
+ text) to substitute for each math span; it is responsible for its own
167
+ escaping.
168
+ """
169
+ if not tokens:
170
+ return rendered
171
+
172
+ def _sub(m: re.Match[str]) -> str:
173
+ return render(tokens[int(m.group(1))])
174
+
175
+ return _PH_RE.sub(_sub, rendered)
176
+
177
+
178
+ def has_placeholder(text: str) -> bool:
179
+ """True if `text` still contains an unrestored math placeholder."""
180
+ return bool(_PH_RE.search(text))