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/store.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Unified store: query across all (or selected) source adapters.
|
|
2
|
+
|
|
3
|
+
This is the single entry point the CLI and web app use. It composes the
|
|
4
|
+
individual adapters, applies cross-source filtering/sorting, and resolves
|
|
5
|
+
session selectors that may carry a `source:id` qualifier.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
|
|
14
|
+
from .models import Message, Part, Session
|
|
15
|
+
from .sources import registry
|
|
16
|
+
from .sources.base import Source
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _sort_key(s: Session) -> datetime:
|
|
20
|
+
return s.updated or s.created or datetime.min.replace(tzinfo=timezone.utc)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class SearchHit:
|
|
25
|
+
"""A single search match within a session."""
|
|
26
|
+
|
|
27
|
+
session: Session
|
|
28
|
+
message: Message
|
|
29
|
+
part: Part
|
|
30
|
+
snippet: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class Stats:
|
|
35
|
+
"""Aggregate counts across sessions (see `Store.stats`)."""
|
|
36
|
+
|
|
37
|
+
sessions: int
|
|
38
|
+
per_source: dict[str, int]
|
|
39
|
+
per_project: dict[str, int]
|
|
40
|
+
total_messages: int
|
|
41
|
+
total_tokens_input: int
|
|
42
|
+
total_tokens_output: int
|
|
43
|
+
total_cost: float
|
|
44
|
+
oldest: datetime | None
|
|
45
|
+
newest: datetime | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Store:
|
|
49
|
+
"""Facade over one or more source adapters."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, sources: list[Source] | None = None) -> None:
|
|
52
|
+
self._sources = sources if sources is not None else registry.available_sources()
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def sources(self) -> list[Source]:
|
|
56
|
+
return self._sources
|
|
57
|
+
|
|
58
|
+
def with_sources(self, names: list[str]) -> "Store":
|
|
59
|
+
"""Return a Store narrowed to the named sources.
|
|
60
|
+
|
|
61
|
+
Filters THIS store's own sources rather than re-fetching from the
|
|
62
|
+
global registry, so an injected store (tests, demos) stays isolated
|
|
63
|
+
instead of silently picking up the machine's real adapters.
|
|
64
|
+
"""
|
|
65
|
+
wanted = set(names)
|
|
66
|
+
chosen = [s for s in self._sources if s.name in wanted]
|
|
67
|
+
return Store(chosen)
|
|
68
|
+
|
|
69
|
+
# -- aggregate stats ----------------------------------------------------
|
|
70
|
+
|
|
71
|
+
def stats(self) -> "Stats":
|
|
72
|
+
"""Aggregate session metadata across sources (metadata-only; cheap-ish).
|
|
73
|
+
|
|
74
|
+
Computes per-source and per-project session counts plus totals
|
|
75
|
+
(messages, tokens, cost) and the overall date span. Uses list-level
|
|
76
|
+
metadata only -- it does not load message bodies.
|
|
77
|
+
"""
|
|
78
|
+
from collections import Counter
|
|
79
|
+
|
|
80
|
+
per_source: Counter[str] = Counter()
|
|
81
|
+
per_project: Counter[str] = Counter()
|
|
82
|
+
total_messages = 0
|
|
83
|
+
total_tokens_in = 0
|
|
84
|
+
total_tokens_out = 0
|
|
85
|
+
total_cost = 0.0
|
|
86
|
+
oldest: datetime | None = None
|
|
87
|
+
newest: datetime | None = None
|
|
88
|
+
n = 0
|
|
89
|
+
|
|
90
|
+
for s in self.list_sessions(fold_subagents=False):
|
|
91
|
+
n += 1
|
|
92
|
+
per_source[s.source] += 1
|
|
93
|
+
if s.directory:
|
|
94
|
+
per_project[s.directory] += 1
|
|
95
|
+
if s.message_count:
|
|
96
|
+
total_messages += s.message_count
|
|
97
|
+
if s.tokens_input:
|
|
98
|
+
total_tokens_in += s.tokens_input
|
|
99
|
+
if s.tokens_output:
|
|
100
|
+
total_tokens_out += s.tokens_output
|
|
101
|
+
if s.cost:
|
|
102
|
+
total_cost += s.cost
|
|
103
|
+
when = s.updated or s.created
|
|
104
|
+
if when is not None:
|
|
105
|
+
oldest = when if oldest is None or when < oldest else oldest
|
|
106
|
+
newest = when if newest is None or when > newest else newest
|
|
107
|
+
|
|
108
|
+
return Stats(
|
|
109
|
+
sessions=n,
|
|
110
|
+
per_source=dict(per_source),
|
|
111
|
+
per_project=dict(per_project),
|
|
112
|
+
total_messages=total_messages,
|
|
113
|
+
total_tokens_input=total_tokens_in,
|
|
114
|
+
total_tokens_output=total_tokens_out,
|
|
115
|
+
total_cost=total_cost,
|
|
116
|
+
oldest=oldest,
|
|
117
|
+
newest=newest,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# -- listing ------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def list_sessions(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
directory: str | None = None,
|
|
126
|
+
query: str | None = None,
|
|
127
|
+
since: datetime | None = None,
|
|
128
|
+
until: datetime | None = None,
|
|
129
|
+
limit: int | None = None,
|
|
130
|
+
offset: int = 0,
|
|
131
|
+
fold_subagents: bool = False,
|
|
132
|
+
) -> list[Session]:
|
|
133
|
+
"""List sessions across sources, newest first.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
directory: keep only sessions whose directory contains this substring.
|
|
137
|
+
query: case-insensitive substring match on the title.
|
|
138
|
+
since / until: keep sessions whose updated (or created) time falls
|
|
139
|
+
within the range (inclusive).
|
|
140
|
+
limit / offset: pagination over the filtered, sorted result.
|
|
141
|
+
fold_subagents: nest subagent sessions under their parent (as
|
|
142
|
+
`.children`) instead of listing them at the top level.
|
|
143
|
+
"""
|
|
144
|
+
results: list[Session] = []
|
|
145
|
+
for src in self._sources:
|
|
146
|
+
for sess in src.list_sessions():
|
|
147
|
+
if directory and (sess.directory is None or directory not in sess.directory):
|
|
148
|
+
continue
|
|
149
|
+
if query and query.lower() not in (sess.title or "").lower():
|
|
150
|
+
continue
|
|
151
|
+
when = sess.updated or sess.created
|
|
152
|
+
if since and (when is None or when < since):
|
|
153
|
+
continue
|
|
154
|
+
if until and (when is None or when > until):
|
|
155
|
+
continue
|
|
156
|
+
results.append(sess)
|
|
157
|
+
results.sort(key=_sort_key, reverse=True)
|
|
158
|
+
|
|
159
|
+
if fold_subagents:
|
|
160
|
+
results = _fold(results)
|
|
161
|
+
|
|
162
|
+
if offset:
|
|
163
|
+
results = results[offset:]
|
|
164
|
+
if limit is not None:
|
|
165
|
+
results = results[:limit]
|
|
166
|
+
return results
|
|
167
|
+
|
|
168
|
+
# -- single session -----------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def _resolve(self, selector: str, source: str | None):
|
|
171
|
+
"""Return (Source, full_id) for a selector, or (None, None)."""
|
|
172
|
+
src_name, sel = _split_selector(selector, source)
|
|
173
|
+
candidates = self._sources if src_name is None else [
|
|
174
|
+
s for s in self._sources if s.name == src_name
|
|
175
|
+
]
|
|
176
|
+
for src in candidates:
|
|
177
|
+
full = src.resolve_session_id(sel)
|
|
178
|
+
if full:
|
|
179
|
+
return src, full
|
|
180
|
+
return None, None
|
|
181
|
+
|
|
182
|
+
def load_session_meta(self, selector: str, *, source: str | None = None) -> Session | None:
|
|
183
|
+
"""Load only a session's metadata (no messages) -- cheap for huge ones."""
|
|
184
|
+
src, full = self._resolve(selector, source)
|
|
185
|
+
return src.load_session_meta(full) if src else None
|
|
186
|
+
|
|
187
|
+
def load_messages(
|
|
188
|
+
self, selector: str, *, source: str | None = None,
|
|
189
|
+
offset: int = 0, limit: int | None = None,
|
|
190
|
+
) -> list[Message]:
|
|
191
|
+
"""Load a windowed slice of a session's messages."""
|
|
192
|
+
src, full = self._resolve(selector, source)
|
|
193
|
+
return src.load_messages(full, offset=offset, limit=limit) if src else []
|
|
194
|
+
|
|
195
|
+
def load_session(self, selector: str, *, source: str | None = None) -> Session | None:
|
|
196
|
+
"""Load one session (with all messages) by selector.
|
|
197
|
+
|
|
198
|
+
Selector may be `source:id`, a full id, a unique prefix, or 'latest'.
|
|
199
|
+
"""
|
|
200
|
+
src, full = self._resolve(selector, source)
|
|
201
|
+
return src.load_session(full) if src else None
|
|
202
|
+
|
|
203
|
+
# -- search -------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def search(
|
|
206
|
+
self,
|
|
207
|
+
query: str,
|
|
208
|
+
*,
|
|
209
|
+
directory: str | None = None,
|
|
210
|
+
since: datetime | None = None,
|
|
211
|
+
until: datetime | None = None,
|
|
212
|
+
limit: int | None = None,
|
|
213
|
+
context: int = 80,
|
|
214
|
+
use_index: bool = True,
|
|
215
|
+
) -> Iterator[SearchHit]:
|
|
216
|
+
"""Yield hits where `query` appears in any message part.
|
|
217
|
+
|
|
218
|
+
If an FTS index exists (built via `scrollback index`) and
|
|
219
|
+
`use_index` is True, the fast indexed path is used. Otherwise this
|
|
220
|
+
falls back to a lexical scan over the live data -- zero setup, always
|
|
221
|
+
correct, but O(corpus) per query.
|
|
222
|
+
"""
|
|
223
|
+
if use_index:
|
|
224
|
+
indexed = self._search_indexed(
|
|
225
|
+
query, directory=directory, since=since, until=until, limit=limit
|
|
226
|
+
)
|
|
227
|
+
if indexed is not None:
|
|
228
|
+
yield from indexed
|
|
229
|
+
return
|
|
230
|
+
yield from self._search_lexical(
|
|
231
|
+
query, directory=directory, since=since, until=until,
|
|
232
|
+
limit=limit, context=context,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _search_indexed(
|
|
236
|
+
self, query, *, directory, since, until, limit
|
|
237
|
+
) -> Iterator[SearchHit] | None:
|
|
238
|
+
"""Indexed search. Returns None if the index is unavailable (caller
|
|
239
|
+
then falls back to lexical).
|
|
240
|
+
|
|
241
|
+
The FTS query itself is ~instant; the only expensive thing is mapping
|
|
242
|
+
a hit's session id back to session metadata. So we resolve metadata
|
|
243
|
+
*lazily* per distinct session that actually appears in results
|
|
244
|
+
(usually few), via the cheap per-adapter `load_session_meta`. We only
|
|
245
|
+
pay for a full `list_sessions` when directory/date filters are given.
|
|
246
|
+
"""
|
|
247
|
+
from . import fts
|
|
248
|
+
|
|
249
|
+
index = fts.FtsIndex()
|
|
250
|
+
if not index.exists():
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
source_names = [s.name for s in self._sources]
|
|
254
|
+
filtering = directory is not None or since is not None or until is not None
|
|
255
|
+
allowed: set[tuple[str, str]] | None = None
|
|
256
|
+
if filtering:
|
|
257
|
+
allowed = {
|
|
258
|
+
(s.source, s.id)
|
|
259
|
+
for s in self.list_sessions(directory=directory, since=since, until=until)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
meta_cache: dict[tuple[str, str], Session | None] = {}
|
|
263
|
+
|
|
264
|
+
def meta_for(source: str, sid: str) -> Session | None:
|
|
265
|
+
k = (source, sid)
|
|
266
|
+
if k not in meta_cache:
|
|
267
|
+
src = next((s for s in self._sources if s.name == source), None)
|
|
268
|
+
meta_cache[k] = src.load_session_meta(sid) if src else None
|
|
269
|
+
return meta_cache[k]
|
|
270
|
+
|
|
271
|
+
def gen() -> Iterator[SearchHit]:
|
|
272
|
+
count = 0
|
|
273
|
+
for hit in index.search(query, sources=source_names):
|
|
274
|
+
key = (hit.source, hit.session_id)
|
|
275
|
+
if allowed is not None and key not in allowed:
|
|
276
|
+
continue
|
|
277
|
+
meta = meta_for(hit.source, hit.session_id)
|
|
278
|
+
if meta is None:
|
|
279
|
+
continue # stale index entry (session deleted)
|
|
280
|
+
part = Part(id="", type=hit.part_type, text="", tool_name=hit.tool_name)
|
|
281
|
+
msg = Message(id=hit.message_id, role=hit.role, created=None, parts=(part,))
|
|
282
|
+
yield SearchHit(
|
|
283
|
+
session=meta,
|
|
284
|
+
message=msg,
|
|
285
|
+
part=part,
|
|
286
|
+
snippet=_clean_snippet(hit.text),
|
|
287
|
+
)
|
|
288
|
+
count += 1
|
|
289
|
+
if limit is not None and count >= limit:
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
return gen()
|
|
293
|
+
|
|
294
|
+
def _search_lexical(
|
|
295
|
+
self, query, *, directory, since, until, limit, context
|
|
296
|
+
) -> Iterator[SearchHit]:
|
|
297
|
+
ql = query.lower()
|
|
298
|
+
count = 0
|
|
299
|
+
for meta in self.list_sessions(directory=directory, since=since, until=until):
|
|
300
|
+
src = next((s for s in self._sources if s.name == meta.source), None)
|
|
301
|
+
if src is None:
|
|
302
|
+
continue
|
|
303
|
+
sess = src.load_session(meta.id)
|
|
304
|
+
if sess is None:
|
|
305
|
+
continue
|
|
306
|
+
for msg in sess.messages:
|
|
307
|
+
for part in msg.parts:
|
|
308
|
+
if not part.text:
|
|
309
|
+
continue
|
|
310
|
+
pos = part.text.lower().find(ql)
|
|
311
|
+
if pos == -1:
|
|
312
|
+
continue
|
|
313
|
+
yield SearchHit(
|
|
314
|
+
session=sess,
|
|
315
|
+
message=msg,
|
|
316
|
+
part=part,
|
|
317
|
+
snippet=_snippet(part.text, pos, len(query), context),
|
|
318
|
+
)
|
|
319
|
+
count += 1
|
|
320
|
+
if limit is not None and count >= limit:
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _fold(sessions: list[Session]) -> list[Session]:
|
|
325
|
+
"""Nest subagent sessions under their parent.
|
|
326
|
+
|
|
327
|
+
A session with a `parent_id` that matches another session's id becomes a
|
|
328
|
+
child of that parent (attached via `.children`). Subagents whose parent
|
|
329
|
+
is not in the list stay at top level so nothing is lost. Order among
|
|
330
|
+
top-level sessions is preserved; children keep newest-first order.
|
|
331
|
+
"""
|
|
332
|
+
from dataclasses import replace
|
|
333
|
+
|
|
334
|
+
# Key on (source, id): ids are only unique within a source, so keying on
|
|
335
|
+
# the bare id could mis-link a parent in one source to a child in another.
|
|
336
|
+
def key(source: str, sid: str) -> tuple[str, str]:
|
|
337
|
+
return (source, sid)
|
|
338
|
+
|
|
339
|
+
by_key = {key(s.source, s.id): s for s in sessions}
|
|
340
|
+
children_of: dict[tuple[str, str], list[Session]] = {}
|
|
341
|
+
top: list[Session] = []
|
|
342
|
+
for s in sessions:
|
|
343
|
+
parent_key = key(s.source, s.parent_id) if s.parent_id else None
|
|
344
|
+
# Fold only when the parent exists AND isn't the session itself
|
|
345
|
+
# (a self-referential parent_id would otherwise drop the session).
|
|
346
|
+
if parent_key and parent_key != key(s.source, s.id) and parent_key in by_key:
|
|
347
|
+
children_of.setdefault(parent_key, []).append(s)
|
|
348
|
+
else:
|
|
349
|
+
top.append(s)
|
|
350
|
+
return [
|
|
351
|
+
replace(s, children=tuple(children_of.get(key(s.source, s.id), ())))
|
|
352
|
+
if key(s.source, s.id) in children_of
|
|
353
|
+
else s
|
|
354
|
+
for s in top
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _split_selector(selector: str, source: str | None) -> tuple[str | None, str]:
|
|
359
|
+
if source:
|
|
360
|
+
return source, selector
|
|
361
|
+
if ":" in selector:
|
|
362
|
+
head, _, tail = selector.partition(":")
|
|
363
|
+
if registry.get_source(head) is not None:
|
|
364
|
+
return head, tail
|
|
365
|
+
return None, selector
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _snippet(text: str, pos: int, qlen: int, context: int) -> str:
|
|
369
|
+
start = max(0, pos - context)
|
|
370
|
+
end = min(len(text), pos + qlen + context)
|
|
371
|
+
prefix = "..." if start > 0 else ""
|
|
372
|
+
suffix = "..." if end < len(text) else ""
|
|
373
|
+
return (prefix + text[start:end] + suffix).replace("\n", " ")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _clean_snippet(snip: str) -> str:
|
|
377
|
+
"""Normalize an FTS5 snippet for display.
|
|
378
|
+
|
|
379
|
+
The index requests snippets with \\x02/\\x03 wrapping the matched term
|
|
380
|
+
(so the frontend/CLI can re-highlight without re-searching). We strip the
|
|
381
|
+
markers here and collapse newlines; the consumers do their own
|
|
382
|
+
highlighting against the query, matching the lexical path's snippets.
|
|
383
|
+
"""
|
|
384
|
+
return snip.replace("\x02", "").replace("\x03", "").replace("\n", " ").strip()
|
scrollback/termrender.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Optional rich-powered terminal rendering.
|
|
2
|
+
|
|
3
|
+
If the `rich` package is installed and output is a TTY, these functions
|
|
4
|
+
render colourful tables/transcripts; otherwise callers fall back to the
|
|
5
|
+
plain renderers in cli.py. Importing this module never fails when rich is
|
|
6
|
+
absent -- `available()` reports the capability.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
|
|
14
|
+
from .models import Session
|
|
15
|
+
from .store import SearchHit
|
|
16
|
+
|
|
17
|
+
try: # pragma: no cover - exercised indirectly
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.markdown import Markdown
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
|
|
23
|
+
_RICH = True
|
|
24
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
25
|
+
_RICH = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Source -> accent colour, mirroring the web UI's source rails.
|
|
29
|
+
_SRC_STYLE = {"opencode": "yellow", "claudecode": "dark_orange3"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def available(force: bool | None = None) -> bool:
|
|
33
|
+
"""True if rich rendering should be used.
|
|
34
|
+
|
|
35
|
+
`force=True`/`False` overrides the auto-detection (used by --plain and
|
|
36
|
+
a future --color flag). Default: rich installed AND stdout is a TTY.
|
|
37
|
+
"""
|
|
38
|
+
if force is not None:
|
|
39
|
+
return force and _RICH
|
|
40
|
+
return _RICH and sys.stdout.isatty()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _console() -> "Console":
|
|
44
|
+
return Console()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _src_style(source: str) -> str:
|
|
48
|
+
return _SRC_STYLE.get(source, "cyan")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _fmt_dt(dt: datetime | None) -> str:
|
|
52
|
+
return dt.strftime("%Y-%m-%d %H:%M") if dt else "?"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _fmt_tokens(n: int | None) -> str:
|
|
56
|
+
if n is None:
|
|
57
|
+
return ""
|
|
58
|
+
if n < 1000:
|
|
59
|
+
return str(n)
|
|
60
|
+
if n < 1_000_000:
|
|
61
|
+
return f"{n / 1000:.1f}k"
|
|
62
|
+
return f"{n / 1_000_000:.1f}M"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_list(sessions: list[Session], *, show_usage: bool) -> None:
|
|
66
|
+
console = _console()
|
|
67
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False)
|
|
68
|
+
table.add_column("source", no_wrap=True)
|
|
69
|
+
table.add_column("id", no_wrap=True, style="dim")
|
|
70
|
+
table.add_column("updated", no_wrap=True, style="dim")
|
|
71
|
+
table.add_column("msgs", justify="right", no_wrap=True)
|
|
72
|
+
if show_usage:
|
|
73
|
+
table.add_column("cost", justify="right", no_wrap=True)
|
|
74
|
+
table.add_column("tok in/out", justify="right", no_wrap=True)
|
|
75
|
+
table.add_column("title")
|
|
76
|
+
|
|
77
|
+
def add(s: Session, depth: int = 0) -> None:
|
|
78
|
+
prefix = (" " * depth + "└ ") if depth else ""
|
|
79
|
+
row = [
|
|
80
|
+
Text(s.source, style=_src_style(s.source)),
|
|
81
|
+
s.short_id,
|
|
82
|
+
_fmt_dt(s.updated),
|
|
83
|
+
str(s.message_count if s.message_count is not None else "?"),
|
|
84
|
+
]
|
|
85
|
+
if show_usage:
|
|
86
|
+
row.append(f"${s.cost:.2f}" if s.cost else "")
|
|
87
|
+
row.append(f"{_fmt_tokens(s.tokens_input)}/{_fmt_tokens(s.tokens_output)}")
|
|
88
|
+
row.append(Text(prefix + (s.title or "(untitled)"),
|
|
89
|
+
style="dim" if depth else ""))
|
|
90
|
+
table.add_row(*row)
|
|
91
|
+
for child in s.children:
|
|
92
|
+
add(child, depth + 1)
|
|
93
|
+
|
|
94
|
+
for s in sessions:
|
|
95
|
+
add(s)
|
|
96
|
+
console.print(table)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_search(hits: list[SearchHit], query: str) -> None:
|
|
100
|
+
console = _console()
|
|
101
|
+
ql = query.lower()
|
|
102
|
+
for h in hits:
|
|
103
|
+
head = Text()
|
|
104
|
+
head.append(f"{h.session.source}", style=_src_style(h.session.source))
|
|
105
|
+
head.append(f":{h.session.short_id} ", style="dim")
|
|
106
|
+
head.append(f"[{h.message.role}]", style="bold")
|
|
107
|
+
if h.part.tool_name:
|
|
108
|
+
head.append(f" {h.part.tool_name}", style="cyan")
|
|
109
|
+
console.print(head)
|
|
110
|
+
snippet = Text(h.snippet, style="dim")
|
|
111
|
+
# Manual highlight of all case-insensitive matches.
|
|
112
|
+
low = h.snippet.lower()
|
|
113
|
+
start = 0
|
|
114
|
+
styled = Text()
|
|
115
|
+
idx = low.find(ql, start)
|
|
116
|
+
if ql and idx != -1:
|
|
117
|
+
while idx != -1:
|
|
118
|
+
styled.append(h.snippet[start:idx], style="dim")
|
|
119
|
+
styled.append(h.snippet[idx:idx + len(query)], style="bold black on yellow")
|
|
120
|
+
start = idx + len(query)
|
|
121
|
+
idx = low.find(ql, start)
|
|
122
|
+
styled.append(h.snippet[start:], style="dim")
|
|
123
|
+
console.print(" ", styled)
|
|
124
|
+
else:
|
|
125
|
+
console.print(" ", snippet)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def render_transcript(
|
|
129
|
+
sess: Session, *, include_reasoning: bool, include_tools: bool, markdown: bool = True
|
|
130
|
+
) -> None:
|
|
131
|
+
console = _console()
|
|
132
|
+
title = Text(sess.title or "(untitled)", style="bold")
|
|
133
|
+
console.print(title)
|
|
134
|
+
meta = Text(
|
|
135
|
+
f"{sess.source} {sess.short_id} {_fmt_dt(sess.created)} "
|
|
136
|
+
f"{len(sess.messages)} messages",
|
|
137
|
+
style="dim",
|
|
138
|
+
)
|
|
139
|
+
console.print(meta)
|
|
140
|
+
console.print()
|
|
141
|
+
|
|
142
|
+
for msg in sess.messages:
|
|
143
|
+
role_style = "cyan" if msg.role == "user" else "green"
|
|
144
|
+
printed_role = False
|
|
145
|
+
|
|
146
|
+
def ensure_role() -> None:
|
|
147
|
+
nonlocal printed_role
|
|
148
|
+
if not printed_role:
|
|
149
|
+
console.print(Text(msg.role.upper(), style=f"bold {role_style}"))
|
|
150
|
+
printed_role = True
|
|
151
|
+
|
|
152
|
+
for part in msg.parts:
|
|
153
|
+
if part.type == "text" and part.text:
|
|
154
|
+
ensure_role()
|
|
155
|
+
# Render assistant/user prose as Markdown (code, lists,
|
|
156
|
+
# headings, bold) when markdown is on; else plain text.
|
|
157
|
+
if markdown:
|
|
158
|
+
console.print(Markdown(part.text))
|
|
159
|
+
else:
|
|
160
|
+
console.print(part.text)
|
|
161
|
+
elif part.type == "reasoning" and include_reasoning and part.text:
|
|
162
|
+
ensure_role()
|
|
163
|
+
console.print(Text(part.text, style="dim italic"))
|
|
164
|
+
elif part.type == "tool" and include_tools and part.text:
|
|
165
|
+
label = part.tool_name or part.tool_status or "tool"
|
|
166
|
+
ensure_role()
|
|
167
|
+
console.print(Text(f" $ {label}", style="yellow"))
|
|
168
|
+
console.print(Text(" " + part.text.replace("\n", "\n "), style="dim"))
|
|
169
|
+
if printed_role:
|
|
170
|
+
console.print()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Local, read-only web app for browsing scrollback sessions."""
|