cc-session-browser 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.
@@ -0,0 +1,3 @@
1
+ """Three-pane browser for local Claude Code sessions."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,115 @@
1
+ """Command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import threading
8
+ import webbrowser
9
+ from pathlib import Path
10
+
11
+ import uvicorn
12
+
13
+ from . import config as config_mod
14
+ from .app import build_app
15
+
16
+
17
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
18
+ """Parse the command line."""
19
+ parser = argparse.ArgumentParser(
20
+ prog="cc-session-browser",
21
+ description="Three-pane browser for your local Claude Code sessions.",
22
+ epilog="Run --help-config for the configuration reference.",
23
+ )
24
+ parser.add_argument(
25
+ "--config",
26
+ type=Path,
27
+ metavar="PATH",
28
+ help=f"config file (default: {config_mod.config_path()})",
29
+ )
30
+ parser.add_argument(
31
+ "--projects-dir",
32
+ type=Path,
33
+ metavar="PATH",
34
+ help="where Claude Code keeps its sessions",
35
+ )
36
+ parser.add_argument(
37
+ "--host", help=f"bind address (default {config_mod.DEFAULT_HOST})"
38
+ )
39
+ parser.add_argument(
40
+ "--port", type=int, help=f"port (default {config_mod.DEFAULT_PORT})"
41
+ )
42
+ parser.add_argument(
43
+ "--no-browser", action="store_true", help="do not open a browser"
44
+ )
45
+ parser.add_argument(
46
+ "--write-config",
47
+ action="store_true",
48
+ help="write a commented starter config and exit",
49
+ )
50
+ parser.add_argument(
51
+ "--help-config",
52
+ action="store_true",
53
+ help="print the documented sample config and exit",
54
+ )
55
+ return parser.parse_args(argv)
56
+
57
+
58
+ def write_config() -> int:
59
+ """Write the sample config to the per-user location, refusing to overwrite."""
60
+ path = config_mod.config_path()
61
+ if path.exists():
62
+ print(f"config already exists: {path}", file=sys.stderr)
63
+ return 1
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ path.write_text(config_mod.example_config())
66
+ print(f"wrote {path}")
67
+ return 0
68
+
69
+
70
+ def main(argv: list[str] | None = None) -> int:
71
+ """Run the browser, or handle one of the exit-early flags."""
72
+ args = parse_args(argv)
73
+ if args.help_config:
74
+ print(config_mod.preamble())
75
+ print(config_mod.example_config())
76
+ return 0
77
+ if args.write_config:
78
+ return write_config()
79
+
80
+ try:
81
+ cfg = config_mod.load(
82
+ args.config,
83
+ projects_dir=args.projects_dir,
84
+ host=args.host,
85
+ port=args.port,
86
+ open_browser=False if args.no_browser else None,
87
+ )
88
+ except FileNotFoundError as exc:
89
+ print(f"config file not found: {exc}", file=sys.stderr)
90
+ return 2
91
+
92
+ if not cfg.projects_dir.is_dir():
93
+ print(
94
+ f"no Claude Code sessions at {cfg.projects_dir}\n"
95
+ f"point --projects-dir, or projects_dir in the config, at the right place.",
96
+ file=sys.stderr,
97
+ )
98
+ return 2
99
+
100
+ url = f"http://{cfg.host}:{cfg.port}/"
101
+ print(f"cc-session-browser \N{RIGHTWARDS ARROW} {url}")
102
+ print(f" sessions: {cfg.projects_dir}")
103
+ print(
104
+ f" config: {cfg.source or '(defaults)'} \N{EM DASH} --help-config for options"
105
+ )
106
+
107
+ if cfg.open_browser:
108
+ threading.Timer(0.6, webbrowser.open, args=(url,)).start()
109
+
110
+ uvicorn.run(build_app(cfg), host=cfg.host, port=cfg.port, log_level="warning")
111
+ return 0
112
+
113
+
114
+ if __name__ == "__main__":
115
+ raise SystemExit(main())
@@ -0,0 +1,357 @@
1
+ """Starlette app: three panes, server-rendered fragments, no client framework."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import secrets
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from starlette.applications import Starlette
12
+ from starlette.exceptions import HTTPException
13
+ from starlette.responses import JSONResponse, PlainTextResponse, Response
14
+ from starlette.routing import Mount, Route
15
+ from starlette.staticfiles import StaticFiles
16
+ from starlette.templating import Jinja2Templates
17
+
18
+ from . import config as config_mod
19
+ from . import index, launcher, render, transcript
20
+
21
+ if TYPE_CHECKING:
22
+ import sqlite3
23
+
24
+ from starlette.requests import Request
25
+
26
+ from .config import Config
27
+
28
+ HERE = Path(__file__).parent
29
+
30
+ # Entries rendered per request. A long session is several megabytes of HTML if
31
+ # emitted in one go, so the transcript streams in as you scroll.
32
+ CHUNK = 40
33
+
34
+ # Thresholds for the relative-age filter, in their own units.
35
+ JUST_NOW_SECONDS = 45
36
+ MINUTES_PER_HOUR = 60
37
+ HOURS_PER_DAY = 24
38
+ DAYS_PER_WEEK = 7
39
+ DAYS_BEFORE_MONTHS = 35
40
+ DAYS_PER_YEAR = 365
41
+ MEAN_DAYS_PER_MONTH = 30.44
42
+ MEAN_DAYS_PER_YEAR = 365.25
43
+
44
+
45
+ def humanise(value: str | None) -> str:
46
+ """Short, scannable timestamps: time today, weekday this week, else a date."""
47
+ if not value:
48
+ return ""
49
+ try:
50
+ when = datetime.fromisoformat(value).astimezone()
51
+ except ValueError:
52
+ return value[:16]
53
+ delta = datetime.now(UTC).astimezone() - when
54
+ if delta.days == 0:
55
+ return when.strftime("%H:%M")
56
+ if delta.days < DAYS_PER_WEEK:
57
+ return when.strftime("%a %H:%M")
58
+ return when.strftime("%d %b %Y")
59
+
60
+
61
+ def _parse(value: str | None) -> datetime | None:
62
+ if not value:
63
+ return None
64
+ try:
65
+ return datetime.fromisoformat(value).astimezone()
66
+ except ValueError:
67
+ return None
68
+
69
+
70
+ def ago(value: str | None) -> str:
71
+ """Coarse relative age: how long since, at one unit of precision."""
72
+ when = _parse(value)
73
+ if when is None:
74
+ return ""
75
+ seconds = (datetime.now(UTC).astimezone() - when).total_seconds()
76
+ if seconds < JUST_NOW_SECONDS:
77
+ return "just now"
78
+ minutes = seconds / 60
79
+ if minutes < MINUTES_PER_HOUR:
80
+ return f"{minutes:.0f}m ago"
81
+ hours = minutes / 60
82
+ if hours < HOURS_PER_DAY:
83
+ return f"{hours:.0f}h ago"
84
+ days = hours / 24
85
+ if days < DAYS_PER_WEEK:
86
+ return f"{days:.0f}d ago"
87
+ if days < DAYS_BEFORE_MONTHS:
88
+ return f"{days / 7:.0f}w ago"
89
+ if days < DAYS_PER_YEAR:
90
+ return f"{days / MEAN_DAYS_PER_MONTH:.0f}mo ago"
91
+ return f"{days / MEAN_DAYS_PER_YEAR:.0f}y ago"
92
+
93
+
94
+ def full(value: str | None) -> str:
95
+ """The exact timestamp, for a title attribute."""
96
+ when = _parse(value)
97
+ return when.strftime("%a %d %b %Y, %H:%M") if when else ""
98
+
99
+
100
+ def tilde(path: str | None) -> str:
101
+ """Collapse the home directory, which is the same on every row."""
102
+ if not path:
103
+ return ""
104
+ home = str(Path.home())
105
+ return "~" + path[len(home) :] if path.startswith(home) else path
106
+
107
+
108
+ def build_app(cfg: Config) -> Starlette:
109
+ """Build the ASGI app: open the index, register routes and filters."""
110
+ conn = index.connect(cfg)
111
+ index.refresh(conn, cfg)
112
+ token = secrets.token_urlsafe(24)
113
+
114
+ templates = Jinja2Templates(directory=HERE / "templates")
115
+ templates.env.globals.update(
116
+ md=render.markdown,
117
+ tool_input=render.tool_input,
118
+ tool_args=render.tool_args,
119
+ code=render.code,
120
+ token=token,
121
+ cfg_example=config_mod.example_config,
122
+ cfg_intro=config_mod.INTRO,
123
+ cfg_lookup_intro=config_mod.LOOKUP_INTRO,
124
+ cfg_lookup=config_mod.lookup_paths,
125
+ cfg_file=config_mod.user_config_file,
126
+ )
127
+ templates.env.filters["when"] = humanise
128
+ templates.env.filters["ago"] = ago
129
+ templates.env.filters["full"] = full
130
+ templates.env.filters["tilde"] = tilde
131
+
132
+ def page(request: Request, fragment: str, ctx: dict) -> Response:
133
+ """Return just the fragment for htmx, or the whole shell for a deep link."""
134
+ ctx = {"request": request, "cfg": cfg, **ctx}
135
+ if request.headers.get("HX-Request"):
136
+ return templates.TemplateResponse(request, fragment, ctx)
137
+ ctx.setdefault("projects", index.projects(conn))
138
+ ctx["initial"] = fragment
139
+ return templates.TemplateResponse(request, "layout.html", ctx)
140
+
141
+ async def home(request: Request) -> Response:
142
+ index.refresh(conn, cfg)
143
+ return templates.TemplateResponse(
144
+ request,
145
+ "layout.html",
146
+ {
147
+ "request": request,
148
+ "cfg": cfg,
149
+ "projects": index.projects(conn),
150
+ "initial": None,
151
+ },
152
+ )
153
+
154
+ def _project_row(slug: str) -> sqlite3.Row:
155
+ meta = conn.execute("SELECT * FROM projects WHERE slug=?", (slug,)).fetchone()
156
+ if meta is None:
157
+ raise HTTPException(404, "unknown project")
158
+ return meta
159
+
160
+ def _sessions_view(request: Request, slug: str, query: str) -> Response:
161
+ """A project's sessions, or that project's slice of an active search."""
162
+ meta = _project_row(slug)
163
+ if query:
164
+ return page(
165
+ request,
166
+ "_results.html",
167
+ {
168
+ "results": index.search(conn, query, slug=slug),
169
+ "q": query,
170
+ "scope": slug,
171
+ "scope_name": meta["path"].rsplit("/", 1)[-1],
172
+ "active_slug": slug,
173
+ },
174
+ )
175
+ return page(
176
+ request,
177
+ "_sessions.html",
178
+ {
179
+ "sessions": index.sessions(conn, slug),
180
+ "project": meta,
181
+ "active_slug": slug,
182
+ },
183
+ )
184
+
185
+ async def project(request: Request) -> Response:
186
+ return _sessions_view(
187
+ request,
188
+ request.path_params["slug"],
189
+ (request.query_params.get("q") or "").strip(),
190
+ )
191
+
192
+ def _transcript(session_id: str) -> tuple[Path, list, int]:
193
+ row = index.session(conn, session_id)
194
+ if row is None:
195
+ raise HTTPException(404, "unknown session")
196
+ path = Path(row["file"])
197
+ if not path.is_file():
198
+ raise HTTPException(410, "transcript file is gone")
199
+ entries, _ = transcript.load(path)
200
+ return row, entries, len(entries)
201
+
202
+ async def session(request: Request) -> Response:
203
+ session_id = request.path_params["session_id"]
204
+ row, entries, total = _transcript(session_id)
205
+
206
+ # A search hit opens the window that contains it rather than the top,
207
+ # so jumping to entry 600 does not mean loading the first 600.
208
+ raw_seq = request.query_params.get("seq", "")
209
+ start = (int(raw_seq) // CHUNK) * CHUNK if raw_seq.isdigit() else 0
210
+ start = max(0, min(start, max(0, total - 1)))
211
+ end = min(total, start + CHUNK)
212
+
213
+ subagents = conn.execute(
214
+ "SELECT * FROM sessions WHERE parent_id=? ORDER BY started_at",
215
+ (session_id,),
216
+ ).fetchall()
217
+ ctx = {
218
+ "session": row,
219
+ "entries": entries[start:end],
220
+ "start": start,
221
+ "end": end,
222
+ "total": total,
223
+ "chunk": CHUNK,
224
+ "subagents": subagents,
225
+ "active_slug": row["slug"],
226
+ "active_id": session_id,
227
+ }
228
+ if not request.headers.get("HX-Request"):
229
+ ctx["sessions"] = index.sessions(conn, row["slug"])
230
+ ctx["project"] = conn.execute(
231
+ "SELECT * FROM projects WHERE slug=?", (row["slug"],)
232
+ ).fetchone()
233
+ return page(request, "_transcript.html", ctx)
234
+
235
+ async def chunk(request: Request) -> Response:
236
+ session_id = request.path_params["session_id"]
237
+ _, entries, total = _transcript(session_id)
238
+ raw = request.query_params.get("start", "0")
239
+ start = max(0, min(int(raw) if raw.isdigit() else 0, total))
240
+ end = min(total, start + CHUNK)
241
+ return templates.TemplateResponse(
242
+ request,
243
+ "_chunk.html",
244
+ {
245
+ "request": request,
246
+ "cfg": cfg,
247
+ "sid": session_id,
248
+ "entries": entries[start:end],
249
+ "start": start,
250
+ "end": end,
251
+ "total": total,
252
+ "chunk": CHUNK,
253
+ "dir": "up" if request.query_params.get("dir") == "up" else "down",
254
+ },
255
+ )
256
+
257
+ async def subagent(request: Request) -> Response:
258
+ row = index.session(conn, request.path_params["session_id"])
259
+ if row is None or not Path(row["file"]).is_file(): # noqa: ASYNC240 - local stat
260
+ raise HTTPException(404, "unknown subagent transcript")
261
+ return templates.TemplateResponse(
262
+ request,
263
+ "_subagent.html",
264
+ {
265
+ "request": request,
266
+ "cfg": cfg,
267
+ "session": row,
268
+ "entries": transcript.parse(Path(row["file"])),
269
+ },
270
+ )
271
+
272
+ def _known_directories() -> set[str]:
273
+ rows = conn.execute(
274
+ "SELECT path FROM projects UNION SELECT cwd FROM sessions WHERE cwd IS NOT NULL"
275
+ )
276
+ return {r[0] for r in rows}
277
+
278
+ async def launch(request: Request) -> Response:
279
+ # This endpoint starts a process, so it is not reachable by a drive-by
280
+ # POST from another origin: same-origin token, issued per run.
281
+ if request.headers.get("X-CSB-Token") != token:
282
+ raise HTTPException(403, "bad or missing token")
283
+ body = await request.json()
284
+ mode = body.get("mode", "resume")
285
+ session_id = body.get("session_id")
286
+ row = index.session(conn, session_id) if session_id else None
287
+ cwd = body.get("cwd") or (row["cwd"] if row else None)
288
+ if not cwd:
289
+ raise HTTPException(400, "no working directory for this session")
290
+ # The directory arrives in the request body, so it is only honoured when
291
+ # the index already knows it: this endpoint can start claude in a real
292
+ # project, and nowhere else.
293
+ if cwd not in _known_directories():
294
+ raise HTTPException(400, "not a known project directory")
295
+ try:
296
+ argv = launcher.launch(cfg, cwd, session_id, mode)
297
+ except launcher.LaunchError as exc:
298
+ return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
299
+ return JSONResponse({"ok": True, "mode": mode, "argv": argv})
300
+
301
+ async def find(request: Request) -> Response:
302
+ query = (request.query_params.get("q") or "").strip()
303
+ slug = (request.query_params.get("slug") or "").strip()
304
+ if slug:
305
+ # Emptying the box inside a project returns to its session list
306
+ # rather than dumping the user back into every project.
307
+ return _sessions_view(request, slug, query)
308
+ if not query:
309
+ return page(
310
+ request, "_recent.html", {"recent": index.recent(conn), "q": ""}
311
+ )
312
+ return page(
313
+ request, "_results.html", {"results": index.search(conn, query), "q": query}
314
+ )
315
+
316
+ async def image(request: Request) -> Response:
317
+ """
318
+ Serve one image from a transcript.
319
+
320
+ Images are real URLs rather than data: URIs, so the page stays small
321
+ and a click can open one full size in a new tab.
322
+ """
323
+ row = index.session(conn, request.path_params["session_id"])
324
+ if row is None or not Path(row["file"]).is_file(): # noqa: ASYNC240 - local stat
325
+ raise HTTPException(404, "unknown session")
326
+ shots = transcript.images(Path(row["file"]))
327
+ idx = request.path_params["idx"]
328
+ if not 0 <= idx < len(shots):
329
+ raise HTTPException(404, "no such image")
330
+ media_type, data = shots[idx]
331
+ return Response(
332
+ base64.b64decode(data),
333
+ media_type=media_type,
334
+ headers={"Cache-Control": "max-age=3600"},
335
+ )
336
+
337
+ async def pygments_css(_: Request) -> Response:
338
+ return PlainTextResponse(
339
+ render.pygments_css(),
340
+ media_type="text/css",
341
+ headers={"Cache-Control": "max-age=86400"},
342
+ )
343
+
344
+ return Starlette(
345
+ routes=[
346
+ Route("/", home),
347
+ Route("/p/{slug}", project),
348
+ Route("/s/{session_id}", session),
349
+ Route("/a/{session_id}", subagent),
350
+ Route("/chunk/{session_id}", chunk),
351
+ Route("/search", find),
352
+ Route("/img/{session_id}/{idx:int}", image),
353
+ Route("/launch", launch, methods=["POST"]),
354
+ Route("/pygments.css", pygments_css),
355
+ Mount("/static", StaticFiles(directory=HERE / "static"), name="static"),
356
+ ]
357
+ )