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/cli.py
ADDED
|
@@ -0,0 +1,1139 @@
|
|
|
1
|
+
"""scrollback command-line interface.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
sources list detected agents and where they read from
|
|
5
|
+
list list sessions (newest first), with filters
|
|
6
|
+
show <selector> print a session transcript to the terminal
|
|
7
|
+
search <query> search across sessions
|
|
8
|
+
export <selector> render a session to markdown/json/html/text
|
|
9
|
+
copy <selector> copy a rendered session to the clipboard
|
|
10
|
+
|
|
11
|
+
Selectors accept a full id, a unique prefix, `source:id`, or `latest`.
|
|
12
|
+
All output is plain and pipe-friendly. Reads are strictly read-only.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
|
|
22
|
+
from . import __version__, clipboard, export, serverconfig
|
|
23
|
+
from .models import Session
|
|
24
|
+
from .sources import registry
|
|
25
|
+
from .store import Store
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _fmt_dt(dt: datetime | None) -> str:
|
|
29
|
+
return dt.strftime("%Y-%m-%d %H:%M") if dt else "?"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _eprint(*a: object) -> None:
|
|
33
|
+
print(*a, file=sys.stderr)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _nonneg_int(s: str) -> int:
|
|
37
|
+
"""argparse type: a non-negative integer (rejects negatives)."""
|
|
38
|
+
try:
|
|
39
|
+
v = int(s)
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise argparse.ArgumentTypeError(f"expected an integer, got {s!r}") from exc
|
|
42
|
+
if v < 0:
|
|
43
|
+
raise argparse.ArgumentTypeError(f"must be >= 0, got {v}")
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _positive_int(s: str) -> int:
|
|
48
|
+
"""argparse type: a positive integer (>= 1)."""
|
|
49
|
+
v = _nonneg_int(s)
|
|
50
|
+
if v < 1:
|
|
51
|
+
raise argparse.ArgumentTypeError(f"must be >= 1, got {v}")
|
|
52
|
+
return v
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _parse_date(s: str | None) -> datetime | None:
|
|
56
|
+
"""Parse a CLI date/datetime into an aware UTC datetime.
|
|
57
|
+
|
|
58
|
+
Accepts YYYY-MM-DD or full ISO-8601. Naive values are treated as UTC.
|
|
59
|
+
Raises argparse.ArgumentTypeError on bad input so the CLI reports it.
|
|
60
|
+
"""
|
|
61
|
+
if not s:
|
|
62
|
+
return None
|
|
63
|
+
raw = s.strip()
|
|
64
|
+
try:
|
|
65
|
+
if len(raw) == 10: # YYYY-MM-DD
|
|
66
|
+
dt = datetime.strptime(raw, "%Y-%m-%d")
|
|
67
|
+
else:
|
|
68
|
+
iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
|
69
|
+
dt = datetime.fromisoformat(iso)
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
raise argparse.ArgumentTypeError(
|
|
72
|
+
f"invalid date {s!r}; use YYYY-MM-DD or ISO-8601"
|
|
73
|
+
) from exc
|
|
74
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fmt_tokens(n: int | None) -> str:
|
|
78
|
+
"""Compact token count: 12345 -> '12.3k', 2100000 -> '2.1M'."""
|
|
79
|
+
if n is None:
|
|
80
|
+
return ""
|
|
81
|
+
if n < 1000:
|
|
82
|
+
return str(n)
|
|
83
|
+
if n < 1_000_000:
|
|
84
|
+
return f"{n / 1000:.1f}k"
|
|
85
|
+
return f"{n / 1_000_000:.1f}M"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _fmt_cost(c: float | None) -> str:
|
|
89
|
+
return f"${c:.2f}" if c else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# -- subcommand implementations -------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_sources(args: argparse.Namespace) -> int:
|
|
96
|
+
any_found = False
|
|
97
|
+
for src in registry.all_sources():
|
|
98
|
+
avail = src.is_available()
|
|
99
|
+
any_found = any_found or avail
|
|
100
|
+
loc = src.location()
|
|
101
|
+
status = "available" if avail else "not found"
|
|
102
|
+
print(f"{src.name:12} {status:12} {loc if loc else ''}")
|
|
103
|
+
return 0 if any_found else 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
107
|
+
"""Print a diagnostics summary: sources, index, optional features, env."""
|
|
108
|
+
import platform
|
|
109
|
+
|
|
110
|
+
from . import __version__, fts
|
|
111
|
+
|
|
112
|
+
print(f"scrollback {__version__}")
|
|
113
|
+
print(f"python {platform.python_version()} ({platform.system()} {platform.machine()})")
|
|
114
|
+
import sqlite3
|
|
115
|
+
|
|
116
|
+
print(f"sqlite {sqlite3.sqlite_version}")
|
|
117
|
+
print()
|
|
118
|
+
|
|
119
|
+
print("sources:")
|
|
120
|
+
store = Store()
|
|
121
|
+
any_avail = False
|
|
122
|
+
for src in registry.all_sources():
|
|
123
|
+
avail = src.is_available()
|
|
124
|
+
any_avail = any_avail or avail
|
|
125
|
+
loc = src.location()
|
|
126
|
+
if avail:
|
|
127
|
+
try:
|
|
128
|
+
n = len(list(src.list_sessions()))
|
|
129
|
+
except Exception:
|
|
130
|
+
n = "?"
|
|
131
|
+
print(f" {src.name:12} available {n} sessions {loc}")
|
|
132
|
+
else:
|
|
133
|
+
print(f" {src.name:12} not found (looked in default location)")
|
|
134
|
+
if not any_avail:
|
|
135
|
+
print(" (none detected -- set SCROLLBACK_OPENCODE_DB / SCROLLBACK_CLAUDE_DIR")
|
|
136
|
+
print(" if your data lives outside the default locations)")
|
|
137
|
+
print()
|
|
138
|
+
|
|
139
|
+
print("optional features:")
|
|
140
|
+
print(f" full-text search (FTS5): {'yes' if fts.fts5_available() else 'no'}")
|
|
141
|
+
index = fts.FtsIndex()
|
|
142
|
+
if index.exists():
|
|
143
|
+
s = index.stats()
|
|
144
|
+
stale = "stale" if index.is_stale(store) else "fresh"
|
|
145
|
+
print(f" search index: built ({s['sessions']} sessions, {s['parts']} parts, {stale})")
|
|
146
|
+
print(f" {index.path}")
|
|
147
|
+
else:
|
|
148
|
+
print(" search index: not built (run 'scrollback index' for faster search)")
|
|
149
|
+
print(f" native window (pywebview): {'yes' if _pywebview_available() else 'no'}")
|
|
150
|
+
print(f" rich terminal output: {'yes' if _rich_available() else 'no'}")
|
|
151
|
+
print(f" web app (fastapi/uvicorn): {'yes' if _web_available() else 'no'}")
|
|
152
|
+
|
|
153
|
+
return 0 if any_avail else 1
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _rich_available() -> bool:
|
|
157
|
+
import importlib.util
|
|
158
|
+
|
|
159
|
+
return importlib.util.find_spec("rich") is not None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _web_available() -> bool:
|
|
163
|
+
import importlib.util
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
importlib.util.find_spec("fastapi") is not None
|
|
167
|
+
and importlib.util.find_spec("uvicorn") is not None
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_resume(args: argparse.Namespace) -> int:
|
|
172
|
+
store = _make_store(args)
|
|
173
|
+
src, full = store._resolve(args.selector, getattr(args, "source", None))
|
|
174
|
+
if src is None or full is None:
|
|
175
|
+
_eprint(f"session not found: {args.selector}")
|
|
176
|
+
return 1
|
|
177
|
+
sess = src.load_session_meta(full)
|
|
178
|
+
if sess is None:
|
|
179
|
+
_eprint(f"session not found: {args.selector}")
|
|
180
|
+
return 1
|
|
181
|
+
cmd = src.resume_command(sess)
|
|
182
|
+
if not cmd:
|
|
183
|
+
_eprint(f"{src.name} has no by-id resume command; open the project and "
|
|
184
|
+
"start the agent there:")
|
|
185
|
+
if sess.directory:
|
|
186
|
+
_eprint(f" cd {sess.directory!r} && {src.name}")
|
|
187
|
+
return 1
|
|
188
|
+
if args.copy:
|
|
189
|
+
if clipboard.copy(cmd):
|
|
190
|
+
_eprint("resume command copied to clipboard")
|
|
191
|
+
else:
|
|
192
|
+
_eprint("clipboard unavailable; printing instead")
|
|
193
|
+
print(cmd)
|
|
194
|
+
else:
|
|
195
|
+
print(cmd)
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_stats(args: argparse.Namespace) -> int:
|
|
200
|
+
store = _make_store(args)
|
|
201
|
+
if not store.sources:
|
|
202
|
+
_no_sessions_help(store)
|
|
203
|
+
return 1
|
|
204
|
+
st = store.stats()
|
|
205
|
+
if args.json:
|
|
206
|
+
import json
|
|
207
|
+
|
|
208
|
+
print(json.dumps({
|
|
209
|
+
"sessions": st.sessions,
|
|
210
|
+
"per_source": st.per_source,
|
|
211
|
+
"total_messages": st.total_messages,
|
|
212
|
+
"total_tokens_input": st.total_tokens_input,
|
|
213
|
+
"total_tokens_output": st.total_tokens_output,
|
|
214
|
+
"total_cost": st.total_cost,
|
|
215
|
+
"oldest": st.oldest.isoformat() if st.oldest else None,
|
|
216
|
+
"newest": st.newest.isoformat() if st.newest else None,
|
|
217
|
+
"top_projects": sorted(
|
|
218
|
+
st.per_project.items(), key=lambda kv: kv[1], reverse=True
|
|
219
|
+
)[:args.top],
|
|
220
|
+
}, indent=2, ensure_ascii=False))
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
span = ""
|
|
224
|
+
if st.oldest and st.newest:
|
|
225
|
+
span = f" ({_fmt_dt(st.oldest)} -> {_fmt_dt(st.newest)})"
|
|
226
|
+
print(f"sessions: {st.sessions}{span}")
|
|
227
|
+
print(f"messages: {st.total_messages}")
|
|
228
|
+
if st.total_tokens_input or st.total_tokens_output:
|
|
229
|
+
print(f"tokens: {_fmt_tokens(st.total_tokens_input)} in / "
|
|
230
|
+
f"{_fmt_tokens(st.total_tokens_output)} out")
|
|
231
|
+
if st.total_cost:
|
|
232
|
+
print(f"cost: ${st.total_cost:.2f}")
|
|
233
|
+
print()
|
|
234
|
+
print("by source:")
|
|
235
|
+
for name, count in sorted(st.per_source.items(), key=lambda kv: kv[1], reverse=True):
|
|
236
|
+
print(f" {name:12} {count}")
|
|
237
|
+
if st.per_project:
|
|
238
|
+
print()
|
|
239
|
+
print(f"top {args.top} projects:")
|
|
240
|
+
top = sorted(st.per_project.items(), key=lambda kv: kv[1], reverse=True)[:args.top]
|
|
241
|
+
for path, count in top:
|
|
242
|
+
base = path.rstrip("/").split("/")[-1] or path
|
|
243
|
+
print(f" {count:>5} {base}")
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_index(args: argparse.Namespace) -> int:
|
|
248
|
+
from . import fts
|
|
249
|
+
|
|
250
|
+
index = fts.FtsIndex()
|
|
251
|
+
if args.clear:
|
|
252
|
+
if index.path.exists():
|
|
253
|
+
index.path.unlink()
|
|
254
|
+
_eprint(f"removed index {index.path}")
|
|
255
|
+
else:
|
|
256
|
+
_eprint("no index to remove")
|
|
257
|
+
return 0
|
|
258
|
+
if args.stats:
|
|
259
|
+
if not index.exists():
|
|
260
|
+
_eprint("no index built yet; run 'scrollback index' to build one")
|
|
261
|
+
return 1
|
|
262
|
+
s = index.stats()
|
|
263
|
+
print(f"index: {index.path}")
|
|
264
|
+
print(f"sessions: {s['sessions']} parts: {s['parts']}")
|
|
265
|
+
return 0
|
|
266
|
+
# Build / update.
|
|
267
|
+
if not fts.fts5_available():
|
|
268
|
+
_eprint(
|
|
269
|
+
"full-text search needs SQLite FTS5, which this Python's SQLite "
|
|
270
|
+
"was not built with. Search still works without an index (lexical "
|
|
271
|
+
"scan); no action needed."
|
|
272
|
+
)
|
|
273
|
+
return 1
|
|
274
|
+
store = Store()
|
|
275
|
+
if not store.sources:
|
|
276
|
+
_eprint("no sources available to index")
|
|
277
|
+
return 1
|
|
278
|
+
_eprint(f"building index at {index.path} ...")
|
|
279
|
+
|
|
280
|
+
def progress(done: int, total: int) -> None:
|
|
281
|
+
if done == total or done % 25 == 0:
|
|
282
|
+
_eprint(f" {done}/{total} sessions", )
|
|
283
|
+
|
|
284
|
+
stats = index.sync(store, progress=progress)
|
|
285
|
+
_eprint(
|
|
286
|
+
f"done: +{stats['added']} added, {stats['updated']} updated, "
|
|
287
|
+
f"{stats['removed']} removed, {stats['unchanged']} unchanged"
|
|
288
|
+
)
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class _BadSource(Exception):
|
|
293
|
+
"""Raised when an unknown --source name is given."""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _make_store(args: argparse.Namespace) -> Store:
|
|
297
|
+
store = Store()
|
|
298
|
+
name = getattr(args, "source", None)
|
|
299
|
+
if name:
|
|
300
|
+
known = {s.name for s in registry.all_sources()}
|
|
301
|
+
if name not in known:
|
|
302
|
+
raise _BadSource(
|
|
303
|
+
f"unknown source {name!r}; available: {', '.join(sorted(known))}"
|
|
304
|
+
)
|
|
305
|
+
store = store.with_sources([name])
|
|
306
|
+
return store
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
310
|
+
store = _make_store(args)
|
|
311
|
+
offset = args.offset
|
|
312
|
+
if args.page and args.page > 1:
|
|
313
|
+
offset = (args.page - 1) * args.limit
|
|
314
|
+
sessions = store.list_sessions(
|
|
315
|
+
directory=args.dir,
|
|
316
|
+
query=args.query,
|
|
317
|
+
since=args.since,
|
|
318
|
+
until=args.until,
|
|
319
|
+
limit=args.limit,
|
|
320
|
+
offset=offset,
|
|
321
|
+
fold_subagents=not args.no_fold,
|
|
322
|
+
)
|
|
323
|
+
if not sessions:
|
|
324
|
+
_no_sessions_help(store)
|
|
325
|
+
return 1
|
|
326
|
+
if args.json:
|
|
327
|
+
import json
|
|
328
|
+
|
|
329
|
+
def row(s: Session) -> dict[str, object]:
|
|
330
|
+
return {
|
|
331
|
+
"id": s.id,
|
|
332
|
+
"source": s.source,
|
|
333
|
+
"title": s.title,
|
|
334
|
+
"directory": s.directory,
|
|
335
|
+
"updated": s.updated.isoformat() if s.updated else None,
|
|
336
|
+
"model": s.model,
|
|
337
|
+
"agent": s.agent,
|
|
338
|
+
"messages": s.message_count,
|
|
339
|
+
"cost": s.cost,
|
|
340
|
+
"tokens_input": s.tokens_input,
|
|
341
|
+
"tokens_output": s.tokens_output,
|
|
342
|
+
"parent_id": s.parent_id,
|
|
343
|
+
"children": [row(c) for c in s.children],
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
print(json.dumps([row(s) for s in sessions], indent=2, ensure_ascii=False))
|
|
347
|
+
return 0
|
|
348
|
+
|
|
349
|
+
from . import termrender
|
|
350
|
+
|
|
351
|
+
if termrender.available(force=_color_force(args)):
|
|
352
|
+
termrender.render_list(sessions, show_usage=args.usage)
|
|
353
|
+
else:
|
|
354
|
+
if args.usage:
|
|
355
|
+
_eprint(
|
|
356
|
+
f"{'source':10} {'id':13} {'updated':16} {'msgs':>9} "
|
|
357
|
+
f"{'cost':>7} {'tok in/out':>14} title"
|
|
358
|
+
)
|
|
359
|
+
_print_list(sessions, show_usage=args.usage)
|
|
360
|
+
if offset:
|
|
361
|
+
_eprint(f"(offset {offset})")
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _color_force(args: argparse.Namespace) -> bool | None:
|
|
366
|
+
"""Translate --plain into a force flag for termrender.available()."""
|
|
367
|
+
if getattr(args, "plain", False):
|
|
368
|
+
return False
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _print_list(sessions: list[Session], *, show_usage: bool, indent: str = "") -> None:
|
|
373
|
+
for s in sessions:
|
|
374
|
+
msgs = f"{s.message_count:>4}" if s.message_count is not None else " ?"
|
|
375
|
+
usage = ""
|
|
376
|
+
if show_usage:
|
|
377
|
+
toks = f"{_fmt_tokens(s.tokens_input)}/{_fmt_tokens(s.tokens_output)}"
|
|
378
|
+
cost = _fmt_cost(s.cost)
|
|
379
|
+
usage = f" {cost:>7} {toks:>14}"
|
|
380
|
+
marker = "\u2514 " if indent else ""
|
|
381
|
+
print(
|
|
382
|
+
f"{indent}{marker}{s.source:10} {s.short_id:13} {_fmt_dt(s.updated):16} "
|
|
383
|
+
f"{msgs} msgs{usage} {s.title}"
|
|
384
|
+
)
|
|
385
|
+
if s.children:
|
|
386
|
+
_print_list(list(s.children), show_usage=show_usage, indent=indent + " ")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _resolve(store: Store, args: argparse.Namespace) -> Session | None:
|
|
390
|
+
return store.load_session(args.selector, source=getattr(args, "source", None))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
394
|
+
store = _make_store(args)
|
|
395
|
+
sess = _resolve(store, args)
|
|
396
|
+
if sess is None:
|
|
397
|
+
_eprint(f"session not found: {args.selector}")
|
|
398
|
+
return 1
|
|
399
|
+
from . import termrender
|
|
400
|
+
|
|
401
|
+
if termrender.available(force=_color_force(args)):
|
|
402
|
+
termrender.render_transcript(
|
|
403
|
+
sess,
|
|
404
|
+
include_reasoning=args.reasoning,
|
|
405
|
+
include_tools=not args.no_tools,
|
|
406
|
+
markdown=not args.no_markdown,
|
|
407
|
+
)
|
|
408
|
+
return 0
|
|
409
|
+
text = export.to_text(
|
|
410
|
+
sess,
|
|
411
|
+
include_reasoning=args.reasoning,
|
|
412
|
+
include_tools=not args.no_tools,
|
|
413
|
+
)
|
|
414
|
+
print(text)
|
|
415
|
+
return 0
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _no_sessions_help(store: Store) -> None:
|
|
419
|
+
"""Explain why a list/search is empty: no sources vs. just no matches."""
|
|
420
|
+
if not store.sources:
|
|
421
|
+
_eprint("no AI-agent sessions found -- no supported sources detected.")
|
|
422
|
+
_eprint("scrollback reads, by default:")
|
|
423
|
+
_eprint(" opencode ~/.local/share/opencode/opencode.db")
|
|
424
|
+
_eprint(" claudecode ~/.claude/projects/")
|
|
425
|
+
_eprint("Override with SCROLLBACK_OPENCODE_DB / SCROLLBACK_CLAUDE_DIR.")
|
|
426
|
+
_eprint("Run 'scrollback doctor' to see what was detected.")
|
|
427
|
+
else:
|
|
428
|
+
_eprint("no sessions matched.")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _maybe_warn_stale_index(store: Store) -> None:
|
|
432
|
+
"""Hint (once) that the FTS index is stale, so results may miss new
|
|
433
|
+
sessions. Cheap mtime check; no-op when there's no index."""
|
|
434
|
+
try:
|
|
435
|
+
from . import fts
|
|
436
|
+
|
|
437
|
+
index = fts.FtsIndex()
|
|
438
|
+
if index.exists() and index.is_stale(store):
|
|
439
|
+
_eprint("note: search index looks out of date; run 'scrollback index' to refresh")
|
|
440
|
+
except Exception: # never let a hint break search
|
|
441
|
+
pass
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
445
|
+
store = _make_store(args)
|
|
446
|
+
hits = list(
|
|
447
|
+
store.search(
|
|
448
|
+
args.query,
|
|
449
|
+
directory=args.dir,
|
|
450
|
+
since=args.since,
|
|
451
|
+
until=args.until,
|
|
452
|
+
limit=args.limit,
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
_maybe_warn_stale_index(store)
|
|
456
|
+
if not hits:
|
|
457
|
+
_eprint("no matches")
|
|
458
|
+
return 1
|
|
459
|
+
if args.json:
|
|
460
|
+
import json
|
|
461
|
+
|
|
462
|
+
rows = [
|
|
463
|
+
{
|
|
464
|
+
"source": h.session.source,
|
|
465
|
+
"session_id": h.session.id,
|
|
466
|
+
"title": h.session.title,
|
|
467
|
+
"message_id": h.message.id,
|
|
468
|
+
"role": h.message.role,
|
|
469
|
+
"part_type": h.part.type,
|
|
470
|
+
"snippet": h.snippet,
|
|
471
|
+
}
|
|
472
|
+
for h in hits
|
|
473
|
+
]
|
|
474
|
+
print(json.dumps(rows, indent=2, ensure_ascii=False))
|
|
475
|
+
return 0
|
|
476
|
+
from . import termrender
|
|
477
|
+
|
|
478
|
+
if termrender.available(force=_color_force(args)):
|
|
479
|
+
termrender.render_search(hits, args.query)
|
|
480
|
+
else:
|
|
481
|
+
for h in hits:
|
|
482
|
+
print(f"{h.session.source}:{h.session.short_id} [{h.message.role}] {h.snippet}")
|
|
483
|
+
return 0
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
487
|
+
store = _make_store(args)
|
|
488
|
+
sess = _resolve(store, args)
|
|
489
|
+
if sess is None:
|
|
490
|
+
_eprint(f"session not found: {args.selector}")
|
|
491
|
+
return 1
|
|
492
|
+
kwargs = _render_kwargs(args.format, args)
|
|
493
|
+
rendered = export.render(sess, args.format, **kwargs)
|
|
494
|
+
if args.output:
|
|
495
|
+
try:
|
|
496
|
+
with open(args.output, "w", encoding="utf-8") as fh:
|
|
497
|
+
fh.write(rendered)
|
|
498
|
+
except OSError as exc:
|
|
499
|
+
_eprint(f"could not write {args.output}: {exc}")
|
|
500
|
+
return 1
|
|
501
|
+
_eprint(f"wrote {args.output}")
|
|
502
|
+
else:
|
|
503
|
+
print(rendered)
|
|
504
|
+
return 0
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def cmd_copy(args: argparse.Namespace) -> int:
|
|
508
|
+
store = _make_store(args)
|
|
509
|
+
sess = _resolve(store, args)
|
|
510
|
+
if sess is None:
|
|
511
|
+
_eprint(f"session not found: {args.selector}")
|
|
512
|
+
return 1
|
|
513
|
+
kwargs = _render_kwargs(args.format, args)
|
|
514
|
+
rendered = export.render(sess, args.format, **kwargs)
|
|
515
|
+
if clipboard.copy(rendered):
|
|
516
|
+
_eprint(f"copied {len(rendered)} chars ({args.format}) to clipboard")
|
|
517
|
+
return 0
|
|
518
|
+
_eprint("clipboard unavailable; printing instead")
|
|
519
|
+
print(rendered)
|
|
520
|
+
return 1
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _render_kwargs(fmt: str, args: argparse.Namespace) -> dict[str, object]:
|
|
524
|
+
if fmt == "json":
|
|
525
|
+
return {}
|
|
526
|
+
return {
|
|
527
|
+
"include_reasoning": args.reasoning,
|
|
528
|
+
"include_tools": not args.no_tools,
|
|
529
|
+
"math": getattr(args, "math", "raw"),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def cmd_web(args: argparse.Namespace) -> int:
|
|
534
|
+
try:
|
|
535
|
+
import uvicorn
|
|
536
|
+
except ModuleNotFoundError:
|
|
537
|
+
_eprint(
|
|
538
|
+
"the web app needs extra dependencies. install with:\n"
|
|
539
|
+
' pip install "scrollback[web]"\n'
|
|
540
|
+
"or:\n"
|
|
541
|
+
" pip install fastapi uvicorn"
|
|
542
|
+
)
|
|
543
|
+
return 1
|
|
544
|
+
from .web.app import create_app
|
|
545
|
+
|
|
546
|
+
# Resolve the actual port to bind: honour the requested one, but fall back
|
|
547
|
+
# to the next free port if it's taken (unless --strict-port). This keeps a
|
|
548
|
+
# single source of truth and avoids "started on a different port than the
|
|
549
|
+
# URL we opened" bugs -- the resolved port is used everywhere below.
|
|
550
|
+
try:
|
|
551
|
+
port = serverconfig.resolve_port(args.host, args.port, strict=args.strict_port)
|
|
552
|
+
except OSError as exc:
|
|
553
|
+
_eprint(str(exc))
|
|
554
|
+
return 1
|
|
555
|
+
if port != args.port:
|
|
556
|
+
_eprint(f"port {args.port} busy; using {port} instead")
|
|
557
|
+
args.port = port # so downstream (app-window mode) sees the real port
|
|
558
|
+
|
|
559
|
+
url = f"http://{args.host}:{port}"
|
|
560
|
+
|
|
561
|
+
# If binding to a non-loopback address, warn loudly: the API is
|
|
562
|
+
# unauthenticated and would expose all local AI history to the network.
|
|
563
|
+
# Add the chosen host to the Host-guard allowlist so it can be reached.
|
|
564
|
+
loopback = {"127.0.0.1", "localhost", "::1", "0.0.0.0", ""}
|
|
565
|
+
allowed_hosts = None
|
|
566
|
+
if args.host not in loopback:
|
|
567
|
+
_eprint(f"WARNING: binding to non-loopback host {args.host!r}; the read-only "
|
|
568
|
+
"API will be reachable from the network with no authentication.")
|
|
569
|
+
allowed_hosts = [args.host]
|
|
570
|
+
elif args.host == "0.0.0.0":
|
|
571
|
+
_eprint("WARNING: binding to 0.0.0.0 exposes the API on all interfaces.")
|
|
572
|
+
allowed_hosts = [] # can't know the external hostname; disable host guard
|
|
573
|
+
|
|
574
|
+
# Desktop "app window" mode: a true native window via pywebview. Closing
|
|
575
|
+
# the window quits the process -> server stops -> port is freed, and there
|
|
576
|
+
# is no terminal. If pywebview isn't available, fall back to a browser
|
|
577
|
+
# window (with heartbeat auto-shutdown) instead of failing.
|
|
578
|
+
if getattr(args, "app", False):
|
|
579
|
+
if _pywebview_available():
|
|
580
|
+
return _run_app_window(create_app(allowed_hosts=allowed_hosts), args, url)
|
|
581
|
+
_eprint("native window unavailable (pywebview not installed/usable); "
|
|
582
|
+
"opening a browser window with auto-shutdown instead")
|
|
583
|
+
args.window = True
|
|
584
|
+
args.auto_shutdown = True # browser fallback: stop server when window closes
|
|
585
|
+
|
|
586
|
+
# Optional heartbeat auto-shutdown: stop the server shortly after the
|
|
587
|
+
# browser window/tab is closed (so the port is freed without Ctrl-C).
|
|
588
|
+
server_holder: dict[str, object] = {}
|
|
589
|
+
|
|
590
|
+
def _on_idle() -> None:
|
|
591
|
+
srv = server_holder.get("server")
|
|
592
|
+
if srv is not None:
|
|
593
|
+
srv.should_exit = True
|
|
594
|
+
|
|
595
|
+
if getattr(args, "auto_shutdown", False):
|
|
596
|
+
app = create_app(on_idle=_on_idle, idle_timeout=10.0, allowed_hosts=allowed_hosts)
|
|
597
|
+
else:
|
|
598
|
+
app = create_app(allowed_hosts=allowed_hosts)
|
|
599
|
+
|
|
600
|
+
_eprint(f"scrollback web -> {url} (read-only; Ctrl-C to stop)")
|
|
601
|
+
_maybe_launcher_hint(args)
|
|
602
|
+
if not args.no_browser:
|
|
603
|
+
import threading
|
|
604
|
+
|
|
605
|
+
from . import webopen
|
|
606
|
+
|
|
607
|
+
# Open after a short delay so the server is accepting connections.
|
|
608
|
+
# `--window` asks for a standalone window; default opens a tab.
|
|
609
|
+
opener = webopen.open_window if args.window else _open_tab
|
|
610
|
+
threading.Timer(0.8, lambda: opener(url)).start()
|
|
611
|
+
|
|
612
|
+
_background_index_refresh()
|
|
613
|
+
|
|
614
|
+
server = uvicorn.Server(
|
|
615
|
+
uvicorn.Config(app, host=args.host, port=port, log_level="warning")
|
|
616
|
+
)
|
|
617
|
+
server_holder["server"] = server
|
|
618
|
+
server.run()
|
|
619
|
+
return 0
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _maybe_launcher_hint(args: argparse.Namespace) -> None:
|
|
623
|
+
"""Print a one-time tip about the double-clickable launcher.
|
|
624
|
+
|
|
625
|
+
Shown only on an interactive terminal and only for the plain `web`
|
|
626
|
+
command -- not the native `--app` / `--window` modes (already launched
|
|
627
|
+
that way) -- so it never gets in the way of scripted or app usage.
|
|
628
|
+
"""
|
|
629
|
+
import sys
|
|
630
|
+
|
|
631
|
+
if getattr(args, "app", False) or getattr(args, "window", False):
|
|
632
|
+
return
|
|
633
|
+
if not sys.stderr.isatty():
|
|
634
|
+
return
|
|
635
|
+
_eprint(
|
|
636
|
+
"tip: 'scrollback install-launcher --app-bundle' adds a "
|
|
637
|
+
"double-clickable app so you can skip the terminal next time."
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _background_index_refresh() -> None:
|
|
642
|
+
"""If an FTS index exists and is stale, refresh it in a daemon thread.
|
|
643
|
+
|
|
644
|
+
Opt-in by virtue of an index existing; runs off the request path so the
|
|
645
|
+
UI is usable immediately and shutdown isn't blocked.
|
|
646
|
+
"""
|
|
647
|
+
import threading
|
|
648
|
+
|
|
649
|
+
def work() -> None:
|
|
650
|
+
try:
|
|
651
|
+
from . import fts
|
|
652
|
+
|
|
653
|
+
index = fts.FtsIndex()
|
|
654
|
+
store = Store()
|
|
655
|
+
if index.exists() and index.is_stale(store):
|
|
656
|
+
index.sync(store)
|
|
657
|
+
except Exception:
|
|
658
|
+
pass # best-effort; search still works via the existing index
|
|
659
|
+
|
|
660
|
+
threading.Thread(target=work, daemon=True).start()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _pywebview_available() -> bool:
|
|
664
|
+
import importlib.util
|
|
665
|
+
|
|
666
|
+
return importlib.util.find_spec("webview") is not None
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _app_icon_path() -> str | None:
|
|
670
|
+
"""Extract the bundled PNG window icon to a temp file and return its path.
|
|
671
|
+
|
|
672
|
+
Used cross-platform: pywebview's `icon` wants a filesystem path (Windows
|
|
673
|
+
taskbar, GTK/Qt window icon, macOS Dock). Our icon ships as package data,
|
|
674
|
+
so we materialize it once per run.
|
|
675
|
+
"""
|
|
676
|
+
import tempfile
|
|
677
|
+
from importlib import resources
|
|
678
|
+
|
|
679
|
+
try:
|
|
680
|
+
data = resources.files("scrollback.assets").joinpath("icon-256.png").read_bytes()
|
|
681
|
+
except (OSError, ModuleNotFoundError, FileNotFoundError):
|
|
682
|
+
return None
|
|
683
|
+
path = os.path.join(tempfile.gettempdir(), "scrollback-icon.png")
|
|
684
|
+
try:
|
|
685
|
+
with open(path, "wb") as fh:
|
|
686
|
+
fh.write(data)
|
|
687
|
+
except OSError:
|
|
688
|
+
return None
|
|
689
|
+
return path
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def _brand_macos_app() -> None:
|
|
693
|
+
"""Brand the macOS app: menu name 'scrollback', a rich standard About
|
|
694
|
+
panel (version + description), and the Dock icon.
|
|
695
|
+
|
|
696
|
+
The menu-bar name and the standard About panel both read from the running
|
|
697
|
+
process's bundle info dict. When we run unbundled (or after the .app
|
|
698
|
+
runner exec's python), that's 'Python' with an empty About. We patch the
|
|
699
|
+
main bundle's info dict via PyObjC (already a pywebview dep on macOS) so
|
|
700
|
+
pywebview's *default* app menu -- the one it always creates -- gets the
|
|
701
|
+
right name and a useful About, instead of adding a second custom menu.
|
|
702
|
+
"""
|
|
703
|
+
if sys.platform != "darwin":
|
|
704
|
+
return
|
|
705
|
+
from . import __version__
|
|
706
|
+
|
|
707
|
+
try:
|
|
708
|
+
from Foundation import NSBundle # type: ignore
|
|
709
|
+
|
|
710
|
+
bundle = NSBundle.mainBundle()
|
|
711
|
+
info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
|
|
712
|
+
if info is not None:
|
|
713
|
+
info["CFBundleName"] = "scrollback"
|
|
714
|
+
info["CFBundleDisplayName"] = "scrollback"
|
|
715
|
+
# Fields the standard About panel reads:
|
|
716
|
+
info["CFBundleShortVersionString"] = __version__
|
|
717
|
+
info["CFBundleVersion"] = __version__
|
|
718
|
+
info["NSHumanReadableCopyright"] = (
|
|
719
|
+
"Navigate, search, copy, and export your AI coding-agent "
|
|
720
|
+
"sessions. Local-first and read-only."
|
|
721
|
+
)
|
|
722
|
+
except Exception:
|
|
723
|
+
pass # best-effort cosmetic; never fail the launch
|
|
724
|
+
# Dock icon (independent of the menu name).
|
|
725
|
+
icon = _app_icon_path()
|
|
726
|
+
if icon:
|
|
727
|
+
try:
|
|
728
|
+
from AppKit import NSApplication, NSImage # type: ignore
|
|
729
|
+
|
|
730
|
+
img = NSImage.alloc().initByReferencingFile_(icon)
|
|
731
|
+
if img is not None:
|
|
732
|
+
NSApplication.sharedApplication().setApplicationIconImage_(img)
|
|
733
|
+
except Exception:
|
|
734
|
+
pass
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# Keep a reference so the Objective-C handler isn't garbage-collected while the
|
|
738
|
+
# menu item points at it.
|
|
739
|
+
_about_handler = None
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _install_macos_about_link() -> None:
|
|
743
|
+
"""Re-point the standard 'About' menu item to a panel that includes a
|
|
744
|
+
clickable link to the project repo.
|
|
745
|
+
|
|
746
|
+
Runs after the Cocoa menu exists (via webview.start(func=...)). Replaces
|
|
747
|
+
the About item's action with one that calls
|
|
748
|
+
orderFrontStandardAboutPanelWithOptions: and passes a Credits attributed
|
|
749
|
+
string containing a real hyperlink.
|
|
750
|
+
"""
|
|
751
|
+
global _about_handler
|
|
752
|
+
if sys.platform != "darwin":
|
|
753
|
+
return
|
|
754
|
+
try:
|
|
755
|
+
from AppKit import ( # type: ignore
|
|
756
|
+
NSApplication,
|
|
757
|
+
NSAttributedString,
|
|
758
|
+
NSFont,
|
|
759
|
+
NSFontAttributeName,
|
|
760
|
+
)
|
|
761
|
+
from Foundation import NSObject, NSURL # type: ignore
|
|
762
|
+
|
|
763
|
+
from . import __version__
|
|
764
|
+
|
|
765
|
+
repo = "https://github.com/a-attia/scrollback"
|
|
766
|
+
credits = NSAttributedString.alloc().initWithString_attributes_(
|
|
767
|
+
"Navigate, search, copy, and export your AI coding-agent sessions.\n"
|
|
768
|
+
"Local-first and read-only.\n\n",
|
|
769
|
+
{NSFontAttributeName: NSFont.systemFontOfSize_(11)},
|
|
770
|
+
)
|
|
771
|
+
link = NSAttributedString.alloc().initWithString_attributes_(
|
|
772
|
+
repo,
|
|
773
|
+
{
|
|
774
|
+
"NSLink": NSURL.URLWithString_(repo),
|
|
775
|
+
NSFontAttributeName: NSFont.systemFontOfSize_(11),
|
|
776
|
+
},
|
|
777
|
+
)
|
|
778
|
+
full = credits.mutableCopy()
|
|
779
|
+
full.appendAttributedString_(link)
|
|
780
|
+
|
|
781
|
+
class _AboutHandler(NSObject):
|
|
782
|
+
def showAbout_(self, _sender):
|
|
783
|
+
opts = {
|
|
784
|
+
"Credits": full,
|
|
785
|
+
"ApplicationName": "scrollback",
|
|
786
|
+
"Version": __version__,
|
|
787
|
+
"ApplicationVersion": __version__,
|
|
788
|
+
}
|
|
789
|
+
NSApplication.sharedApplication().orderFrontStandardAboutPanelWithOptions_(opts)
|
|
790
|
+
|
|
791
|
+
_about_handler = _AboutHandler.alloc().init()
|
|
792
|
+
|
|
793
|
+
# Find the About item in the app menu (first menu) and rewire it.
|
|
794
|
+
app = NSApplication.sharedApplication()
|
|
795
|
+
main_menu = app.mainMenu()
|
|
796
|
+
if main_menu is None or main_menu.numberOfItems() == 0:
|
|
797
|
+
return
|
|
798
|
+
app_menu = main_menu.itemAtIndex_(0).submenu()
|
|
799
|
+
for i in range(app_menu.numberOfItems()):
|
|
800
|
+
item = app_menu.itemAtIndex_(i)
|
|
801
|
+
action = item.action()
|
|
802
|
+
if action is not None and str(action) == "orderFrontStandardAboutPanel:":
|
|
803
|
+
item.setTarget_(_about_handler)
|
|
804
|
+
item.setAction_(b"showAbout:")
|
|
805
|
+
break
|
|
806
|
+
except Exception:
|
|
807
|
+
pass # best-effort; the plain About still works
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _open_tab(url: str) -> str:
|
|
811
|
+
import webbrowser
|
|
812
|
+
|
|
813
|
+
return "tab" if webbrowser.open(url) else "failed"
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def cmd_install_launcher(args: argparse.Namespace) -> int:
|
|
817
|
+
from . import launcher_install
|
|
818
|
+
|
|
819
|
+
dest = None
|
|
820
|
+
if args.dest:
|
|
821
|
+
import pathlib
|
|
822
|
+
|
|
823
|
+
dest = pathlib.Path(args.dest).expanduser()
|
|
824
|
+
try:
|
|
825
|
+
created = launcher_install.install(
|
|
826
|
+
dest, desktop=args.desktop, app_bundle=args.app_bundle
|
|
827
|
+
)
|
|
828
|
+
except OSError as exc:
|
|
829
|
+
_eprint(f"could not install launcher: {exc}")
|
|
830
|
+
return 1
|
|
831
|
+
if not created:
|
|
832
|
+
_eprint("nothing was installed")
|
|
833
|
+
return 1
|
|
834
|
+
_eprint("installed launcher(s):")
|
|
835
|
+
for p in created:
|
|
836
|
+
_eprint(f" {p}")
|
|
837
|
+
if sys.platform == "darwin":
|
|
838
|
+
made_app = any(p.name.endswith(".app") for p in created)
|
|
839
|
+
made_cmd = any(p.name.endswith(".command") for p in created)
|
|
840
|
+
if made_cmd:
|
|
841
|
+
_eprint("tip: double-click it (first time: right-click -> Open).")
|
|
842
|
+
if made_cmd and not made_app:
|
|
843
|
+
_eprint(" for an app icon in ~/Applications, add --app-bundle")
|
|
844
|
+
return 0
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
class _AppBridge:
|
|
848
|
+
"""JS<->Python API exposed to the pywebview window.
|
|
849
|
+
|
|
850
|
+
In a native webview the browser's own download/print plumbing isn't
|
|
851
|
+
available, so the frontend calls these methods (via window.pywebview.api)
|
|
852
|
+
to save a file through a native dialog and to print via the user's real
|
|
853
|
+
browser. Each method returns a small status string the JS can toast.
|
|
854
|
+
"""
|
|
855
|
+
|
|
856
|
+
def __init__(self, url: str) -> None:
|
|
857
|
+
self._url = url
|
|
858
|
+
self.window = None # set after the window is created
|
|
859
|
+
|
|
860
|
+
def is_native(self) -> bool:
|
|
861
|
+
return True
|
|
862
|
+
|
|
863
|
+
def save_file(self, suggested_name: str, content: str) -> str:
|
|
864
|
+
"""Show a native Save dialog and write `content` to the chosen path."""
|
|
865
|
+
import webview
|
|
866
|
+
|
|
867
|
+
try:
|
|
868
|
+
result = self.window.create_file_dialog(
|
|
869
|
+
webview.SAVE_DIALOG, save_filename=suggested_name
|
|
870
|
+
)
|
|
871
|
+
except Exception as exc: # pragma: no cover - GUI path
|
|
872
|
+
return f"error: {exc}"
|
|
873
|
+
if not result:
|
|
874
|
+
return "cancelled"
|
|
875
|
+
dest = result if isinstance(result, str) else result[0]
|
|
876
|
+
try:
|
|
877
|
+
with open(dest, "w", encoding="utf-8") as fh:
|
|
878
|
+
fh.write(content)
|
|
879
|
+
except OSError as exc: # pragma: no cover - GUI path
|
|
880
|
+
return f"error: {exc}"
|
|
881
|
+
return f"saved: {dest}"
|
|
882
|
+
|
|
883
|
+
def open_external(self, path_and_query: str) -> str:
|
|
884
|
+
"""Open a URL on this server in the user's real browser (for printing,
|
|
885
|
+
which the native webview can't do reliably)."""
|
|
886
|
+
from . import webopen
|
|
887
|
+
|
|
888
|
+
full = self._url + path_and_query
|
|
889
|
+
webopen.open_window(full)
|
|
890
|
+
return "opened"
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _run_app_window(app: object, args: argparse.Namespace, url: str) -> int:
|
|
894
|
+
try:
|
|
895
|
+
import webview # pywebview
|
|
896
|
+
except ModuleNotFoundError:
|
|
897
|
+
_eprint(
|
|
898
|
+
"the desktop app window needs pywebview. install with:\n"
|
|
899
|
+
' pip install "scrollback[app]"\n'
|
|
900
|
+
"or just run without --app to use your browser."
|
|
901
|
+
)
|
|
902
|
+
return 1
|
|
903
|
+
import threading
|
|
904
|
+
|
|
905
|
+
import uvicorn
|
|
906
|
+
|
|
907
|
+
server = uvicorn.Server(
|
|
908
|
+
uvicorn.Config(app, host=args.host, port=args.port, log_level="warning")
|
|
909
|
+
)
|
|
910
|
+
t = threading.Thread(target=server.run, daemon=True)
|
|
911
|
+
t.start()
|
|
912
|
+
_background_index_refresh()
|
|
913
|
+
_eprint(f"scrollback app -> {url} (read-only; close the window to quit)")
|
|
914
|
+
# macOS only: fix the menu-bar app name (an unbundled python process shows
|
|
915
|
+
# up as "Python"). No-op on other platforms, where the window title + icon
|
|
916
|
+
# below are what the OS uses.
|
|
917
|
+
_brand_macos_app()
|
|
918
|
+
bridge = _AppBridge(url)
|
|
919
|
+
# Window title is used by all backends (Windows/Linux taskbar + title bar).
|
|
920
|
+
window = webview.create_window("scrollback", url, width=1280, height=860, js_api=bridge)
|
|
921
|
+
bridge.window = window
|
|
922
|
+
icon = _app_icon_path() # cross-platform window/taskbar/Dock icon
|
|
923
|
+
start_kwargs: dict[str, object] = {}
|
|
924
|
+
if icon:
|
|
925
|
+
start_kwargs["icon"] = icon
|
|
926
|
+
# Run once the Cocoa menu exists to add a clickable repo link to the
|
|
927
|
+
# standard About panel (macOS only; no-op elsewhere).
|
|
928
|
+
webview.start(_install_macos_about_link, **start_kwargs) # blocks until window closed
|
|
929
|
+
# Window closed: stop the server and wait for the port to be released so
|
|
930
|
+
# an immediate relaunch can reuse it.
|
|
931
|
+
server.should_exit = True
|
|
932
|
+
t.join(timeout=5)
|
|
933
|
+
return 0
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# -- argument parser -------------------------------------------------------
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
940
|
+
p = argparse.ArgumentParser(
|
|
941
|
+
prog="scrollback",
|
|
942
|
+
description="Navigate, search, copy, and export AI coding-agent sessions.",
|
|
943
|
+
)
|
|
944
|
+
p.add_argument("--version", action="version", version=f"scrollback {__version__}")
|
|
945
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
946
|
+
|
|
947
|
+
# sources
|
|
948
|
+
sp = sub.add_parser("sources", help="list detected agents")
|
|
949
|
+
sp.set_defaults(func=cmd_sources)
|
|
950
|
+
|
|
951
|
+
# doctor
|
|
952
|
+
sp = sub.add_parser("doctor", help="diagnostics: sources, index, features, env")
|
|
953
|
+
sp.set_defaults(func=cmd_doctor)
|
|
954
|
+
|
|
955
|
+
# index
|
|
956
|
+
sp = sub.add_parser(
|
|
957
|
+
"index", help="build/update the full-text search index (optional, faster search)"
|
|
958
|
+
)
|
|
959
|
+
sp.add_argument("--stats", action="store_true", help="show index stats and exit")
|
|
960
|
+
sp.add_argument("--clear", action="store_true", help="delete the index and exit")
|
|
961
|
+
sp.set_defaults(func=cmd_index)
|
|
962
|
+
|
|
963
|
+
# common filters
|
|
964
|
+
def add_source_flag(sp_: argparse.ArgumentParser) -> None:
|
|
965
|
+
sp_.add_argument("--source", help="restrict to one source (e.g. opencode)")
|
|
966
|
+
|
|
967
|
+
# list
|
|
968
|
+
sp = sub.add_parser("list", help="list sessions (newest first)")
|
|
969
|
+
add_source_flag(sp)
|
|
970
|
+
sp.add_argument("--dir", help="filter by directory substring")
|
|
971
|
+
sp.add_argument("-q", "--query", help="filter by title substring")
|
|
972
|
+
sp.add_argument("--since", type=_parse_date, metavar="DATE",
|
|
973
|
+
help="only sessions updated on/after DATE (YYYY-MM-DD or ISO)")
|
|
974
|
+
sp.add_argument("--until", type=_parse_date, metavar="DATE",
|
|
975
|
+
help="only sessions updated on/before DATE")
|
|
976
|
+
sp.add_argument("-n", "--limit", type=_positive_int, default=30, help="max rows (default 30)")
|
|
977
|
+
sp.add_argument("--offset", type=_nonneg_int, default=0, help="skip N rows (pagination)")
|
|
978
|
+
sp.add_argument("--page", type=_positive_int, help="page number (uses --limit as page size)")
|
|
979
|
+
sp.add_argument("--usage", action="store_true", help="show cost + token columns")
|
|
980
|
+
sp.add_argument("--no-fold", action="store_true",
|
|
981
|
+
help="do not nest subagent sessions under their parent")
|
|
982
|
+
sp.add_argument("--plain", action="store_true", help="disable colour output")
|
|
983
|
+
sp.add_argument("--json", action="store_true", help="JSON output")
|
|
984
|
+
sp.set_defaults(func=cmd_list)
|
|
985
|
+
|
|
986
|
+
# show
|
|
987
|
+
sp = sub.add_parser("show", help="print a session transcript")
|
|
988
|
+
add_source_flag(sp)
|
|
989
|
+
sp.add_argument("selector", help="session id / prefix / source:id / latest")
|
|
990
|
+
sp.add_argument("--reasoning", action="store_true", help="include reasoning blocks")
|
|
991
|
+
sp.add_argument("--no-tools", action="store_true", help="hide tool calls/outputs")
|
|
992
|
+
sp.add_argument("--no-markdown", action="store_true",
|
|
993
|
+
help="render text as plain (no markdown formatting)")
|
|
994
|
+
sp.add_argument("--plain", action="store_true", help="disable colour output")
|
|
995
|
+
sp.set_defaults(func=cmd_show)
|
|
996
|
+
|
|
997
|
+
# search
|
|
998
|
+
sp = sub.add_parser("search", help="search across sessions")
|
|
999
|
+
add_source_flag(sp)
|
|
1000
|
+
sp.add_argument("query", help="text to search for (case-insensitive)")
|
|
1001
|
+
sp.add_argument("--dir", help="filter by directory substring")
|
|
1002
|
+
sp.add_argument("--since", type=_parse_date, metavar="DATE",
|
|
1003
|
+
help="only sessions updated on/after DATE")
|
|
1004
|
+
sp.add_argument("--until", type=_parse_date, metavar="DATE",
|
|
1005
|
+
help="only sessions updated on/before DATE")
|
|
1006
|
+
sp.add_argument("-n", "--limit", type=_positive_int, default=50, help="max hits (default 50)")
|
|
1007
|
+
sp.add_argument("--plain", action="store_true", help="disable colour output")
|
|
1008
|
+
sp.add_argument("--json", action="store_true", help="JSON output")
|
|
1009
|
+
sp.set_defaults(func=cmd_search)
|
|
1010
|
+
|
|
1011
|
+
# stats
|
|
1012
|
+
sp = sub.add_parser("stats", help="aggregate counts across your sessions")
|
|
1013
|
+
add_source_flag(sp)
|
|
1014
|
+
sp.add_argument("--top", type=_positive_int, default=10,
|
|
1015
|
+
help="how many top projects to show (default 10)")
|
|
1016
|
+
sp.add_argument("--json", action="store_true", help="JSON output")
|
|
1017
|
+
sp.set_defaults(func=cmd_stats)
|
|
1018
|
+
|
|
1019
|
+
# resume
|
|
1020
|
+
sp = sub.add_parser(
|
|
1021
|
+
"resume", help="print the command to resume a session in its native agent"
|
|
1022
|
+
)
|
|
1023
|
+
add_source_flag(sp)
|
|
1024
|
+
sp.add_argument("selector", help="session id / prefix / source:id / latest")
|
|
1025
|
+
sp.add_argument("--copy", action="store_true", help="copy the command to the clipboard")
|
|
1026
|
+
sp.set_defaults(func=cmd_resume)
|
|
1027
|
+
|
|
1028
|
+
# export
|
|
1029
|
+
sp = sub.add_parser("export", help="render a session to a file/stdout")
|
|
1030
|
+
add_source_flag(sp)
|
|
1031
|
+
sp.add_argument("selector", help="session id / prefix / source:id / latest")
|
|
1032
|
+
sp.add_argument(
|
|
1033
|
+
"-f", "--format", default="markdown",
|
|
1034
|
+
choices=sorted(set(export.FORMATS)), help="output format",
|
|
1035
|
+
)
|
|
1036
|
+
sp.add_argument("-o", "--output", help="write to file instead of stdout")
|
|
1037
|
+
sp.add_argument("--reasoning", action="store_true", help="include reasoning blocks")
|
|
1038
|
+
sp.add_argument("--no-tools", action="store_true", help="hide tool calls/outputs")
|
|
1039
|
+
sp.add_argument(
|
|
1040
|
+
"--math", default="raw", choices=list(export.MATH_MODES),
|
|
1041
|
+
help="LaTeX handling: raw (verbatim), latex (verbatim, never typeset), "
|
|
1042
|
+
"rendered (typeset with KaTeX in html export)",
|
|
1043
|
+
)
|
|
1044
|
+
sp.set_defaults(func=cmd_export)
|
|
1045
|
+
|
|
1046
|
+
# copy
|
|
1047
|
+
sp = sub.add_parser("copy", help="copy a rendered session to the clipboard")
|
|
1048
|
+
add_source_flag(sp)
|
|
1049
|
+
sp.add_argument("selector", help="session id / prefix / source:id / latest")
|
|
1050
|
+
sp.add_argument(
|
|
1051
|
+
"-f", "--format", default="markdown",
|
|
1052
|
+
choices=sorted(set(export.FORMATS)), help="render format",
|
|
1053
|
+
)
|
|
1054
|
+
sp.add_argument("--reasoning", action="store_true", help="include reasoning blocks")
|
|
1055
|
+
sp.add_argument("--no-tools", action="store_true", help="hide tool calls/outputs")
|
|
1056
|
+
sp.add_argument(
|
|
1057
|
+
"--math", default="raw", choices=list(export.MATH_MODES),
|
|
1058
|
+
help="LaTeX handling: raw (verbatim), latex (verbatim, never typeset), "
|
|
1059
|
+
"rendered (typeset with KaTeX in html export)",
|
|
1060
|
+
)
|
|
1061
|
+
sp.set_defaults(func=cmd_copy)
|
|
1062
|
+
|
|
1063
|
+
# web
|
|
1064
|
+
sp = sub.add_parser("web", help="launch the local web app (read-only)")
|
|
1065
|
+
sp.add_argument("--host", default=serverconfig.default_host(),
|
|
1066
|
+
help="bind host (default localhost; or $SCROLLBACK_HOST)")
|
|
1067
|
+
sp.add_argument("-p", "--port", type=int, default=serverconfig.default_port(),
|
|
1068
|
+
help=f"port (default {serverconfig.DEFAULT_PORT}; or $SCROLLBACK_PORT)")
|
|
1069
|
+
sp.add_argument("--strict-port", action="store_true",
|
|
1070
|
+
help="fail if the port is busy instead of picking the next free one")
|
|
1071
|
+
sp.add_argument("--no-browser", action="store_true", help="do not open a browser")
|
|
1072
|
+
sp.add_argument("--window", action="store_true",
|
|
1073
|
+
help="open in a standalone browser window instead of a tab")
|
|
1074
|
+
sp.add_argument("--app", action="store_true",
|
|
1075
|
+
help="open in a native desktop window (auto-closes; needs pywebview)")
|
|
1076
|
+
sp.add_argument("--auto-shutdown", action="store_true",
|
|
1077
|
+
help="stop the server shortly after the browser window is closed")
|
|
1078
|
+
sp.set_defaults(func=cmd_web)
|
|
1079
|
+
|
|
1080
|
+
# install-launcher
|
|
1081
|
+
sp = sub.add_parser(
|
|
1082
|
+
"install-launcher",
|
|
1083
|
+
help="install a double-clickable launcher for the web app",
|
|
1084
|
+
description="Install a launcher for the web app. With no flags, "
|
|
1085
|
+
"creates both the Desktop launcher and (on macOS) the .app "
|
|
1086
|
+
"bundle. Use --desktop or --app-bundle to create just one.",
|
|
1087
|
+
)
|
|
1088
|
+
sp.add_argument("--dest", help="where to place the launcher (default: Desktop)")
|
|
1089
|
+
sp.add_argument("--desktop", action="store_true",
|
|
1090
|
+
help="only the Desktop launcher (.command / .bat / .desktop)")
|
|
1091
|
+
sp.add_argument("--app-bundle", action="store_true",
|
|
1092
|
+
help="only the scrollback.app in ~/Applications (macOS; "
|
|
1093
|
+
"falls back to the Desktop launcher elsewhere)")
|
|
1094
|
+
sp.set_defaults(func=cmd_install_launcher)
|
|
1095
|
+
|
|
1096
|
+
return p
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1100
|
+
# Make stdout tolerant of non-UTF-8 locales (e.g. LANG=C) so transcripts
|
|
1101
|
+
# full of emoji/CJK don't crash with UnicodeEncodeError when piped/redirected.
|
|
1102
|
+
try:
|
|
1103
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
1104
|
+
except (AttributeError, ValueError):
|
|
1105
|
+
pass
|
|
1106
|
+
|
|
1107
|
+
parser = build_parser()
|
|
1108
|
+
args = parser.parse_args(argv)
|
|
1109
|
+
try:
|
|
1110
|
+
return args.func(args)
|
|
1111
|
+
except _BadSource as exc:
|
|
1112
|
+
_eprint(str(exc))
|
|
1113
|
+
return 2
|
|
1114
|
+
except BrokenPipeError:
|
|
1115
|
+
# Avoid a second BrokenPipeError + "Exception ignored" noise when Python
|
|
1116
|
+
# flushes stdout at shutdown (the classic `| head` case): redirect the
|
|
1117
|
+
# stdout fd to devnull before returning.
|
|
1118
|
+
try:
|
|
1119
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
1120
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
1121
|
+
except OSError:
|
|
1122
|
+
pass
|
|
1123
|
+
return 0
|
|
1124
|
+
except KeyboardInterrupt:
|
|
1125
|
+
return 130
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def main_web(argv: list[str] | None = None) -> int:
|
|
1129
|
+
"""Console entry point: `scrollback-web [options]` == `scrollback web`."""
|
|
1130
|
+
return main(["web", *(argv if argv is not None else sys.argv[1:])])
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
def main_app(argv: list[str] | None = None) -> int:
|
|
1134
|
+
"""Console entry point: `scrollback-app` == `scrollback web --app`."""
|
|
1135
|
+
return main(["web", "--app", *(argv if argv is not None else sys.argv[1:])])
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
if __name__ == "__main__":
|
|
1139
|
+
raise SystemExit(main())
|