scrollback 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. scrollback/__init__.py +8 -0
  2. scrollback/assets/icon-256.png +0 -0
  3. scrollback/assets/icon.icns +0 -0
  4. scrollback/cli.py +1139 -0
  5. scrollback/clipboard.py +34 -0
  6. scrollback/export.py +293 -0
  7. scrollback/fts.py +307 -0
  8. scrollback/highlight.py +128 -0
  9. scrollback/katexbundle.py +81 -0
  10. scrollback/launcher_install.py +209 -0
  11. scrollback/launchers/scrollback.bat +19 -0
  12. scrollback/launchers/scrollback.command +19 -0
  13. scrollback/launchers/scrollback.desktop +10 -0
  14. scrollback/launchers/scrollback.sh +12 -0
  15. scrollback/mathspan.py +180 -0
  16. scrollback/minimd.py +205 -0
  17. scrollback/models.py +135 -0
  18. scrollback/serialize.py +83 -0
  19. scrollback/serverconfig.py +66 -0
  20. scrollback/sources/__init__.py +6 -0
  21. scrollback/sources/aider.py +244 -0
  22. scrollback/sources/base.py +117 -0
  23. scrollback/sources/claudecode.py +631 -0
  24. scrollback/sources/codex.py +281 -0
  25. scrollback/sources/opencode.py +357 -0
  26. scrollback/sources/registry.py +39 -0
  27. scrollback/store.py +384 -0
  28. scrollback/termrender.py +170 -0
  29. scrollback/web/__init__.py +1 -0
  30. scrollback/web/app.py +359 -0
  31. scrollback/web/static/app.js +1245 -0
  32. scrollback/web/static/apple-touch-icon.png +0 -0
  33. scrollback/web/static/favicon.png +0 -0
  34. scrollback/web/static/favicon.svg +41 -0
  35. scrollback/web/static/index.html +75 -0
  36. scrollback/web/static/style.css +628 -0
  37. scrollback/web/static/vendor/highlight.min.js +1213 -0
  38. scrollback/web/static/vendor/hljs-dark.min.css +10 -0
  39. scrollback/web/static/vendor/hljs-light.min.css +10 -0
  40. scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  41. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  42. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  43. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  44. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  45. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  47. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  48. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  49. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  50. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  51. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  52. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  53. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  54. scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  55. scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  56. scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  57. scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  58. scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  59. scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  60. scrollback/web/static/vendor/katex/katex.min.css +1 -0
  61. scrollback/web/static/vendor/katex/katex.min.js +1 -0
  62. scrollback/web/static/vendor/marked.min.js +6 -0
  63. scrollback/web/static/vendor/purify.min.js +3 -0
  64. scrollback/webopen.py +96 -0
  65. scrollback-0.1.0.dist-info/METADATA +391 -0
  66. scrollback-0.1.0.dist-info/RECORD +69 -0
  67. scrollback-0.1.0.dist-info/WHEEL +4 -0
  68. scrollback-0.1.0.dist-info/entry_points.txt +4 -0
  69. scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
@@ -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."""