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.
- scrollback/__init__.py +8 -0
- scrollback/assets/icon-256.png +0 -0
- scrollback/assets/icon.icns +0 -0
- scrollback/cli.py +1139 -0
- scrollback/clipboard.py +34 -0
- scrollback/export.py +293 -0
- scrollback/fts.py +307 -0
- scrollback/highlight.py +128 -0
- scrollback/katexbundle.py +81 -0
- scrollback/launcher_install.py +209 -0
- scrollback/launchers/scrollback.bat +19 -0
- scrollback/launchers/scrollback.command +19 -0
- scrollback/launchers/scrollback.desktop +10 -0
- scrollback/launchers/scrollback.sh +12 -0
- scrollback/mathspan.py +180 -0
- scrollback/minimd.py +205 -0
- scrollback/models.py +135 -0
- scrollback/serialize.py +83 -0
- scrollback/serverconfig.py +66 -0
- scrollback/sources/__init__.py +6 -0
- scrollback/sources/aider.py +244 -0
- scrollback/sources/base.py +117 -0
- scrollback/sources/claudecode.py +631 -0
- scrollback/sources/codex.py +281 -0
- scrollback/sources/opencode.py +357 -0
- scrollback/sources/registry.py +39 -0
- scrollback/store.py +384 -0
- scrollback/termrender.py +170 -0
- scrollback/web/__init__.py +1 -0
- scrollback/web/app.py +359 -0
- scrollback/web/static/app.js +1245 -0
- scrollback/web/static/apple-touch-icon.png +0 -0
- scrollback/web/static/favicon.png +0 -0
- scrollback/web/static/favicon.svg +41 -0
- scrollback/web/static/index.html +75 -0
- scrollback/web/static/style.css +628 -0
- scrollback/web/static/vendor/highlight.min.js +1213 -0
- scrollback/web/static/vendor/hljs-dark.min.css +10 -0
- scrollback/web/static/vendor/hljs-light.min.css +10 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- scrollback/web/static/vendor/katex/katex.min.css +1 -0
- scrollback/web/static/vendor/katex/katex.min.js +1 -0
- scrollback/web/static/vendor/marked.min.js +6 -0
- scrollback/web/static/vendor/purify.min.js +3 -0
- scrollback/webopen.py +96 -0
- scrollback-0.1.0.dist-info/METADATA +391 -0
- scrollback-0.1.0.dist-info/RECORD +69 -0
- scrollback-0.1.0.dist-info/WHEEL +4 -0
- scrollback-0.1.0.dist-info/entry_points.txt +4 -0
- scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
scrollback/highlight.py
ADDED
|
@@ -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))
|