sift-cc 0.2.1__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.
- sift.py +2459 -0
- sift_cc-0.2.1.dist-info/METADATA +316 -0
- sift_cc-0.2.1.dist-info/RECORD +6 -0
- sift_cc-0.2.1.dist-info/WHEEL +4 -0
- sift_cc-0.2.1.dist-info/entry_points.txt +2 -0
- sift_cc-0.2.1.dist-info/licenses/LICENSE +21 -0
sift.py
ADDED
|
@@ -0,0 +1,2459 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
sift — mine your Claude Code conversation archive.
|
|
4
|
+
|
|
5
|
+
Browse, search, and extract from the JSONL session files Claude Code
|
|
6
|
+
leaves behind in ~/.claude/projects/. Zero dependencies, pure stdlib.
|
|
7
|
+
|
|
8
|
+
Run `sift --help` for the command list,
|
|
9
|
+
or `sift <command> --help` for details on each command.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
__version__ = "0.2.0"
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
import textwrap
|
|
25
|
+
from collections import Counter, defaultdict
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Iterator
|
|
30
|
+
|
|
31
|
+
ARCHIVE_ROOT = Path(
|
|
32
|
+
os.environ.get("SIFT_ARCHIVE", Path.home() / ".claude" / "projects")
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# ============================================================
|
|
36
|
+
# terminal helpers
|
|
37
|
+
# ============================================================
|
|
38
|
+
|
|
39
|
+
def _supports_color() -> bool:
|
|
40
|
+
if os.environ.get("NO_COLOR") or os.environ.get("SIFT_NO_COLOR"):
|
|
41
|
+
return False
|
|
42
|
+
return sys.stdout.isatty()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
USE_COLOR = _supports_color()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class C:
|
|
49
|
+
RESET = "\033[0m" if USE_COLOR else ""
|
|
50
|
+
DIM = "\033[2m" if USE_COLOR else ""
|
|
51
|
+
BOLD = "\033[1m" if USE_COLOR else ""
|
|
52
|
+
RED = "\033[31m" if USE_COLOR else ""
|
|
53
|
+
GREEN = "\033[32m" if USE_COLOR else ""
|
|
54
|
+
YELLOW = "\033[33m" if USE_COLOR else ""
|
|
55
|
+
BLUE = "\033[34m" if USE_COLOR else ""
|
|
56
|
+
MAGENTA = "\033[35m" if USE_COLOR else ""
|
|
57
|
+
CYAN = "\033[36m" if USE_COLOR else ""
|
|
58
|
+
GREY = "\033[90m" if USE_COLOR else ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def term_width(default: int = 100) -> int:
|
|
62
|
+
try:
|
|
63
|
+
return shutil.get_terminal_size((default, 24)).columns
|
|
64
|
+
except Exception:
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def hr(char: str = "─") -> str:
|
|
69
|
+
return C.GREY + char * term_width() + C.RESET
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ============================================================
|
|
73
|
+
# date / time helpers
|
|
74
|
+
# ============================================================
|
|
75
|
+
|
|
76
|
+
_WHEN_UNITS = {"h": 3600, "d": 86400, "w": 604800}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_when(s: str | None) -> datetime | None:
|
|
80
|
+
"""Accept '7d', '2w', '12h' or 'YYYY-MM-DD' / ISO datetime. Returns UTC."""
|
|
81
|
+
if not s:
|
|
82
|
+
return None
|
|
83
|
+
s = s.strip()
|
|
84
|
+
m = re.fullmatch(r"(\d+)([hdw])", s)
|
|
85
|
+
if m:
|
|
86
|
+
n = int(m.group(1))
|
|
87
|
+
return datetime.now(timezone.utc) - timedelta(seconds=n * _WHEN_UNITS[m.group(2)])
|
|
88
|
+
try:
|
|
89
|
+
d = datetime.fromisoformat(s)
|
|
90
|
+
if d.tzinfo is None:
|
|
91
|
+
d = d.replace(tzinfo=timezone.utc)
|
|
92
|
+
return d
|
|
93
|
+
except ValueError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ============================================================
|
|
98
|
+
# data model
|
|
99
|
+
# ============================================================
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class SessionInfo:
|
|
103
|
+
project_dir: str
|
|
104
|
+
project_path: str
|
|
105
|
+
session_id: str
|
|
106
|
+
file: Path
|
|
107
|
+
first_ts: datetime | None = None
|
|
108
|
+
last_ts: datetime | None = None
|
|
109
|
+
title: str | None = None
|
|
110
|
+
user_msgs: int = 0
|
|
111
|
+
assistant_msgs: int = 0
|
|
112
|
+
input_tokens: int = 0
|
|
113
|
+
output_tokens: int = 0
|
|
114
|
+
cache_read: int = 0
|
|
115
|
+
cache_create: int = 0
|
|
116
|
+
first_user_prompt: str = ""
|
|
117
|
+
cwd: str = ""
|
|
118
|
+
git_branch: str = ""
|
|
119
|
+
models: set[str] = field(default_factory=set)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def duration(self) -> timedelta | None:
|
|
123
|
+
if self.first_ts and self.last_ts:
|
|
124
|
+
return self.last_ts - self.first_ts
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def total_msgs(self) -> int:
|
|
129
|
+
return self.user_msgs + self.assistant_msgs
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ============================================================
|
|
133
|
+
# parsing
|
|
134
|
+
# ============================================================
|
|
135
|
+
|
|
136
|
+
def decode_project_dir(name: str) -> str:
|
|
137
|
+
if name.startswith("-"):
|
|
138
|
+
return "/" + name[1:].replace("-", "/")
|
|
139
|
+
return name
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_ts(s: str | None) -> datetime | None:
|
|
143
|
+
if not s:
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
if s.endswith("Z"):
|
|
147
|
+
s = s[:-1] + "+00:00"
|
|
148
|
+
return datetime.fromisoformat(s)
|
|
149
|
+
except Exception:
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def iter_session_files(project_filter: str | None = None) -> Iterator[Path]:
|
|
154
|
+
if not ARCHIVE_ROOT.exists():
|
|
155
|
+
return
|
|
156
|
+
for proj_dir in sorted(ARCHIVE_ROOT.iterdir()):
|
|
157
|
+
if not proj_dir.is_dir():
|
|
158
|
+
continue
|
|
159
|
+
if project_filter and project_filter.lower() not in decode_project_dir(proj_dir.name).lower():
|
|
160
|
+
continue
|
|
161
|
+
for f in sorted(proj_dir.glob("*.jsonl")):
|
|
162
|
+
yield f
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def iter_records(path: Path) -> Iterator[dict]:
|
|
166
|
+
try:
|
|
167
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
168
|
+
for line in fh:
|
|
169
|
+
line = line.strip()
|
|
170
|
+
if not line:
|
|
171
|
+
continue
|
|
172
|
+
try:
|
|
173
|
+
yield json.loads(line)
|
|
174
|
+
except json.JSONDecodeError:
|
|
175
|
+
continue
|
|
176
|
+
except OSError:
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def text_of_user_message(msg: dict) -> str:
|
|
181
|
+
if not isinstance(msg, dict):
|
|
182
|
+
return ""
|
|
183
|
+
content = msg.get("content")
|
|
184
|
+
if isinstance(content, str):
|
|
185
|
+
return content
|
|
186
|
+
if isinstance(content, list):
|
|
187
|
+
chunks: list[str] = []
|
|
188
|
+
for block in content:
|
|
189
|
+
if not isinstance(block, dict):
|
|
190
|
+
continue
|
|
191
|
+
if block.get("type") == "text":
|
|
192
|
+
chunks.append(block.get("text") or "")
|
|
193
|
+
elif block.get("type") == "tool_result":
|
|
194
|
+
tc = block.get("content")
|
|
195
|
+
if isinstance(tc, str):
|
|
196
|
+
chunks.append(tc)
|
|
197
|
+
elif isinstance(tc, list):
|
|
198
|
+
for b in tc:
|
|
199
|
+
if isinstance(b, dict) and b.get("type") == "text":
|
|
200
|
+
chunks.append(b.get("text") or "")
|
|
201
|
+
return "\n".join(chunks)
|
|
202
|
+
return ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def text_of_assistant_message(msg: dict) -> tuple[str, list[dict]]:
|
|
206
|
+
if not isinstance(msg, dict):
|
|
207
|
+
return "", []
|
|
208
|
+
content = msg.get("content")
|
|
209
|
+
if isinstance(content, str):
|
|
210
|
+
return content, []
|
|
211
|
+
tool_uses: list[dict] = []
|
|
212
|
+
chunks: list[str] = []
|
|
213
|
+
if isinstance(content, list):
|
|
214
|
+
for block in content:
|
|
215
|
+
if not isinstance(block, dict):
|
|
216
|
+
continue
|
|
217
|
+
btype = block.get("type")
|
|
218
|
+
if btype == "text":
|
|
219
|
+
chunks.append(block.get("text") or "")
|
|
220
|
+
elif btype == "tool_use":
|
|
221
|
+
tool_uses.append(block)
|
|
222
|
+
return "\n".join(chunks), tool_uses
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def thinking_of_assistant_message(msg: dict) -> str:
|
|
226
|
+
if not isinstance(msg, dict):
|
|
227
|
+
return ""
|
|
228
|
+
content = msg.get("content")
|
|
229
|
+
if not isinstance(content, list):
|
|
230
|
+
return ""
|
|
231
|
+
return "\n".join(
|
|
232
|
+
(b.get("thinking") or "")
|
|
233
|
+
for b in content
|
|
234
|
+
if isinstance(b, dict) and b.get("type") == "thinking"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _looks_like_tool_result(msg: dict) -> bool:
|
|
239
|
+
content = msg.get("content")
|
|
240
|
+
if not isinstance(content, list):
|
|
241
|
+
return False
|
|
242
|
+
return any(isinstance(b, dict) and b.get("type") == "tool_result" for b in content)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def summarize_session(path: Path) -> SessionInfo:
|
|
246
|
+
project_dir = path.parent.name
|
|
247
|
+
info = SessionInfo(
|
|
248
|
+
project_dir=project_dir,
|
|
249
|
+
project_path=decode_project_dir(project_dir),
|
|
250
|
+
session_id=path.stem,
|
|
251
|
+
file=path,
|
|
252
|
+
)
|
|
253
|
+
for rec in iter_records(path):
|
|
254
|
+
rtype = rec.get("type")
|
|
255
|
+
ts = parse_ts(rec.get("timestamp"))
|
|
256
|
+
if ts:
|
|
257
|
+
if info.first_ts is None or ts < info.first_ts:
|
|
258
|
+
info.first_ts = ts
|
|
259
|
+
if info.last_ts is None or ts > info.last_ts:
|
|
260
|
+
info.last_ts = ts
|
|
261
|
+
if rtype == "ai-title":
|
|
262
|
+
info.title = rec.get("aiTitle")
|
|
263
|
+
elif rtype == "user":
|
|
264
|
+
msg = rec.get("message") or {}
|
|
265
|
+
txt = text_of_user_message(msg)
|
|
266
|
+
if (
|
|
267
|
+
not info.first_user_prompt
|
|
268
|
+
and txt
|
|
269
|
+
and not txt.startswith("<")
|
|
270
|
+
and not _looks_like_tool_result(msg)
|
|
271
|
+
):
|
|
272
|
+
info.first_user_prompt = txt.strip().splitlines()[0][:240]
|
|
273
|
+
info.user_msgs += 1
|
|
274
|
+
if not info.cwd and rec.get("cwd"):
|
|
275
|
+
info.cwd = rec["cwd"]
|
|
276
|
+
if not info.git_branch and rec.get("gitBranch"):
|
|
277
|
+
info.git_branch = rec["gitBranch"]
|
|
278
|
+
elif rtype == "assistant":
|
|
279
|
+
info.assistant_msgs += 1
|
|
280
|
+
msg = rec.get("message") or {}
|
|
281
|
+
model = msg.get("model")
|
|
282
|
+
if model:
|
|
283
|
+
info.models.add(model)
|
|
284
|
+
usage = msg.get("usage") or {}
|
|
285
|
+
info.input_tokens += int(usage.get("input_tokens") or 0)
|
|
286
|
+
info.output_tokens += int(usage.get("output_tokens") or 0)
|
|
287
|
+
info.cache_read += int(usage.get("cache_read_input_tokens") or 0)
|
|
288
|
+
info.cache_create += int(usage.get("cache_creation_input_tokens") or 0)
|
|
289
|
+
return info
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def collect_sessions(
|
|
293
|
+
project_filter: str | None = None,
|
|
294
|
+
since: datetime | None = None,
|
|
295
|
+
until: datetime | None = None,
|
|
296
|
+
) -> list[SessionInfo]:
|
|
297
|
+
sessions: list[SessionInfo] = []
|
|
298
|
+
for f in iter_session_files(project_filter):
|
|
299
|
+
info = summarize_session(f)
|
|
300
|
+
if since and (info.last_ts is None or info.last_ts < since):
|
|
301
|
+
continue
|
|
302
|
+
if until and (info.first_ts is None or info.first_ts > until):
|
|
303
|
+
continue
|
|
304
|
+
sessions.append(info)
|
|
305
|
+
sessions.sort(
|
|
306
|
+
key=lambda s: (s.last_ts or datetime.min.replace(tzinfo=timezone.utc)),
|
|
307
|
+
reverse=True,
|
|
308
|
+
)
|
|
309
|
+
return sessions
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def iter_tool_uses(path: Path) -> Iterator[tuple[datetime | None, dict]]:
|
|
313
|
+
"""Yield (timestamp, tool_use_block) for every assistant tool call."""
|
|
314
|
+
for rec in iter_records(path):
|
|
315
|
+
if rec.get("type") != "assistant":
|
|
316
|
+
continue
|
|
317
|
+
ts = parse_ts(rec.get("timestamp"))
|
|
318
|
+
msg = rec.get("message") or {}
|
|
319
|
+
content = msg.get("content")
|
|
320
|
+
if not isinstance(content, list):
|
|
321
|
+
continue
|
|
322
|
+
for block in content:
|
|
323
|
+
if isinstance(block, dict) and block.get("type") == "tool_use":
|
|
324
|
+
yield ts, block
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
URL_RE = re.compile(r"https?://[^\s\)\]\>\"'`]+")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def extract_urls(text: str) -> list[str]:
|
|
331
|
+
return URL_RE.findall(text or "")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ============================================================
|
|
335
|
+
# formatters
|
|
336
|
+
# ============================================================
|
|
337
|
+
|
|
338
|
+
def fmt_ts(ts: datetime | None) -> str:
|
|
339
|
+
if ts is None:
|
|
340
|
+
return "—"
|
|
341
|
+
local = ts.astimezone()
|
|
342
|
+
now = datetime.now(local.tzinfo)
|
|
343
|
+
delta = now - local
|
|
344
|
+
if delta < timedelta(minutes=1):
|
|
345
|
+
return "just now"
|
|
346
|
+
if delta < timedelta(hours=1):
|
|
347
|
+
return f"{int(delta.total_seconds() // 60)}m ago"
|
|
348
|
+
if delta < timedelta(days=1):
|
|
349
|
+
return f"{int(delta.total_seconds() // 3600)}h ago"
|
|
350
|
+
if delta < timedelta(days=7):
|
|
351
|
+
return f"{delta.days}d ago"
|
|
352
|
+
return local.strftime("%Y-%m-%d")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def fmt_num(n: int) -> str:
|
|
356
|
+
if n >= 1_000_000_000:
|
|
357
|
+
return f"{n/1_000_000_000:.1f}G"
|
|
358
|
+
if n >= 1_000_000:
|
|
359
|
+
return f"{n/1_000_000:.1f}M"
|
|
360
|
+
if n >= 1_000:
|
|
361
|
+
return f"{n/1_000:.1f}k"
|
|
362
|
+
return str(n)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def fmt_duration(td: timedelta | None) -> str:
|
|
366
|
+
if td is None:
|
|
367
|
+
return "—"
|
|
368
|
+
s = int(td.total_seconds())
|
|
369
|
+
if s < 60:
|
|
370
|
+
return f"{s}s"
|
|
371
|
+
if s < 3600:
|
|
372
|
+
return f"{s // 60}m"
|
|
373
|
+
h = s // 3600
|
|
374
|
+
m = (s % 3600) // 60
|
|
375
|
+
if h < 24:
|
|
376
|
+
return f"{h}h{m:02d}m" if m else f"{h}h"
|
|
377
|
+
d = h // 24
|
|
378
|
+
return f"{d}d{h % 24:02d}h"
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def short_path(p: str, width: int) -> str:
|
|
382
|
+
if len(p) <= width:
|
|
383
|
+
return p
|
|
384
|
+
home = str(Path.home())
|
|
385
|
+
if p.startswith(home):
|
|
386
|
+
p = "~" + p[len(home):]
|
|
387
|
+
if len(p) <= width:
|
|
388
|
+
return p
|
|
389
|
+
return "…" + p[-(width - 1):]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def truncate(s: str, n: int) -> str:
|
|
393
|
+
s = " ".join((s or "").split())
|
|
394
|
+
return s if len(s) <= n else s[: n - 1] + "…"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def find_session(prefix: str) -> Path | None:
|
|
398
|
+
matches: list[Path] = []
|
|
399
|
+
for f in iter_session_files():
|
|
400
|
+
if f.stem == prefix:
|
|
401
|
+
return f
|
|
402
|
+
if f.stem.startswith(prefix):
|
|
403
|
+
matches.append(f)
|
|
404
|
+
if len(matches) == 1:
|
|
405
|
+
return matches[0]
|
|
406
|
+
if len(matches) > 1:
|
|
407
|
+
print(
|
|
408
|
+
f"{C.RED}Ambiguous prefix '{prefix}' matches {len(matches)} sessions:{C.RESET}",
|
|
409
|
+
file=sys.stderr,
|
|
410
|
+
)
|
|
411
|
+
for m in matches[:10]:
|
|
412
|
+
print(
|
|
413
|
+
f" {m.stem} {C.GREY}{decode_project_dir(m.parent.name)}{C.RESET}",
|
|
414
|
+
file=sys.stderr,
|
|
415
|
+
)
|
|
416
|
+
return None
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _session_to_dict(s: SessionInfo) -> dict:
|
|
421
|
+
return {
|
|
422
|
+
"session_id": s.session_id,
|
|
423
|
+
"project": s.project_path,
|
|
424
|
+
"title": s.title,
|
|
425
|
+
"first_prompt": s.first_user_prompt,
|
|
426
|
+
"first_ts": s.first_ts.isoformat() if s.first_ts else None,
|
|
427
|
+
"last_ts": s.last_ts.isoformat() if s.last_ts else None,
|
|
428
|
+
"duration_seconds": int(s.duration.total_seconds()) if s.duration else None,
|
|
429
|
+
"user_msgs": s.user_msgs,
|
|
430
|
+
"assistant_msgs": s.assistant_msgs,
|
|
431
|
+
"input_tokens": s.input_tokens,
|
|
432
|
+
"output_tokens": s.output_tokens,
|
|
433
|
+
"cache_read": s.cache_read,
|
|
434
|
+
"cache_create": s.cache_create,
|
|
435
|
+
"models": sorted(s.models),
|
|
436
|
+
"cwd": s.cwd,
|
|
437
|
+
"git_branch": s.git_branch,
|
|
438
|
+
"file": str(s.file),
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# ============================================================
|
|
443
|
+
# pager
|
|
444
|
+
# ============================================================
|
|
445
|
+
|
|
446
|
+
def _maybe_pager(disable: bool) -> subprocess.Popen | None:
|
|
447
|
+
if disable or not sys.stdout.isatty():
|
|
448
|
+
return None
|
|
449
|
+
pager = os.environ.get("PAGER") or "less"
|
|
450
|
+
try:
|
|
451
|
+
cmd = [pager]
|
|
452
|
+
if Path(pager).name == "less":
|
|
453
|
+
cmd += ["-RFX"]
|
|
454
|
+
return subprocess.Popen(cmd, stdin=subprocess.PIPE, text=True)
|
|
455
|
+
except OSError:
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
# ============================================================
|
|
460
|
+
# tool-input summarization (used by show / files / export)
|
|
461
|
+
# ============================================================
|
|
462
|
+
|
|
463
|
+
def _summarize_tool_input(name: str, inp: dict) -> str:
|
|
464
|
+
if not isinstance(inp, dict):
|
|
465
|
+
return ""
|
|
466
|
+
if name in ("Read", "Edit", "Write", "NotebookEdit", "MultiEdit"):
|
|
467
|
+
return inp.get("file_path", "")
|
|
468
|
+
if name == "Bash":
|
|
469
|
+
return truncate(inp.get("command", ""), 100)
|
|
470
|
+
if name == "Grep":
|
|
471
|
+
return f"{inp.get('pattern','')} in {inp.get('path','.')}"
|
|
472
|
+
if name == "Glob":
|
|
473
|
+
return inp.get("pattern", "")
|
|
474
|
+
if name == "WebFetch":
|
|
475
|
+
return inp.get("url", "")
|
|
476
|
+
if name == "WebSearch":
|
|
477
|
+
return inp.get("query", "")
|
|
478
|
+
if name in ("Task", "Agent"):
|
|
479
|
+
return truncate(inp.get("description", ""), 80)
|
|
480
|
+
for k, v in inp.items():
|
|
481
|
+
if isinstance(v, str):
|
|
482
|
+
return f"{k}={truncate(v, 80)}"
|
|
483
|
+
return ""
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ============================================================
|
|
487
|
+
# rendering: terminal + markdown
|
|
488
|
+
# ============================================================
|
|
489
|
+
|
|
490
|
+
def _render_session(
|
|
491
|
+
path: Path,
|
|
492
|
+
out,
|
|
493
|
+
show_thinking: bool = False,
|
|
494
|
+
show_tool_results: bool = False,
|
|
495
|
+
) -> None:
|
|
496
|
+
info = summarize_session(path)
|
|
497
|
+
|
|
498
|
+
def emit(s: str = "") -> None:
|
|
499
|
+
try:
|
|
500
|
+
out.write(s + "\n")
|
|
501
|
+
except (BrokenPipeError, OSError):
|
|
502
|
+
raise SystemExit(0)
|
|
503
|
+
|
|
504
|
+
emit(f"{C.BOLD}{C.YELLOW}{info.session_id}{C.RESET}")
|
|
505
|
+
emit(f" {C.DIM}title {C.RESET}{info.title or '—'}")
|
|
506
|
+
emit(f" {C.DIM}project {C.RESET}{info.project_path}")
|
|
507
|
+
emit(f" {C.DIM}cwd {C.RESET}{info.cwd or '—'}")
|
|
508
|
+
emit(f" {C.DIM}branch {C.RESET}{info.git_branch or '—'}")
|
|
509
|
+
emit(f" {C.DIM}models {C.RESET}{', '.join(sorted(info.models)) or '—'}")
|
|
510
|
+
emit(f" {C.DIM}msgs {C.RESET}{info.user_msgs} user, {info.assistant_msgs} assistant")
|
|
511
|
+
emit(f" {C.DIM}tokens {C.RESET}in {fmt_num(info.input_tokens)}, out {fmt_num(info.output_tokens)}, cache-read {fmt_num(info.cache_read)}")
|
|
512
|
+
span = (
|
|
513
|
+
f"{info.first_ts.astimezone().strftime('%Y-%m-%d %H:%M') if info.first_ts else '—'}"
|
|
514
|
+
f" → {info.last_ts.astimezone().strftime('%H:%M') if info.last_ts else '—'}"
|
|
515
|
+
f" ({fmt_duration(info.duration)})"
|
|
516
|
+
)
|
|
517
|
+
emit(f" {C.DIM}span {C.RESET}{span}")
|
|
518
|
+
emit(hr())
|
|
519
|
+
|
|
520
|
+
width = term_width()
|
|
521
|
+
wrap = textwrap.TextWrapper(
|
|
522
|
+
width=min(width, 100),
|
|
523
|
+
initial_indent=" ",
|
|
524
|
+
subsequent_indent=" ",
|
|
525
|
+
break_long_words=False,
|
|
526
|
+
replace_whitespace=False,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
for rec in iter_records(path):
|
|
530
|
+
rtype = rec.get("type")
|
|
531
|
+
if rtype not in ("user", "assistant"):
|
|
532
|
+
continue
|
|
533
|
+
msg = rec.get("message") or {}
|
|
534
|
+
ts = parse_ts(rec.get("timestamp"))
|
|
535
|
+
ts_s = ts.astimezone().strftime("%H:%M:%S") if ts else ""
|
|
536
|
+
if rtype == "user":
|
|
537
|
+
is_tr = _looks_like_tool_result(msg)
|
|
538
|
+
if is_tr and not show_tool_results:
|
|
539
|
+
continue
|
|
540
|
+
text = text_of_user_message(msg)
|
|
541
|
+
if not text.strip():
|
|
542
|
+
continue
|
|
543
|
+
label = "TOOL" if is_tr else "USER"
|
|
544
|
+
color = C.GREY if is_tr else C.GREEN
|
|
545
|
+
emit(f"\n{color}{C.BOLD}▌{label}{C.RESET} {C.GREY}{ts_s}{C.RESET}")
|
|
546
|
+
for para in text.split("\n"):
|
|
547
|
+
if not para.strip():
|
|
548
|
+
emit()
|
|
549
|
+
else:
|
|
550
|
+
for line in wrap.wrap(para) or [" "]:
|
|
551
|
+
emit(line)
|
|
552
|
+
else:
|
|
553
|
+
text, tools = text_of_assistant_message(msg)
|
|
554
|
+
thinking = thinking_of_assistant_message(msg) if show_thinking else ""
|
|
555
|
+
if not (text.strip() or tools or thinking):
|
|
556
|
+
continue
|
|
557
|
+
emit(f"\n{C.MAGENTA}{C.BOLD}▌CLAUDE{C.RESET} {C.GREY}{ts_s}{C.RESET}")
|
|
558
|
+
if thinking:
|
|
559
|
+
emit(f" {C.DIM}— thinking —{C.RESET}")
|
|
560
|
+
for para in thinking.split("\n"):
|
|
561
|
+
if not para.strip():
|
|
562
|
+
emit()
|
|
563
|
+
else:
|
|
564
|
+
for line in wrap.wrap(para):
|
|
565
|
+
emit(C.DIM + line + C.RESET)
|
|
566
|
+
emit()
|
|
567
|
+
if text.strip():
|
|
568
|
+
for para in text.split("\n"):
|
|
569
|
+
if not para.strip():
|
|
570
|
+
emit()
|
|
571
|
+
else:
|
|
572
|
+
for line in wrap.wrap(para) or [" "]:
|
|
573
|
+
emit(line)
|
|
574
|
+
for tu in tools:
|
|
575
|
+
name = tu.get("name", "?")
|
|
576
|
+
inp = tu.get("input") or {}
|
|
577
|
+
summary = _summarize_tool_input(name, inp)
|
|
578
|
+
emit(f" {C.CYAN}↳ {name}{C.RESET} {C.GREY}{summary}{C.RESET}")
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _render_markdown(
|
|
582
|
+
path: Path,
|
|
583
|
+
out,
|
|
584
|
+
include_thinking: bool = False,
|
|
585
|
+
include_tools: bool = True,
|
|
586
|
+
include_tool_results: bool = False,
|
|
587
|
+
) -> None:
|
|
588
|
+
info = summarize_session(path)
|
|
589
|
+
|
|
590
|
+
def emit(s: str = "") -> None:
|
|
591
|
+
out.write(s + "\n")
|
|
592
|
+
|
|
593
|
+
emit(f"# {info.title or info.first_user_prompt or info.session_id}")
|
|
594
|
+
emit()
|
|
595
|
+
emit(f"- **Session**: `{info.session_id}`")
|
|
596
|
+
emit(f"- **Project**: `{info.project_path}`")
|
|
597
|
+
if info.git_branch:
|
|
598
|
+
emit(f"- **Branch**: `{info.git_branch}`")
|
|
599
|
+
if info.first_ts:
|
|
600
|
+
emit(f"- **Started**: {info.first_ts.astimezone().strftime('%Y-%m-%d %H:%M %Z')}")
|
|
601
|
+
if info.duration:
|
|
602
|
+
emit(f"- **Duration**: {fmt_duration(info.duration)}")
|
|
603
|
+
if info.models:
|
|
604
|
+
emit(f"- **Models**: {', '.join(sorted(info.models))}")
|
|
605
|
+
emit(f"- **Messages**: {info.user_msgs} user, {info.assistant_msgs} assistant")
|
|
606
|
+
emit(f"- **Tokens**: in {fmt_num(info.input_tokens)}, out {fmt_num(info.output_tokens)}")
|
|
607
|
+
emit()
|
|
608
|
+
emit("---")
|
|
609
|
+
emit()
|
|
610
|
+
|
|
611
|
+
for rec in iter_records(path):
|
|
612
|
+
rtype = rec.get("type")
|
|
613
|
+
if rtype not in ("user", "assistant"):
|
|
614
|
+
continue
|
|
615
|
+
msg = rec.get("message") or {}
|
|
616
|
+
ts = parse_ts(rec.get("timestamp"))
|
|
617
|
+
ts_s = ts.astimezone().strftime("%H:%M:%S") if ts else ""
|
|
618
|
+
if rtype == "user":
|
|
619
|
+
is_tr = _looks_like_tool_result(msg)
|
|
620
|
+
if is_tr and not include_tool_results:
|
|
621
|
+
continue
|
|
622
|
+
text = text_of_user_message(msg).strip()
|
|
623
|
+
if not text:
|
|
624
|
+
continue
|
|
625
|
+
label = "Tool Result" if is_tr else "User"
|
|
626
|
+
emit(f"## {label} · {ts_s}")
|
|
627
|
+
emit()
|
|
628
|
+
emit(text)
|
|
629
|
+
emit()
|
|
630
|
+
else:
|
|
631
|
+
text, tools = text_of_assistant_message(msg)
|
|
632
|
+
thinking = thinking_of_assistant_message(msg) if include_thinking else ""
|
|
633
|
+
if not (text.strip() or (tools and include_tools) or thinking):
|
|
634
|
+
continue
|
|
635
|
+
emit(f"## Claude · {ts_s}")
|
|
636
|
+
emit()
|
|
637
|
+
if thinking:
|
|
638
|
+
emit("<details><summary>thinking</summary>")
|
|
639
|
+
emit()
|
|
640
|
+
emit(thinking.strip())
|
|
641
|
+
emit()
|
|
642
|
+
emit("</details>")
|
|
643
|
+
emit()
|
|
644
|
+
if text.strip():
|
|
645
|
+
emit(text.strip())
|
|
646
|
+
emit()
|
|
647
|
+
if include_tools and tools:
|
|
648
|
+
for tu in tools:
|
|
649
|
+
name = tu.get("name", "?")
|
|
650
|
+
inp = tu.get("input") or {}
|
|
651
|
+
summary = _summarize_tool_input(name, inp)
|
|
652
|
+
emit(f"> **{name}** — `{summary}`")
|
|
653
|
+
emit()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ============================================================
|
|
657
|
+
# TF-IDF related sessions
|
|
658
|
+
# ============================================================
|
|
659
|
+
|
|
660
|
+
_TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z\-]{2,}")
|
|
661
|
+
|
|
662
|
+
_STOPWORDS = set("""
|
|
663
|
+
the and that have for not are was you with this but his they from she will would there
|
|
664
|
+
their what about which when make like time just know take into your some could them than
|
|
665
|
+
other only over also after first well way even may use any its our two more new because
|
|
666
|
+
here only most one all how can do does did has had been being were out off back down out
|
|
667
|
+
then them than these those who whom whose where why because while still also yet such
|
|
668
|
+
between very each through during itself himself herself should would could shall might
|
|
669
|
+
must let put per via etc cant dont didnt isnt arent wasnt werent hasnt havent hadnt
|
|
670
|
+
doesnt wouldnt couldnt shouldnt wont thing things lot get got going want need says said
|
|
671
|
+
think thought know knew see saw look looked work works worked made make makes way ways
|
|
672
|
+
ok okay yes yeah sure right thanks please great good bad really maybe probably
|
|
673
|
+
""".split())
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _session_text_for_related(path: Path, max_chars: int = 30000) -> str:
|
|
677
|
+
chunks: list[str] = []
|
|
678
|
+
char_count = 0
|
|
679
|
+
title = ""
|
|
680
|
+
for rec in iter_records(path):
|
|
681
|
+
rtype = rec.get("type")
|
|
682
|
+
if rtype == "ai-title":
|
|
683
|
+
title = rec.get("aiTitle") or ""
|
|
684
|
+
continue
|
|
685
|
+
if rtype not in ("user", "assistant"):
|
|
686
|
+
continue
|
|
687
|
+
msg = rec.get("message") or {}
|
|
688
|
+
if rtype == "user":
|
|
689
|
+
if _looks_like_tool_result(msg):
|
|
690
|
+
continue
|
|
691
|
+
text = text_of_user_message(msg)
|
|
692
|
+
else:
|
|
693
|
+
text, _ = text_of_assistant_message(msg)
|
|
694
|
+
if not text:
|
|
695
|
+
continue
|
|
696
|
+
chunks.append(text)
|
|
697
|
+
char_count += len(text)
|
|
698
|
+
if char_count >= max_chars:
|
|
699
|
+
break
|
|
700
|
+
body = "\n".join(chunks)
|
|
701
|
+
# Boost the title since it's the highest-signal text in the corpus
|
|
702
|
+
return (title + " ") * 5 + body
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _tokenize(text: str) -> list[str]:
|
|
706
|
+
return [t.lower() for t in _TOKEN_RE.findall(text) if t.lower() not in _STOPWORDS]
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _tf_vector(tokens: list[str]) -> dict[str, float]:
|
|
710
|
+
c = Counter(tokens)
|
|
711
|
+
total = sum(c.values()) or 1
|
|
712
|
+
return {t: n / total for t, n in c.items()}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _cosine(a: dict[str, float], b: dict[str, float]) -> float:
|
|
716
|
+
if not a or not b:
|
|
717
|
+
return 0.0
|
|
718
|
+
common = a.keys() & b.keys()
|
|
719
|
+
if not common:
|
|
720
|
+
return 0.0
|
|
721
|
+
dot = sum(a[t] * b[t] for t in common)
|
|
722
|
+
na = math.sqrt(sum(v * v for v in a.values()))
|
|
723
|
+
nb = math.sqrt(sum(v * v for v in b.values()))
|
|
724
|
+
if na == 0 or nb == 0:
|
|
725
|
+
return 0.0
|
|
726
|
+
return dot / (na * nb)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def compute_related(
|
|
730
|
+
target: Path,
|
|
731
|
+
paths: list[Path],
|
|
732
|
+
top_n: int = 10,
|
|
733
|
+
) -> list[tuple[Path, float]]:
|
|
734
|
+
"""Top-N similar sessions by TF-IDF cosine, excluding target."""
|
|
735
|
+
tokens_by_path: dict[Path, list[str]] = {}
|
|
736
|
+
df: Counter[str] = Counter()
|
|
737
|
+
for p in paths:
|
|
738
|
+
toks = _tokenize(_session_text_for_related(p))
|
|
739
|
+
tokens_by_path[p] = toks
|
|
740
|
+
df.update(set(toks))
|
|
741
|
+
n_docs = len(paths)
|
|
742
|
+
idf = {t: math.log((n_docs + 1) / (n + 1)) + 1 for t, n in df.items()}
|
|
743
|
+
|
|
744
|
+
def vec(p: Path) -> dict[str, float]:
|
|
745
|
+
tf = _tf_vector(tokens_by_path[p])
|
|
746
|
+
return {t: w * idf.get(t, 1.0) for t, w in tf.items()}
|
|
747
|
+
|
|
748
|
+
target_vec = vec(target)
|
|
749
|
+
scores: list[tuple[Path, float]] = []
|
|
750
|
+
for p in paths:
|
|
751
|
+
if p == target:
|
|
752
|
+
continue
|
|
753
|
+
s = _cosine(target_vec, vec(p))
|
|
754
|
+
if s > 0:
|
|
755
|
+
scores.append((p, s))
|
|
756
|
+
scores.sort(key=lambda x: x[1], reverse=True)
|
|
757
|
+
return scores[:top_n]
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
# ============================================================
|
|
761
|
+
# commands
|
|
762
|
+
# ============================================================
|
|
763
|
+
|
|
764
|
+
def cmd_projects(args: argparse.Namespace) -> int:
|
|
765
|
+
by_project: dict[str, list[SessionInfo]] = defaultdict(list)
|
|
766
|
+
for f in iter_session_files():
|
|
767
|
+
info = summarize_session(f)
|
|
768
|
+
by_project[info.project_path].append(info)
|
|
769
|
+
if not by_project:
|
|
770
|
+
print(f"No projects found in {ARCHIVE_ROOT}", file=sys.stderr)
|
|
771
|
+
return 1
|
|
772
|
+
|
|
773
|
+
rows = []
|
|
774
|
+
for path, sessions in by_project.items():
|
|
775
|
+
last = max((s.last_ts for s in sessions if s.last_ts), default=None)
|
|
776
|
+
in_tok = sum(s.input_tokens for s in sessions)
|
|
777
|
+
out_tok = sum(s.output_tokens for s in sessions)
|
|
778
|
+
rows.append(
|
|
779
|
+
(last or datetime.min.replace(tzinfo=timezone.utc), path, len(sessions), in_tok, out_tok)
|
|
780
|
+
)
|
|
781
|
+
rows.sort(reverse=True)
|
|
782
|
+
|
|
783
|
+
if args.json:
|
|
784
|
+
print(json.dumps([
|
|
785
|
+
{
|
|
786
|
+
"project": p, "sessions": n,
|
|
787
|
+
"input_tokens": i, "output_tokens": o,
|
|
788
|
+
"last_ts": last.isoformat() if last and last.year > 1 else None,
|
|
789
|
+
}
|
|
790
|
+
for last, p, n, i, o in rows
|
|
791
|
+
], indent=2))
|
|
792
|
+
return 0
|
|
793
|
+
|
|
794
|
+
width = term_width()
|
|
795
|
+
path_w = max(30, width - 50)
|
|
796
|
+
print(f"{C.BOLD}{'project':<{path_w}} {'sessions':>9} {'in':>8} {'out':>8} {'last':>12}{C.RESET}")
|
|
797
|
+
print(hr())
|
|
798
|
+
for last, path, n, in_t, out_t in rows:
|
|
799
|
+
print(
|
|
800
|
+
f"{C.CYAN}{short_path(path, path_w):<{path_w}}{C.RESET} "
|
|
801
|
+
f"{n:>9} {fmt_num(in_t):>8} {fmt_num(out_t):>8} "
|
|
802
|
+
f"{C.GREY}{fmt_ts(last):>12}{C.RESET}"
|
|
803
|
+
)
|
|
804
|
+
print(hr())
|
|
805
|
+
print(f"{C.DIM}{sum(len(s) for s in by_project.values())} sessions across {len(by_project)} projects{C.RESET}")
|
|
806
|
+
return 0
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _sort_sessions(sessions: list[SessionInfo], key: str) -> list[SessionInfo]:
|
|
810
|
+
if key == "recent":
|
|
811
|
+
return sessions
|
|
812
|
+
if key == "tokens":
|
|
813
|
+
return sorted(sessions, key=lambda s: s.input_tokens + s.output_tokens, reverse=True)
|
|
814
|
+
if key == "messages":
|
|
815
|
+
return sorted(sessions, key=lambda s: s.total_msgs, reverse=True)
|
|
816
|
+
if key == "duration":
|
|
817
|
+
return sorted(sessions, key=lambda s: s.duration or timedelta(0), reverse=True)
|
|
818
|
+
if key == "input":
|
|
819
|
+
return sorted(sessions, key=lambda s: s.input_tokens, reverse=True)
|
|
820
|
+
if key == "output":
|
|
821
|
+
return sorted(sessions, key=lambda s: s.output_tokens, reverse=True)
|
|
822
|
+
return sessions
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _resolve_since(args: argparse.Namespace) -> datetime | None:
|
|
826
|
+
if getattr(args, "since", None):
|
|
827
|
+
return parse_when(args.since)
|
|
828
|
+
if getattr(args, "days", None):
|
|
829
|
+
return datetime.now(timezone.utc) - timedelta(days=args.days)
|
|
830
|
+
return None
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
834
|
+
since = _resolve_since(args)
|
|
835
|
+
until = parse_when(getattr(args, "until", None))
|
|
836
|
+
sessions = collect_sessions(project_filter=args.project, since=since, until=until)
|
|
837
|
+
sessions = _sort_sessions(sessions, args.sort)
|
|
838
|
+
if args.limit:
|
|
839
|
+
sessions = sessions[: args.limit]
|
|
840
|
+
|
|
841
|
+
if args.json:
|
|
842
|
+
print(json.dumps([_session_to_dict(s) for s in sessions], indent=2, default=str))
|
|
843
|
+
return 0
|
|
844
|
+
|
|
845
|
+
if not sessions:
|
|
846
|
+
print("No sessions match.", file=sys.stderr)
|
|
847
|
+
return 1
|
|
848
|
+
|
|
849
|
+
width = term_width()
|
|
850
|
+
id_w = 8
|
|
851
|
+
proj_w = 22
|
|
852
|
+
title_w = max(30, width - id_w - proj_w - 28)
|
|
853
|
+
print(f"{C.BOLD}{'id':<{id_w}} {'project':<{proj_w}} {'msgs':>6} {'last':>10} {'title':<{title_w}}{C.RESET}")
|
|
854
|
+
print(hr())
|
|
855
|
+
for s in sessions:
|
|
856
|
+
proj = Path(s.project_path).name or s.project_path
|
|
857
|
+
title = s.title or s.first_user_prompt or f"{C.GREY}(no title){C.RESET}"
|
|
858
|
+
print(
|
|
859
|
+
f"{C.YELLOW}{s.session_id[:id_w]}{C.RESET} "
|
|
860
|
+
f"{C.CYAN}{truncate(proj, proj_w):<{proj_w}}{C.RESET} "
|
|
861
|
+
f"{s.total_msgs:>6} "
|
|
862
|
+
f"{C.GREY}{fmt_ts(s.last_ts):>10}{C.RESET} "
|
|
863
|
+
f"{truncate(title, title_w)}"
|
|
864
|
+
)
|
|
865
|
+
print(hr())
|
|
866
|
+
print(f"{C.DIM}{len(sessions)} sessions{C.RESET}")
|
|
867
|
+
return 0
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def cmd_last(args: argparse.Namespace) -> int:
|
|
871
|
+
sessions = collect_sessions(project_filter=args.project)
|
|
872
|
+
if not sessions:
|
|
873
|
+
print("No sessions.", file=sys.stderr)
|
|
874
|
+
return 1
|
|
875
|
+
args.session = sessions[0].session_id
|
|
876
|
+
return cmd_show(args)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
880
|
+
if not args.query:
|
|
881
|
+
print("usage: sift search <query>", file=sys.stderr)
|
|
882
|
+
return 2
|
|
883
|
+
pat_flags = 0 if args.case_sensitive else re.IGNORECASE
|
|
884
|
+
try:
|
|
885
|
+
pat = re.compile(args.query if args.regex else re.escape(args.query), pat_flags)
|
|
886
|
+
except re.error as e:
|
|
887
|
+
print(f"{C.RED}bad regex: {e}{C.RESET}", file=sys.stderr)
|
|
888
|
+
return 2
|
|
889
|
+
|
|
890
|
+
roles: set[str] = set()
|
|
891
|
+
if args.user:
|
|
892
|
+
roles.add("user")
|
|
893
|
+
if args.assistant:
|
|
894
|
+
roles.add("assistant")
|
|
895
|
+
if not roles:
|
|
896
|
+
roles = {"user", "assistant"}
|
|
897
|
+
|
|
898
|
+
since = _resolve_since(args)
|
|
899
|
+
total_hits = 0
|
|
900
|
+
sessions_with_hits = 0
|
|
901
|
+
|
|
902
|
+
for f in iter_session_files(args.project):
|
|
903
|
+
try:
|
|
904
|
+
with open(f, "rb") as fh:
|
|
905
|
+
blob = fh.read()
|
|
906
|
+
if not pat.search(blob.decode("utf-8", errors="replace")):
|
|
907
|
+
continue
|
|
908
|
+
except OSError:
|
|
909
|
+
continue
|
|
910
|
+
|
|
911
|
+
hits = list(_search_records(f, pat, roles, since, context=args.context))
|
|
912
|
+
if not hits:
|
|
913
|
+
continue
|
|
914
|
+
sessions_with_hits += 1
|
|
915
|
+
total_hits += len(hits)
|
|
916
|
+
|
|
917
|
+
if args.files_only:
|
|
918
|
+
print(f.stem)
|
|
919
|
+
if args.limit and sessions_with_hits >= args.limit:
|
|
920
|
+
break
|
|
921
|
+
continue
|
|
922
|
+
|
|
923
|
+
info = summarize_session(f)
|
|
924
|
+
header = f"{C.BOLD}{C.YELLOW}{info.session_id[:8]}{C.RESET} {C.CYAN}{info.project_path}{C.RESET}"
|
|
925
|
+
if info.title:
|
|
926
|
+
header += f" {C.DIM}{info.title}{C.RESET}"
|
|
927
|
+
print(header)
|
|
928
|
+
for h in hits:
|
|
929
|
+
ts_s = fmt_ts(h["ts"])
|
|
930
|
+
role_color = C.GREEN if h["role"] == "user" else C.MAGENTA
|
|
931
|
+
print(f" {role_color}{h['role']:>9}{C.RESET} {C.GREY}{ts_s}{C.RESET}")
|
|
932
|
+
for line in h["lines"]:
|
|
933
|
+
highlighted = pat.sub(
|
|
934
|
+
lambda m: f"{C.BOLD}{C.RED}{m.group(0)}{C.RESET}", line
|
|
935
|
+
)
|
|
936
|
+
print(f" {highlighted}")
|
|
937
|
+
print()
|
|
938
|
+
|
|
939
|
+
if args.limit and sessions_with_hits >= args.limit:
|
|
940
|
+
break
|
|
941
|
+
|
|
942
|
+
if not args.files_only:
|
|
943
|
+
print(f"{C.DIM}{total_hits} matches in {sessions_with_hits} sessions{C.RESET}")
|
|
944
|
+
return 0 if total_hits else 1
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def _search_records(path, pat, roles, since, context):
|
|
948
|
+
for rec in iter_records(path):
|
|
949
|
+
rtype = rec.get("type")
|
|
950
|
+
if rtype not in roles:
|
|
951
|
+
continue
|
|
952
|
+
ts = parse_ts(rec.get("timestamp"))
|
|
953
|
+
if since and ts and ts < since:
|
|
954
|
+
continue
|
|
955
|
+
msg = rec.get("message") or {}
|
|
956
|
+
if rtype == "user":
|
|
957
|
+
if _looks_like_tool_result(msg):
|
|
958
|
+
continue
|
|
959
|
+
text = text_of_user_message(msg)
|
|
960
|
+
else:
|
|
961
|
+
text, _ = text_of_assistant_message(msg)
|
|
962
|
+
if not text:
|
|
963
|
+
continue
|
|
964
|
+
m = list(pat.finditer(text))
|
|
965
|
+
if not m:
|
|
966
|
+
continue
|
|
967
|
+
lines = text.splitlines() or [text]
|
|
968
|
+
marked: set[int] = set()
|
|
969
|
+
for match in m:
|
|
970
|
+
upto = text[: match.start()]
|
|
971
|
+
ln = upto.count("\n")
|
|
972
|
+
for i in range(max(0, ln - context), min(len(lines), ln + context + 1)):
|
|
973
|
+
marked.add(i)
|
|
974
|
+
ordered = sorted(marked)
|
|
975
|
+
out_lines: list[str] = []
|
|
976
|
+
prev = -2
|
|
977
|
+
for i in ordered:
|
|
978
|
+
if i > prev + 1 and out_lines:
|
|
979
|
+
out_lines.append(f"{C.GREY}…{C.RESET}")
|
|
980
|
+
out_lines.append(lines[i][:240])
|
|
981
|
+
prev = i
|
|
982
|
+
yield {"role": rtype, "ts": ts, "lines": out_lines}
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
986
|
+
f = find_session(args.session)
|
|
987
|
+
if f is None:
|
|
988
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
989
|
+
return 1
|
|
990
|
+
pager = _maybe_pager(getattr(args, "no_pager", False))
|
|
991
|
+
out = pager.stdin if pager else sys.stdout
|
|
992
|
+
_render_session(
|
|
993
|
+
f, out,
|
|
994
|
+
show_thinking=getattr(args, "thinking", False),
|
|
995
|
+
show_tool_results=getattr(args, "tool_results", False),
|
|
996
|
+
)
|
|
997
|
+
if pager:
|
|
998
|
+
try:
|
|
999
|
+
pager.stdin.close()
|
|
1000
|
+
pager.wait()
|
|
1001
|
+
except Exception:
|
|
1002
|
+
pass
|
|
1003
|
+
return 0
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
_CODE_EXT = {
|
|
1007
|
+
"python": ".py", "py": ".py",
|
|
1008
|
+
"typescript": ".ts", "ts": ".ts",
|
|
1009
|
+
"javascript": ".js", "js": ".js",
|
|
1010
|
+
"tsx": ".tsx", "jsx": ".jsx",
|
|
1011
|
+
"rust": ".rs", "rs": ".rs",
|
|
1012
|
+
"go": ".go",
|
|
1013
|
+
"swift": ".swift",
|
|
1014
|
+
"kotlin": ".kt", "kt": ".kt",
|
|
1015
|
+
"java": ".java",
|
|
1016
|
+
"c": ".c", "cpp": ".cpp", "c++": ".cpp",
|
|
1017
|
+
"ruby": ".rb", "rb": ".rb",
|
|
1018
|
+
"php": ".php",
|
|
1019
|
+
"bash": ".sh", "sh": ".sh", "shell": ".sh", "zsh": ".sh",
|
|
1020
|
+
"json": ".json", "yaml": ".yml", "yml": ".yml",
|
|
1021
|
+
"toml": ".toml",
|
|
1022
|
+
"markdown": ".md", "md": ".md",
|
|
1023
|
+
"sql": ".sql",
|
|
1024
|
+
"html": ".html", "css": ".css", "scss": ".scss",
|
|
1025
|
+
"dockerfile": ".Dockerfile",
|
|
1026
|
+
"diff": ".diff", "patch": ".patch",
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def cmd_code(args: argparse.Namespace) -> int:
|
|
1031
|
+
f = find_session(args.session)
|
|
1032
|
+
if f is None:
|
|
1033
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1034
|
+
return 1
|
|
1035
|
+
fence = re.compile(r"```([a-zA-Z0-9_+\-]*)\n(.*?)```", re.DOTALL)
|
|
1036
|
+
blocks: list[tuple[str, str, str]] = []
|
|
1037
|
+
for rec in iter_records(f):
|
|
1038
|
+
rtype = rec.get("type")
|
|
1039
|
+
if rtype not in ("user", "assistant"):
|
|
1040
|
+
continue
|
|
1041
|
+
msg = rec.get("message") or {}
|
|
1042
|
+
if rtype == "user":
|
|
1043
|
+
if _looks_like_tool_result(msg):
|
|
1044
|
+
continue
|
|
1045
|
+
text = text_of_user_message(msg)
|
|
1046
|
+
else:
|
|
1047
|
+
text, _ = text_of_assistant_message(msg)
|
|
1048
|
+
for m in fence.finditer(text):
|
|
1049
|
+
lang = m.group(1) or ""
|
|
1050
|
+
code = m.group(2).rstrip()
|
|
1051
|
+
if args.lang and lang.lower() != args.lang.lower():
|
|
1052
|
+
continue
|
|
1053
|
+
blocks.append((lang, rtype, code))
|
|
1054
|
+
|
|
1055
|
+
if not blocks:
|
|
1056
|
+
print("No fenced code blocks found.", file=sys.stderr)
|
|
1057
|
+
return 1
|
|
1058
|
+
|
|
1059
|
+
if args.out_dir:
|
|
1060
|
+
outdir = Path(args.out_dir)
|
|
1061
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
1062
|
+
for i, (lang, role, code) in enumerate(blocks, 1):
|
|
1063
|
+
ext = _CODE_EXT.get(lang.lower(), ".txt") if lang else ".txt"
|
|
1064
|
+
out = outdir / f"{i:03d}-{role}{ext}"
|
|
1065
|
+
out.write_text(code + "\n")
|
|
1066
|
+
print(f"Wrote {len(blocks)} blocks to {outdir}/")
|
|
1067
|
+
return 0
|
|
1068
|
+
|
|
1069
|
+
for i, (lang, role, code) in enumerate(blocks, 1):
|
|
1070
|
+
print(f"{C.BOLD}{C.YELLOW}[{i}]{C.RESET} {C.CYAN}{lang or 'text'}{C.RESET} {C.GREY}({role}){C.RESET}")
|
|
1071
|
+
print(code)
|
|
1072
|
+
print()
|
|
1073
|
+
return 0
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def cmd_stats(args: argparse.Namespace) -> int:
|
|
1077
|
+
since = _resolve_since(args)
|
|
1078
|
+
until = parse_when(getattr(args, "until", None))
|
|
1079
|
+
sessions = collect_sessions(project_filter=args.project, since=since, until=until)
|
|
1080
|
+
if not sessions:
|
|
1081
|
+
print("No sessions.", file=sys.stderr)
|
|
1082
|
+
return 1
|
|
1083
|
+
|
|
1084
|
+
n = len(sessions)
|
|
1085
|
+
tot_user = sum(s.user_msgs for s in sessions)
|
|
1086
|
+
tot_ass = sum(s.assistant_msgs for s in sessions)
|
|
1087
|
+
tot_in = sum(s.input_tokens for s in sessions)
|
|
1088
|
+
tot_out = sum(s.output_tokens for s in sessions)
|
|
1089
|
+
tot_cr = sum(s.cache_read for s in sessions)
|
|
1090
|
+
tot_cc = sum(s.cache_create for s in sessions)
|
|
1091
|
+
avg_msgs = (tot_user + tot_ass) // n if n else 0
|
|
1092
|
+
avg_out = tot_out // n if n else 0
|
|
1093
|
+
|
|
1094
|
+
by_model: Counter[str] = Counter()
|
|
1095
|
+
by_day: Counter[str] = Counter()
|
|
1096
|
+
by_hour: Counter[int] = Counter()
|
|
1097
|
+
by_project: Counter[str] = Counter()
|
|
1098
|
+
|
|
1099
|
+
longest_by_msgs = max(sessions, key=lambda s: s.total_msgs, default=None)
|
|
1100
|
+
longest_by_dur = max(
|
|
1101
|
+
(s for s in sessions if s.duration), key=lambda s: s.duration, default=None
|
|
1102
|
+
)
|
|
1103
|
+
biggest_out = max(sessions, key=lambda s: s.output_tokens, default=None)
|
|
1104
|
+
|
|
1105
|
+
for s in sessions:
|
|
1106
|
+
for m in s.models:
|
|
1107
|
+
by_model[m] += 1
|
|
1108
|
+
if s.last_ts:
|
|
1109
|
+
local = s.last_ts.astimezone()
|
|
1110
|
+
by_day[local.strftime("%Y-%m-%d")] += 1
|
|
1111
|
+
by_hour[local.hour] += 1
|
|
1112
|
+
by_project[s.project_path] += 1
|
|
1113
|
+
|
|
1114
|
+
if args.json:
|
|
1115
|
+
print(json.dumps({
|
|
1116
|
+
"sessions": n,
|
|
1117
|
+
"user_messages": tot_user,
|
|
1118
|
+
"assistant_messages": tot_ass,
|
|
1119
|
+
"input_tokens": tot_in,
|
|
1120
|
+
"output_tokens": tot_out,
|
|
1121
|
+
"cache_read": tot_cr,
|
|
1122
|
+
"cache_create": tot_cc,
|
|
1123
|
+
"avg_messages_per_session": avg_msgs,
|
|
1124
|
+
"avg_output_per_session": avg_out,
|
|
1125
|
+
"by_model": dict(by_model),
|
|
1126
|
+
"by_day": dict(by_day),
|
|
1127
|
+
"by_hour": dict(by_hour),
|
|
1128
|
+
"by_project": dict(by_project),
|
|
1129
|
+
"longest_by_msgs": longest_by_msgs.session_id if longest_by_msgs else None,
|
|
1130
|
+
"longest_by_duration": longest_by_dur.session_id if longest_by_dur else None,
|
|
1131
|
+
"biggest_output": biggest_out.session_id if biggest_out else None,
|
|
1132
|
+
}, indent=2))
|
|
1133
|
+
return 0
|
|
1134
|
+
|
|
1135
|
+
label = C.DIM
|
|
1136
|
+
print(f"{C.BOLD}Summary{C.RESET}")
|
|
1137
|
+
print(f" {label}sessions {C.RESET}{n}")
|
|
1138
|
+
print(f" {label}messages {C.RESET}{tot_user} user, {tot_ass} assistant (avg {avg_msgs}/session)")
|
|
1139
|
+
print(f" {label}input tok {C.RESET}{fmt_num(tot_in)}")
|
|
1140
|
+
print(f" {label}output tok {C.RESET}{fmt_num(tot_out)} (avg {fmt_num(avg_out)}/session)")
|
|
1141
|
+
print(f" {label}cache read {C.RESET}{fmt_num(tot_cr)}")
|
|
1142
|
+
print(f" {label}cache create {C.RESET}{fmt_num(tot_cc)}")
|
|
1143
|
+
|
|
1144
|
+
print()
|
|
1145
|
+
print(f"{C.BOLD}By model{C.RESET}")
|
|
1146
|
+
for m, c in by_model.most_common():
|
|
1147
|
+
print(f" {C.CYAN}{m:<28}{C.RESET} {c}")
|
|
1148
|
+
|
|
1149
|
+
print()
|
|
1150
|
+
print(f"{C.BOLD}Top projects{C.RESET}")
|
|
1151
|
+
for p, c in by_project.most_common(10):
|
|
1152
|
+
print(f" {C.CYAN}{short_path(p, 50):<50}{C.RESET} {c}")
|
|
1153
|
+
|
|
1154
|
+
if longest_by_msgs:
|
|
1155
|
+
print()
|
|
1156
|
+
print(f"{C.BOLD}Standouts{C.RESET}")
|
|
1157
|
+
print(
|
|
1158
|
+
f" {label}most messages {C.RESET}"
|
|
1159
|
+
f"{C.YELLOW}{longest_by_msgs.session_id[:8]}{C.RESET} "
|
|
1160
|
+
f"{longest_by_msgs.total_msgs} msgs "
|
|
1161
|
+
f"{C.DIM}{truncate(longest_by_msgs.title or longest_by_msgs.first_user_prompt, 50)}{C.RESET}"
|
|
1162
|
+
)
|
|
1163
|
+
if longest_by_dur:
|
|
1164
|
+
print(
|
|
1165
|
+
f" {label}longest duration {C.RESET}"
|
|
1166
|
+
f"{C.YELLOW}{longest_by_dur.session_id[:8]}{C.RESET} "
|
|
1167
|
+
f"{fmt_duration(longest_by_dur.duration)} "
|
|
1168
|
+
f"{C.DIM}{truncate(longest_by_dur.title or longest_by_dur.first_user_prompt, 50)}{C.RESET}"
|
|
1169
|
+
)
|
|
1170
|
+
if biggest_out:
|
|
1171
|
+
print(
|
|
1172
|
+
f" {label}biggest output {C.RESET}"
|
|
1173
|
+
f"{C.YELLOW}{biggest_out.session_id[:8]}{C.RESET} "
|
|
1174
|
+
f"{fmt_num(biggest_out.output_tokens)} tok "
|
|
1175
|
+
f"{C.DIM}{truncate(biggest_out.title or biggest_out.first_user_prompt, 50)}{C.RESET}"
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
if by_hour:
|
|
1179
|
+
print()
|
|
1180
|
+
print(f"{C.BOLD}Hour of day{C.RESET}")
|
|
1181
|
+
max_h = max(by_hour.values()) or 1
|
|
1182
|
+
bar_chars = " ▁▂▃▄▅▆▇█"
|
|
1183
|
+
row = "".join(
|
|
1184
|
+
bar_chars[int((by_hour.get(h, 0) / max_h) * (len(bar_chars) - 1))]
|
|
1185
|
+
for h in range(24)
|
|
1186
|
+
)
|
|
1187
|
+
print(f" {C.GREEN}{row}{C.RESET}")
|
|
1188
|
+
print(f" {C.DIM}0 6 12 18 23{C.RESET}")
|
|
1189
|
+
|
|
1190
|
+
if args.year:
|
|
1191
|
+
_print_year_heatmap(by_day)
|
|
1192
|
+
else:
|
|
1193
|
+
_print_thirty_day_strip(by_day)
|
|
1194
|
+
|
|
1195
|
+
return 0
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def _print_thirty_day_strip(by_day: Counter[str]) -> None:
|
|
1199
|
+
print()
|
|
1200
|
+
print(f"{C.BOLD}Last 30 days{C.RESET}")
|
|
1201
|
+
today = datetime.now().date()
|
|
1202
|
+
days = [today - timedelta(days=i) for i in range(29, -1, -1)]
|
|
1203
|
+
max_c = max((by_day.get(d.strftime("%Y-%m-%d"), 0) for d in days), default=0) or 1
|
|
1204
|
+
bar_chars = " ▁▂▃▄▅▆▇█"
|
|
1205
|
+
row = "".join(
|
|
1206
|
+
bar_chars[int((by_day.get(d.strftime("%Y-%m-%d"), 0) / max_c) * (len(bar_chars) - 1))]
|
|
1207
|
+
for d in days
|
|
1208
|
+
)
|
|
1209
|
+
print(f" {C.GREEN}{row}{C.RESET}")
|
|
1210
|
+
print(f" {C.DIM}{days[0].strftime('%b %d')}{' ' * (30 - 12)}{days[-1].strftime('%b %d')}{C.RESET}")
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _print_year_heatmap(by_day: Counter[str]) -> None:
|
|
1214
|
+
print()
|
|
1215
|
+
print(f"{C.BOLD}Last 52 weeks{C.RESET}")
|
|
1216
|
+
today = datetime.now().date()
|
|
1217
|
+
days_back = 7 * 52 + today.weekday()
|
|
1218
|
+
start = today - timedelta(days=days_back)
|
|
1219
|
+
weeks = (today - start).days // 7 + 1
|
|
1220
|
+
max_c = max(by_day.values()) if by_day else 1
|
|
1221
|
+
chars = [" ", "·", "▪", "■", "█"]
|
|
1222
|
+
grid = [[" " for _ in range(weeks)] for _ in range(7)]
|
|
1223
|
+
for w in range(weeks):
|
|
1224
|
+
for d in range(7):
|
|
1225
|
+
day = start + timedelta(days=w * 7 + d)
|
|
1226
|
+
if day > today:
|
|
1227
|
+
continue
|
|
1228
|
+
c = by_day.get(day.strftime("%Y-%m-%d"), 0)
|
|
1229
|
+
if c == 0:
|
|
1230
|
+
grid[d][w] = f"{C.GREY}·{C.RESET} "
|
|
1231
|
+
else:
|
|
1232
|
+
level = min(4, int(math.ceil((c / max_c) * 4)))
|
|
1233
|
+
grid[d][w] = f"{C.GREEN}{chars[level]}{C.RESET} "
|
|
1234
|
+
for label, row in zip(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"], grid):
|
|
1235
|
+
print(f" {C.DIM}{label}{C.RESET} {''.join(row)}")
|
|
1236
|
+
print(f" {C.DIM}{start.strftime('%b %Y')}{' ' * max(0, weeks - 14)}{today.strftime('%b %Y')}{C.RESET}")
|
|
1237
|
+
|
|
1238
|
+
|
|
1239
|
+
def cmd_path(args: argparse.Namespace) -> int:
|
|
1240
|
+
f = find_session(args.session)
|
|
1241
|
+
if f is None:
|
|
1242
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1243
|
+
return 1
|
|
1244
|
+
print(f)
|
|
1245
|
+
return 0
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def cmd_open(args: argparse.Namespace) -> int:
|
|
1249
|
+
f = find_session(args.session)
|
|
1250
|
+
if f is None:
|
|
1251
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1252
|
+
return 1
|
|
1253
|
+
editor = os.environ.get("EDITOR") or "vi"
|
|
1254
|
+
return subprocess.call([editor, str(f)])
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def cmd_resume(args: argparse.Namespace) -> int:
|
|
1258
|
+
f = find_session(args.session)
|
|
1259
|
+
if f is None:
|
|
1260
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1261
|
+
return 1
|
|
1262
|
+
info = summarize_session(f)
|
|
1263
|
+
cwd = info.cwd or str(f.parent)
|
|
1264
|
+
cmd = ["claude", "--resume", info.session_id]
|
|
1265
|
+
if args.exec:
|
|
1266
|
+
if Path(cwd).exists():
|
|
1267
|
+
os.chdir(cwd)
|
|
1268
|
+
try:
|
|
1269
|
+
os.execvp(cmd[0], cmd)
|
|
1270
|
+
except FileNotFoundError:
|
|
1271
|
+
print(f"{C.RED}claude not found on PATH{C.RESET}", file=sys.stderr)
|
|
1272
|
+
return 1
|
|
1273
|
+
print(f"cd {cwd} && {' '.join(cmd)}")
|
|
1274
|
+
print(f"{C.DIM}(use --exec to run it now){C.RESET}", file=sys.stderr)
|
|
1275
|
+
return 0
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def cmd_files(args: argparse.Namespace) -> int:
|
|
1279
|
+
f = find_session(args.session)
|
|
1280
|
+
if f is None:
|
|
1281
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1282
|
+
return 1
|
|
1283
|
+
by_file: dict[str, Counter[str]] = defaultdict(Counter)
|
|
1284
|
+
for _ts, tu in iter_tool_uses(f):
|
|
1285
|
+
name = tu.get("name", "")
|
|
1286
|
+
inp = tu.get("input") or {}
|
|
1287
|
+
if name in ("Read", "Edit", "Write", "NotebookEdit", "MultiEdit"):
|
|
1288
|
+
fp = inp.get("file_path")
|
|
1289
|
+
if not fp:
|
|
1290
|
+
continue
|
|
1291
|
+
by_file[fp][name] += 1
|
|
1292
|
+
if not by_file:
|
|
1293
|
+
print("No file operations.", file=sys.stderr)
|
|
1294
|
+
return 1
|
|
1295
|
+
sorted_files = sorted(by_file.items(), key=lambda kv: -sum(kv[1].values()))
|
|
1296
|
+
name_w = max(20, term_width() - 35)
|
|
1297
|
+
print(f"{C.BOLD}{'file':<{name_w}} {'ops':>6} breakdown{C.RESET}")
|
|
1298
|
+
print(hr())
|
|
1299
|
+
for fp, counts in sorted_files:
|
|
1300
|
+
total = sum(counts.values())
|
|
1301
|
+
breakdown = " ".join(f"{C.CYAN}{op}×{n}{C.RESET}" for op, n in counts.most_common())
|
|
1302
|
+
print(f"{short_path(fp, name_w):<{name_w}} {total:>6} {breakdown}")
|
|
1303
|
+
print(hr())
|
|
1304
|
+
print(
|
|
1305
|
+
f"{C.DIM}{len(by_file)} files touched, "
|
|
1306
|
+
f"{sum(sum(c.values()) for c in by_file.values())} operations{C.RESET}"
|
|
1307
|
+
)
|
|
1308
|
+
return 0
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def cmd_tools(args: argparse.Namespace) -> int:
|
|
1312
|
+
f = find_session(args.session)
|
|
1313
|
+
if f is None:
|
|
1314
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1315
|
+
return 1
|
|
1316
|
+
counts: Counter[str] = Counter()
|
|
1317
|
+
for _ts, tu in iter_tool_uses(f):
|
|
1318
|
+
counts[tu.get("name", "?")] += 1
|
|
1319
|
+
if not counts:
|
|
1320
|
+
print("No tool calls.", file=sys.stderr)
|
|
1321
|
+
return 1
|
|
1322
|
+
max_c = max(counts.values())
|
|
1323
|
+
name_w = max(len(n) for n in counts)
|
|
1324
|
+
bar_w = max(20, term_width() - name_w - 16)
|
|
1325
|
+
print(f"{C.BOLD}{'tool':<{name_w}} {'count':>6} bar{C.RESET}")
|
|
1326
|
+
print(hr())
|
|
1327
|
+
for name, c in counts.most_common():
|
|
1328
|
+
bar_len = max(1, int((c / max_c) * bar_w))
|
|
1329
|
+
bar = C.CYAN + "█" * bar_len + C.RESET
|
|
1330
|
+
print(f"{name:<{name_w}} {c:>6} {bar}")
|
|
1331
|
+
print(hr())
|
|
1332
|
+
print(f"{C.DIM}{sum(counts.values())} tool calls across {len(counts)} tools{C.RESET}")
|
|
1333
|
+
return 0
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def cmd_bash(args: argparse.Namespace) -> int:
|
|
1337
|
+
f = find_session(args.session)
|
|
1338
|
+
if f is None:
|
|
1339
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1340
|
+
return 1
|
|
1341
|
+
n = 0
|
|
1342
|
+
for ts, tu in iter_tool_uses(f):
|
|
1343
|
+
if tu.get("name") != "Bash":
|
|
1344
|
+
continue
|
|
1345
|
+
cmd = (tu.get("input") or {}).get("command", "")
|
|
1346
|
+
if not cmd:
|
|
1347
|
+
continue
|
|
1348
|
+
ts_s = ts.astimezone().strftime("%H:%M:%S") if ts else " "
|
|
1349
|
+
if args.plain:
|
|
1350
|
+
print(cmd)
|
|
1351
|
+
else:
|
|
1352
|
+
print(f"{C.GREY}{ts_s}{C.RESET} {cmd}")
|
|
1353
|
+
n += 1
|
|
1354
|
+
if n == 0:
|
|
1355
|
+
print("No Bash commands.", file=sys.stderr)
|
|
1356
|
+
return 1
|
|
1357
|
+
return 0
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def cmd_links(args: argparse.Namespace) -> int:
|
|
1361
|
+
f = find_session(args.session)
|
|
1362
|
+
if f is None:
|
|
1363
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1364
|
+
return 1
|
|
1365
|
+
url_counts: Counter[str] = Counter()
|
|
1366
|
+
by_role: dict[str, Counter[str]] = defaultdict(Counter)
|
|
1367
|
+
for rec in iter_records(f):
|
|
1368
|
+
rtype = rec.get("type")
|
|
1369
|
+
if rtype not in ("user", "assistant"):
|
|
1370
|
+
continue
|
|
1371
|
+
msg = rec.get("message") or {}
|
|
1372
|
+
if rtype == "user":
|
|
1373
|
+
role = "tool" if _looks_like_tool_result(msg) else "user"
|
|
1374
|
+
text = text_of_user_message(msg)
|
|
1375
|
+
else:
|
|
1376
|
+
text, _ = text_of_assistant_message(msg)
|
|
1377
|
+
role = "assistant"
|
|
1378
|
+
for u in extract_urls(text):
|
|
1379
|
+
url_counts[u] += 1
|
|
1380
|
+
by_role[role][u] += 1
|
|
1381
|
+
if not url_counts:
|
|
1382
|
+
print("No URLs found.", file=sys.stderr)
|
|
1383
|
+
return 1
|
|
1384
|
+
if args.plain:
|
|
1385
|
+
for u in url_counts:
|
|
1386
|
+
print(u)
|
|
1387
|
+
return 0
|
|
1388
|
+
print(f"{C.BOLD}{'#':>4} url{C.RESET}")
|
|
1389
|
+
print(hr())
|
|
1390
|
+
for u, c in url_counts.most_common():
|
|
1391
|
+
primary = max(by_role.keys(), key=lambda r: by_role[r].get(u, 0))
|
|
1392
|
+
rcolor = {"user": C.GREEN, "assistant": C.MAGENTA, "tool": C.GREY}.get(primary, "")
|
|
1393
|
+
print(f"{C.DIM}{c:>4}{C.RESET} {rcolor}{u}{C.RESET}")
|
|
1394
|
+
print(hr())
|
|
1395
|
+
print(f"{C.DIM}{len(url_counts)} unique URLs, {sum(url_counts.values())} mentions{C.RESET}")
|
|
1396
|
+
return 0
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def cmd_export(args: argparse.Namespace) -> int:
|
|
1400
|
+
f = find_session(args.session)
|
|
1401
|
+
if f is None:
|
|
1402
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1403
|
+
return 1
|
|
1404
|
+
if args.output:
|
|
1405
|
+
with open(args.output, "w", encoding="utf-8") as out:
|
|
1406
|
+
_render_markdown(
|
|
1407
|
+
f, out,
|
|
1408
|
+
include_thinking=args.thinking,
|
|
1409
|
+
include_tools=not args.no_tools,
|
|
1410
|
+
include_tool_results=args.tool_results,
|
|
1411
|
+
)
|
|
1412
|
+
print(f"Wrote {args.output}")
|
|
1413
|
+
else:
|
|
1414
|
+
_render_markdown(
|
|
1415
|
+
f, sys.stdout,
|
|
1416
|
+
include_thinking=args.thinking,
|
|
1417
|
+
include_tools=not args.no_tools,
|
|
1418
|
+
include_tool_results=args.tool_results,
|
|
1419
|
+
)
|
|
1420
|
+
return 0
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def cmd_related(args: argparse.Namespace) -> int:
|
|
1424
|
+
f = find_session(args.session)
|
|
1425
|
+
if f is None:
|
|
1426
|
+
print(f"{C.RED}No session matching '{args.session}'{C.RESET}", file=sys.stderr)
|
|
1427
|
+
return 1
|
|
1428
|
+
paths = list(iter_session_files(args.project))
|
|
1429
|
+
if f not in paths:
|
|
1430
|
+
paths.append(f)
|
|
1431
|
+
if len(paths) < 2:
|
|
1432
|
+
print("Need at least 2 sessions to compare.", file=sys.stderr)
|
|
1433
|
+
return 1
|
|
1434
|
+
print(f"{C.DIM}Comparing against {len(paths) - 1} sessions…{C.RESET}", file=sys.stderr)
|
|
1435
|
+
results = compute_related(f, paths, top_n=args.limit)
|
|
1436
|
+
if not results:
|
|
1437
|
+
print("No related sessions found.", file=sys.stderr)
|
|
1438
|
+
return 1
|
|
1439
|
+
width = term_width()
|
|
1440
|
+
title_w = max(30, width - 30)
|
|
1441
|
+
print(f"{C.BOLD}{'score':>6} {'id':<10} {'title':<{title_w}}{C.RESET}")
|
|
1442
|
+
print(hr())
|
|
1443
|
+
for p, score in results:
|
|
1444
|
+
info = summarize_session(p)
|
|
1445
|
+
title = info.title or info.first_user_prompt or "(no title)"
|
|
1446
|
+
print(f"{score:>6.3f} {C.YELLOW}{info.session_id[:8]}{C.RESET} {truncate(title, title_w)}")
|
|
1447
|
+
return 0
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
def cmd_pick(args: argparse.Namespace) -> int:
|
|
1451
|
+
since = parse_when(getattr(args, "since", None))
|
|
1452
|
+
sessions = collect_sessions(project_filter=args.project, since=since)
|
|
1453
|
+
if not sessions:
|
|
1454
|
+
print("No sessions.", file=sys.stderr)
|
|
1455
|
+
return 1
|
|
1456
|
+
|
|
1457
|
+
chosen_id: str | None = None
|
|
1458
|
+
if shutil.which("fzf") and sys.stdin.isatty():
|
|
1459
|
+
lines = []
|
|
1460
|
+
for s in sessions:
|
|
1461
|
+
proj = Path(s.project_path).name or s.project_path
|
|
1462
|
+
title = s.title or s.first_user_prompt or "(no title)"
|
|
1463
|
+
ts = fmt_ts(s.last_ts)
|
|
1464
|
+
lines.append(f"{s.session_id}\t{ts:>10} {proj:<22} {title}")
|
|
1465
|
+
try:
|
|
1466
|
+
proc = subprocess.run(
|
|
1467
|
+
[
|
|
1468
|
+
"fzf", "--with-nth=2..", "--delimiter=\t", "--ansi",
|
|
1469
|
+
"--prompt=session> ",
|
|
1470
|
+
"--header=enter to select, ctrl-c to cancel",
|
|
1471
|
+
],
|
|
1472
|
+
input="\n".join(lines), text=True, capture_output=True,
|
|
1473
|
+
)
|
|
1474
|
+
except FileNotFoundError:
|
|
1475
|
+
proc = None
|
|
1476
|
+
if proc is not None:
|
|
1477
|
+
if proc.returncode != 0:
|
|
1478
|
+
return 130
|
|
1479
|
+
chosen_id = proc.stdout.split("\t", 1)[0].strip()
|
|
1480
|
+
|
|
1481
|
+
if chosen_id is None:
|
|
1482
|
+
for i, s in enumerate(sessions[:30], 1):
|
|
1483
|
+
proj = Path(s.project_path).name or s.project_path
|
|
1484
|
+
title = s.title or s.first_user_prompt or "(no title)"
|
|
1485
|
+
print(f"{i:3} {s.session_id[:8]} {C.CYAN}{proj:<22}{C.RESET} {truncate(title, 60)}")
|
|
1486
|
+
try:
|
|
1487
|
+
choice = input("pick #: ").strip()
|
|
1488
|
+
except (EOFError, KeyboardInterrupt):
|
|
1489
|
+
return 130
|
|
1490
|
+
try:
|
|
1491
|
+
idx = int(choice) - 1
|
|
1492
|
+
except ValueError:
|
|
1493
|
+
return 1
|
|
1494
|
+
if not (0 <= idx < len(sessions)):
|
|
1495
|
+
print("out of range", file=sys.stderr)
|
|
1496
|
+
return 1
|
|
1497
|
+
chosen_id = sessions[idx].session_id
|
|
1498
|
+
|
|
1499
|
+
action = args.action
|
|
1500
|
+
args.session = chosen_id
|
|
1501
|
+
if action == "show":
|
|
1502
|
+
args.thinking = False
|
|
1503
|
+
args.tool_results = False
|
|
1504
|
+
args.no_pager = False
|
|
1505
|
+
return cmd_show(args)
|
|
1506
|
+
if action == "path":
|
|
1507
|
+
return cmd_path(args)
|
|
1508
|
+
if action == "resume":
|
|
1509
|
+
args.exec = False
|
|
1510
|
+
return cmd_resume(args)
|
|
1511
|
+
if action == "files":
|
|
1512
|
+
return cmd_files(args)
|
|
1513
|
+
if action == "tools":
|
|
1514
|
+
return cmd_tools(args)
|
|
1515
|
+
print(chosen_id)
|
|
1516
|
+
return 0
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def cmd_prompts(args: argparse.Namespace) -> int:
|
|
1520
|
+
since = _resolve_since(args)
|
|
1521
|
+
sessions = collect_sessions(project_filter=args.project, since=since)
|
|
1522
|
+
if args.limit:
|
|
1523
|
+
sessions = sessions[: args.limit]
|
|
1524
|
+
if not sessions:
|
|
1525
|
+
print("No sessions.", file=sys.stderr)
|
|
1526
|
+
return 1
|
|
1527
|
+
for s in sessions:
|
|
1528
|
+
if not s.first_user_prompt:
|
|
1529
|
+
continue
|
|
1530
|
+
proj = Path(s.project_path).name or s.project_path
|
|
1531
|
+
print(
|
|
1532
|
+
f"{C.YELLOW}{s.session_id[:8]}{C.RESET} "
|
|
1533
|
+
f"{C.GREY}{fmt_ts(s.last_ts):>10}{C.RESET} "
|
|
1534
|
+
f"{C.CYAN}{proj}{C.RESET}"
|
|
1535
|
+
)
|
|
1536
|
+
print(f" {truncate(s.first_user_prompt, term_width() - 4)}")
|
|
1537
|
+
print()
|
|
1538
|
+
return 0
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
# ============================================================
|
|
1542
|
+
# shell completion
|
|
1543
|
+
# ============================================================
|
|
1544
|
+
|
|
1545
|
+
_COMMANDS = [
|
|
1546
|
+
"projects", "list", "ls", "search", "grep", "show", "code", "stats",
|
|
1547
|
+
"path", "open", "last", "resume", "files", "tools", "bash", "links",
|
|
1548
|
+
"export", "related", "pick", "prompts", "completion",
|
|
1549
|
+
]
|
|
1550
|
+
|
|
1551
|
+
_BASH_COMPLETION = r"""# sift bash completion
|
|
1552
|
+
_sift_completion() {
|
|
1553
|
+
local cur
|
|
1554
|
+
COMPREPLY=()
|
|
1555
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
1556
|
+
if [[ $COMP_CWORD -eq 1 ]]; then
|
|
1557
|
+
COMPREPLY=( $(compgen -W "%COMMANDS%" -- "$cur") )
|
|
1558
|
+
return 0
|
|
1559
|
+
fi
|
|
1560
|
+
case "${COMP_WORDS[1]}" in
|
|
1561
|
+
show|code|path|open|resume|files|tools|bash|links|export|related)
|
|
1562
|
+
local ids
|
|
1563
|
+
ids=$(sift list --limit 50 --json 2>/dev/null \
|
|
1564
|
+
| python3 -c "import json,sys
|
|
1565
|
+
for s in json.load(sys.stdin):
|
|
1566
|
+
print(s['session_id'])" 2>/dev/null)
|
|
1567
|
+
COMPREPLY=( $(compgen -W "$ids" -- "$cur") )
|
|
1568
|
+
;;
|
|
1569
|
+
esac
|
|
1570
|
+
}
|
|
1571
|
+
complete -F _sift_completion sift
|
|
1572
|
+
"""
|
|
1573
|
+
|
|
1574
|
+
_ZSH_COMPLETION = r"""#compdef sift
|
|
1575
|
+
_sift() {
|
|
1576
|
+
local -a commands
|
|
1577
|
+
commands=(%COMMANDS_QUOTED%)
|
|
1578
|
+
if (( CURRENT == 2 )); then
|
|
1579
|
+
_describe 'command' commands
|
|
1580
|
+
elif (( CURRENT >= 3 )); then
|
|
1581
|
+
case "$words[2]" in
|
|
1582
|
+
show|code|path|open|resume|files|tools|bash|links|export|related)
|
|
1583
|
+
local -a ids
|
|
1584
|
+
ids=( ${(f)"$(sift list --limit 50 --json 2>/dev/null \
|
|
1585
|
+
| python3 -c 'import json,sys
|
|
1586
|
+
[print(s["session_id"]) for s in json.load(sys.stdin)]' 2>/dev/null)"} )
|
|
1587
|
+
_describe 'session' ids
|
|
1588
|
+
;;
|
|
1589
|
+
esac
|
|
1590
|
+
fi
|
|
1591
|
+
}
|
|
1592
|
+
_sift "$@"
|
|
1593
|
+
"""
|
|
1594
|
+
|
|
1595
|
+
_FISH_COMPLETION = r"""# sift fish completion
|
|
1596
|
+
complete -c sift -f
|
|
1597
|
+
%COMPLETIONS%
|
|
1598
|
+
function __sift_sessions
|
|
1599
|
+
sift list --limit 50 --json 2>/dev/null | python3 -c "import json,sys
|
|
1600
|
+
[print(s['session_id']) for s in json.load(sys.stdin)]" 2>/dev/null
|
|
1601
|
+
end
|
|
1602
|
+
for cmd in show code path open resume files tools bash links export related
|
|
1603
|
+
complete -c sift -n "__fish_seen_subcommand_from $cmd" -a "(__sift_sessions)"
|
|
1604
|
+
end
|
|
1605
|
+
"""
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def cmd_completion(args: argparse.Namespace) -> int:
|
|
1609
|
+
shell = args.shell
|
|
1610
|
+
if shell == "bash":
|
|
1611
|
+
print(_BASH_COMPLETION.replace("%COMMANDS%", " ".join(_COMMANDS)))
|
|
1612
|
+
elif shell == "zsh":
|
|
1613
|
+
quoted = " ".join(f"'{c}'" for c in _COMMANDS)
|
|
1614
|
+
print(_ZSH_COMPLETION.replace("%COMMANDS_QUOTED%", quoted))
|
|
1615
|
+
elif shell == "fish":
|
|
1616
|
+
comps = "\n".join(
|
|
1617
|
+
f"complete -c sift -n '__fish_use_subcommand' -a '{c}'"
|
|
1618
|
+
for c in _COMMANDS
|
|
1619
|
+
)
|
|
1620
|
+
print(_FISH_COMPLETION.replace("%COMPLETIONS%", comps))
|
|
1621
|
+
return 0
|
|
1622
|
+
|
|
1623
|
+
|
|
1624
|
+
# ============================================================
|
|
1625
|
+
# TUI (launched when `sift` is run with no arguments)
|
|
1626
|
+
# ============================================================
|
|
1627
|
+
|
|
1628
|
+
def run_tui() -> int:
|
|
1629
|
+
"""Launch the interactive TUI if attached to a TTY, else print help."""
|
|
1630
|
+
if not (sys.stdout.isatty() and sys.stdin.isatty()):
|
|
1631
|
+
build_parser().print_help()
|
|
1632
|
+
return 0
|
|
1633
|
+
try:
|
|
1634
|
+
import curses
|
|
1635
|
+
import locale
|
|
1636
|
+
except ImportError:
|
|
1637
|
+
build_parser().print_help()
|
|
1638
|
+
return 0
|
|
1639
|
+
locale.setlocale(locale.LC_ALL, "")
|
|
1640
|
+
try:
|
|
1641
|
+
return curses.wrapper(lambda stdscr: _SiftTUI(stdscr).run())
|
|
1642
|
+
except KeyboardInterrupt:
|
|
1643
|
+
return 130
|
|
1644
|
+
|
|
1645
|
+
|
|
1646
|
+
class _SiftTUI:
|
|
1647
|
+
# color pair ids
|
|
1648
|
+
P_TITLE = 1
|
|
1649
|
+
P_DIM = 2
|
|
1650
|
+
P_YELLOW = 3
|
|
1651
|
+
P_CYAN = 4
|
|
1652
|
+
P_GREEN = 5
|
|
1653
|
+
P_MAGENTA = 6
|
|
1654
|
+
P_RED = 7
|
|
1655
|
+
P_SELECT = 8
|
|
1656
|
+
|
|
1657
|
+
def __init__(self, stdscr):
|
|
1658
|
+
import curses
|
|
1659
|
+
self.curses = curses
|
|
1660
|
+
self.stdscr = stdscr
|
|
1661
|
+
self.all_sessions: list[SessionInfo] = []
|
|
1662
|
+
self.filtered: list[SessionInfo] = []
|
|
1663
|
+
self.cursor = 0
|
|
1664
|
+
self.scroll = 0
|
|
1665
|
+
self.filter_text = ""
|
|
1666
|
+
self.mode = "list" # list | filter | search | help
|
|
1667
|
+
self.message = ""
|
|
1668
|
+
self.message_is_error = False
|
|
1669
|
+
self.message_until = 0.0
|
|
1670
|
+
self.search_active = False
|
|
1671
|
+
self.search_query = ""
|
|
1672
|
+
self.search_matches: set[str] = set()
|
|
1673
|
+
self.preview_cache: dict[str, dict] = {}
|
|
1674
|
+
self._setup_colors()
|
|
1675
|
+
self._load()
|
|
1676
|
+
|
|
1677
|
+
# ---------- setup ----------
|
|
1678
|
+
|
|
1679
|
+
def _setup_colors(self):
|
|
1680
|
+
c = self.curses
|
|
1681
|
+
c.start_color()
|
|
1682
|
+
try:
|
|
1683
|
+
c.use_default_colors()
|
|
1684
|
+
bg = -1
|
|
1685
|
+
except c.error:
|
|
1686
|
+
bg = c.COLOR_BLACK
|
|
1687
|
+
c.init_pair(self.P_TITLE, c.COLOR_CYAN, bg)
|
|
1688
|
+
c.init_pair(self.P_DIM, c.COLOR_WHITE, bg)
|
|
1689
|
+
c.init_pair(self.P_YELLOW, c.COLOR_YELLOW, bg)
|
|
1690
|
+
c.init_pair(self.P_CYAN, c.COLOR_CYAN, bg)
|
|
1691
|
+
c.init_pair(self.P_GREEN, c.COLOR_GREEN, bg)
|
|
1692
|
+
c.init_pair(self.P_MAGENTA, c.COLOR_MAGENTA, bg)
|
|
1693
|
+
c.init_pair(self.P_RED, c.COLOR_RED, bg)
|
|
1694
|
+
c.init_pair(self.P_SELECT, c.COLOR_BLACK, c.COLOR_CYAN)
|
|
1695
|
+
|
|
1696
|
+
def attr(self, pair_id: int, bold: bool = False, dim: bool = False) -> int:
|
|
1697
|
+
a = self.curses.color_pair(pair_id)
|
|
1698
|
+
if bold:
|
|
1699
|
+
a |= self.curses.A_BOLD
|
|
1700
|
+
if dim:
|
|
1701
|
+
a |= self.curses.A_DIM
|
|
1702
|
+
return a
|
|
1703
|
+
|
|
1704
|
+
def _load(self):
|
|
1705
|
+
self.all_sessions = collect_sessions()
|
|
1706
|
+
self._apply_filter()
|
|
1707
|
+
|
|
1708
|
+
def _apply_filter(self):
|
|
1709
|
+
q = self.filter_text.lower().strip()
|
|
1710
|
+
out = self.all_sessions
|
|
1711
|
+
if q:
|
|
1712
|
+
out = [
|
|
1713
|
+
s for s in out
|
|
1714
|
+
if q in (s.title or "").lower()
|
|
1715
|
+
or q in s.project_path.lower()
|
|
1716
|
+
or q in (s.first_user_prompt or "").lower()
|
|
1717
|
+
]
|
|
1718
|
+
if self.search_active:
|
|
1719
|
+
out = [s for s in out if s.session_id in self.search_matches]
|
|
1720
|
+
self.filtered = out
|
|
1721
|
+
if self.cursor >= len(self.filtered):
|
|
1722
|
+
self.cursor = max(0, len(self.filtered) - 1)
|
|
1723
|
+
if self.scroll > self.cursor:
|
|
1724
|
+
self.scroll = self.cursor
|
|
1725
|
+
|
|
1726
|
+
# ---------- main loop ----------
|
|
1727
|
+
|
|
1728
|
+
def run(self) -> int:
|
|
1729
|
+
c = self.curses
|
|
1730
|
+
c.curs_set(0)
|
|
1731
|
+
self.stdscr.keypad(True)
|
|
1732
|
+
while True:
|
|
1733
|
+
try:
|
|
1734
|
+
self._draw()
|
|
1735
|
+
except c.error:
|
|
1736
|
+
pass
|
|
1737
|
+
key = self.stdscr.getch()
|
|
1738
|
+
if self.mode == "filter":
|
|
1739
|
+
self._handle_filter(key)
|
|
1740
|
+
elif self.mode == "search":
|
|
1741
|
+
self._handle_search(key)
|
|
1742
|
+
elif self.mode == "help":
|
|
1743
|
+
self.mode = "list"
|
|
1744
|
+
else:
|
|
1745
|
+
if not self._handle_list(key):
|
|
1746
|
+
return 0
|
|
1747
|
+
|
|
1748
|
+
# ---------- key handlers ----------
|
|
1749
|
+
|
|
1750
|
+
def _handle_list(self, key) -> bool:
|
|
1751
|
+
c = self.curses
|
|
1752
|
+
if key in (ord('q'), 27):
|
|
1753
|
+
return False
|
|
1754
|
+
if key in (c.KEY_DOWN, ord('j')):
|
|
1755
|
+
self._move(1)
|
|
1756
|
+
elif key in (c.KEY_UP, ord('k')):
|
|
1757
|
+
self._move(-1)
|
|
1758
|
+
elif key == ord('g'):
|
|
1759
|
+
self.cursor = 0
|
|
1760
|
+
self.scroll = 0
|
|
1761
|
+
elif key == ord('G'):
|
|
1762
|
+
self.cursor = max(0, len(self.filtered) - 1)
|
|
1763
|
+
elif key in (c.KEY_NPAGE, ord('d')):
|
|
1764
|
+
self._move(self._page_size())
|
|
1765
|
+
elif key in (c.KEY_PPAGE, ord('u')):
|
|
1766
|
+
self._move(-self._page_size())
|
|
1767
|
+
elif key == ord('/'):
|
|
1768
|
+
self.mode = "filter"
|
|
1769
|
+
elif key == ord('s'):
|
|
1770
|
+
self.mode = "search"
|
|
1771
|
+
self.search_query = ""
|
|
1772
|
+
elif key in (ord('\n'), c.KEY_ENTER, 10, 13):
|
|
1773
|
+
self._open_session()
|
|
1774
|
+
elif key == ord('e'):
|
|
1775
|
+
self._export_session()
|
|
1776
|
+
elif key == ord('r'):
|
|
1777
|
+
self._copy_resume()
|
|
1778
|
+
elif key == ord('c'):
|
|
1779
|
+
self._copy_id()
|
|
1780
|
+
elif key == ord('p'):
|
|
1781
|
+
self._copy_path()
|
|
1782
|
+
elif key == ord('R'):
|
|
1783
|
+
self._reload()
|
|
1784
|
+
elif key == ord('?'):
|
|
1785
|
+
self.mode = "help"
|
|
1786
|
+
return True
|
|
1787
|
+
|
|
1788
|
+
def _handle_filter(self, key):
|
|
1789
|
+
c = self.curses
|
|
1790
|
+
if key == 27: # ESC
|
|
1791
|
+
self.filter_text = ""
|
|
1792
|
+
self._apply_filter()
|
|
1793
|
+
self.mode = "list"
|
|
1794
|
+
return
|
|
1795
|
+
if key in (ord('\n'), c.KEY_ENTER, 10, 13):
|
|
1796
|
+
self.mode = "list"
|
|
1797
|
+
return
|
|
1798
|
+
if key in (c.KEY_BACKSPACE, 127, 8):
|
|
1799
|
+
self.filter_text = self.filter_text[:-1]
|
|
1800
|
+
self._apply_filter()
|
|
1801
|
+
return
|
|
1802
|
+
if 32 <= key < 127:
|
|
1803
|
+
self.filter_text += chr(key)
|
|
1804
|
+
self._apply_filter()
|
|
1805
|
+
|
|
1806
|
+
def _handle_search(self, key):
|
|
1807
|
+
c = self.curses
|
|
1808
|
+
if key == 27: # ESC
|
|
1809
|
+
self.search_active = False
|
|
1810
|
+
self.search_query = ""
|
|
1811
|
+
self.search_matches.clear()
|
|
1812
|
+
self._apply_filter()
|
|
1813
|
+
self.mode = "list"
|
|
1814
|
+
return
|
|
1815
|
+
if key in (ord('\n'), c.KEY_ENTER, 10, 13):
|
|
1816
|
+
self._do_content_search()
|
|
1817
|
+
self.mode = "list"
|
|
1818
|
+
return
|
|
1819
|
+
if key in (c.KEY_BACKSPACE, 127, 8):
|
|
1820
|
+
self.search_query = self.search_query[:-1]
|
|
1821
|
+
return
|
|
1822
|
+
if 32 <= key < 127:
|
|
1823
|
+
self.search_query += chr(key)
|
|
1824
|
+
|
|
1825
|
+
def _do_content_search(self):
|
|
1826
|
+
q = self.search_query.strip()
|
|
1827
|
+
if not q:
|
|
1828
|
+
self.search_active = False
|
|
1829
|
+
self.search_matches.clear()
|
|
1830
|
+
self._apply_filter()
|
|
1831
|
+
return
|
|
1832
|
+
try:
|
|
1833
|
+
pat = re.compile(re.escape(q), re.IGNORECASE)
|
|
1834
|
+
except re.error:
|
|
1835
|
+
return
|
|
1836
|
+
matches: set[str] = set()
|
|
1837
|
+
for s in self.all_sessions:
|
|
1838
|
+
try:
|
|
1839
|
+
with open(s.file, "rb") as fh:
|
|
1840
|
+
blob = fh.read()
|
|
1841
|
+
if pat.search(blob.decode("utf-8", errors="replace")):
|
|
1842
|
+
matches.add(s.session_id)
|
|
1843
|
+
except OSError:
|
|
1844
|
+
continue
|
|
1845
|
+
self.search_active = True
|
|
1846
|
+
self.search_matches = matches
|
|
1847
|
+
self._flash(f'{len(matches)} sessions match "{q}"')
|
|
1848
|
+
self._apply_filter()
|
|
1849
|
+
|
|
1850
|
+
# ---------- actions ----------
|
|
1851
|
+
|
|
1852
|
+
def _open_session(self):
|
|
1853
|
+
if not self.filtered:
|
|
1854
|
+
return
|
|
1855
|
+
s = self.filtered[self.cursor]
|
|
1856
|
+
c = self.curses
|
|
1857
|
+
c.endwin()
|
|
1858
|
+
sys.stdout.write("\033[2J\033[H")
|
|
1859
|
+
sys.stdout.flush()
|
|
1860
|
+
pager = _maybe_pager(False)
|
|
1861
|
+
out = pager.stdin if pager else sys.stdout
|
|
1862
|
+
try:
|
|
1863
|
+
_render_session(s.file, out)
|
|
1864
|
+
except SystemExit:
|
|
1865
|
+
pass
|
|
1866
|
+
if pager:
|
|
1867
|
+
try:
|
|
1868
|
+
pager.stdin.close()
|
|
1869
|
+
pager.wait()
|
|
1870
|
+
except Exception:
|
|
1871
|
+
pass
|
|
1872
|
+
self.stdscr.clear()
|
|
1873
|
+
self.stdscr.refresh()
|
|
1874
|
+
|
|
1875
|
+
def _export_session(self):
|
|
1876
|
+
if not self.filtered:
|
|
1877
|
+
return
|
|
1878
|
+
s = self.filtered[self.cursor]
|
|
1879
|
+
out_dir = Path.home() / "sift-exports"
|
|
1880
|
+
try:
|
|
1881
|
+
out_dir.mkdir(exist_ok=True)
|
|
1882
|
+
except OSError as e:
|
|
1883
|
+
self._flash(f"mkdir failed: {e}", error=True)
|
|
1884
|
+
return
|
|
1885
|
+
slug = re.sub(r"[^\w\-]+", "_", s.title or "untitled")[:50].strip("_") or "untitled"
|
|
1886
|
+
out_path = out_dir / f"{s.session_id[:8]}-{slug}.md"
|
|
1887
|
+
try:
|
|
1888
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
1889
|
+
_render_markdown(s.file, f)
|
|
1890
|
+
self._flash(f"exported → {out_path}")
|
|
1891
|
+
except OSError as e:
|
|
1892
|
+
self._flash(f"export failed: {e}", error=True)
|
|
1893
|
+
|
|
1894
|
+
def _copy_resume(self):
|
|
1895
|
+
if not self.filtered:
|
|
1896
|
+
return
|
|
1897
|
+
s = self.filtered[self.cursor]
|
|
1898
|
+
cwd = s.cwd or str(s.file.parent)
|
|
1899
|
+
self._copy_to_clipboard(f"cd {cwd} && claude --resume {s.session_id}", "resume command")
|
|
1900
|
+
|
|
1901
|
+
def _copy_id(self):
|
|
1902
|
+
if not self.filtered:
|
|
1903
|
+
return
|
|
1904
|
+
self._copy_to_clipboard(self.filtered[self.cursor].session_id, "session id")
|
|
1905
|
+
|
|
1906
|
+
def _copy_path(self):
|
|
1907
|
+
if not self.filtered:
|
|
1908
|
+
return
|
|
1909
|
+
self._copy_to_clipboard(str(self.filtered[self.cursor].file), "session path")
|
|
1910
|
+
|
|
1911
|
+
def _copy_to_clipboard(self, text: str, label: str):
|
|
1912
|
+
for tool in ("pbcopy", "wl-copy", "xclip"):
|
|
1913
|
+
exe = shutil.which(tool)
|
|
1914
|
+
if not exe:
|
|
1915
|
+
continue
|
|
1916
|
+
try:
|
|
1917
|
+
args = [exe]
|
|
1918
|
+
if tool == "xclip":
|
|
1919
|
+
args = [exe, "-selection", "clipboard"]
|
|
1920
|
+
subprocess.run(args, input=text, text=True, check=True)
|
|
1921
|
+
self._flash(f"copied {label} to clipboard")
|
|
1922
|
+
return
|
|
1923
|
+
except subprocess.CalledProcessError:
|
|
1924
|
+
continue
|
|
1925
|
+
# No clipboard tool — show the text instead
|
|
1926
|
+
self._flash(text)
|
|
1927
|
+
|
|
1928
|
+
def _reload(self):
|
|
1929
|
+
self.preview_cache.clear()
|
|
1930
|
+
self._load()
|
|
1931
|
+
self._flash(f"reloaded ({len(self.all_sessions)} sessions)")
|
|
1932
|
+
|
|
1933
|
+
def _flash(self, msg: str, error: bool = False):
|
|
1934
|
+
import time
|
|
1935
|
+
self.message = msg
|
|
1936
|
+
self.message_is_error = error
|
|
1937
|
+
self.message_until = time.time() + 4
|
|
1938
|
+
|
|
1939
|
+
# ---------- navigation ----------
|
|
1940
|
+
|
|
1941
|
+
def _move(self, delta: int):
|
|
1942
|
+
if not self.filtered:
|
|
1943
|
+
return
|
|
1944
|
+
self.cursor = max(0, min(len(self.filtered) - 1, self.cursor + delta))
|
|
1945
|
+
|
|
1946
|
+
def _page_size(self) -> int:
|
|
1947
|
+
h, _ = self.stdscr.getmaxyx()
|
|
1948
|
+
return max(1, h - 8)
|
|
1949
|
+
|
|
1950
|
+
# ---------- preview data ----------
|
|
1951
|
+
|
|
1952
|
+
def _get_preview(self, s: SessionInfo) -> dict:
|
|
1953
|
+
if s.session_id in self.preview_cache:
|
|
1954
|
+
return self.preview_cache[s.session_id]
|
|
1955
|
+
tools: Counter[str] = Counter()
|
|
1956
|
+
files: Counter[str] = Counter()
|
|
1957
|
+
for _ts, tu in iter_tool_uses(s.file):
|
|
1958
|
+
name = tu.get("name", "?")
|
|
1959
|
+
tools[name] += 1
|
|
1960
|
+
if name in ("Read", "Edit", "Write", "NotebookEdit", "MultiEdit"):
|
|
1961
|
+
fp = (tu.get("input") or {}).get("file_path")
|
|
1962
|
+
if fp:
|
|
1963
|
+
files[fp] += 1
|
|
1964
|
+
data = {"tools": tools, "files": files}
|
|
1965
|
+
self.preview_cache[s.session_id] = data
|
|
1966
|
+
return data
|
|
1967
|
+
|
|
1968
|
+
# ---------- drawing primitives ----------
|
|
1969
|
+
|
|
1970
|
+
def _addnstr(self, y: int, x: int, text: str, max_len: int = -1, attr: int = 0):
|
|
1971
|
+
h, w = self.stdscr.getmaxyx()
|
|
1972
|
+
if y < 0 or y >= h or x < 0 or x >= w:
|
|
1973
|
+
return
|
|
1974
|
+
available = w - x
|
|
1975
|
+
if y == h - 1:
|
|
1976
|
+
available -= 1 # avoid bottom-right cell
|
|
1977
|
+
if available <= 0:
|
|
1978
|
+
return
|
|
1979
|
+
n = available if max_len < 0 else min(available, max_len)
|
|
1980
|
+
try:
|
|
1981
|
+
self.stdscr.addnstr(y, x, text, n, attr)
|
|
1982
|
+
except self.curses.error:
|
|
1983
|
+
pass
|
|
1984
|
+
|
|
1985
|
+
def _hline(self, y: int, x: int, n: int, attr: int = 0):
|
|
1986
|
+
# `curses.hline` requires a single byte char; use addnstr for Unicode safety.
|
|
1987
|
+
self._addnstr(y, x, "─" * n, max_len=n, attr=attr)
|
|
1988
|
+
|
|
1989
|
+
# ---------- main draw ----------
|
|
1990
|
+
|
|
1991
|
+
def _draw(self):
|
|
1992
|
+
self.stdscr.erase()
|
|
1993
|
+
h, w = self.stdscr.getmaxyx()
|
|
1994
|
+
if h < 10 or w < 60:
|
|
1995
|
+
self._addnstr(0, 0, "terminal too small (need 60×10)", attr=self.attr(self.P_RED))
|
|
1996
|
+
self.stdscr.refresh()
|
|
1997
|
+
return
|
|
1998
|
+
|
|
1999
|
+
self._draw_header(0, w)
|
|
2000
|
+
self._draw_status_bar(1, w)
|
|
2001
|
+
self._hline(2, 0, w, self.attr(self.P_DIM, dim=True))
|
|
2002
|
+
|
|
2003
|
+
list_w = w // 2 if w >= 100 else w
|
|
2004
|
+
preview_w = w - list_w - 1
|
|
2005
|
+
list_h = h - 4
|
|
2006
|
+
|
|
2007
|
+
self._draw_list(3, 0, list_w, list_h)
|
|
2008
|
+
if preview_w >= 30:
|
|
2009
|
+
for y in range(3, h - 1):
|
|
2010
|
+
self._addnstr(y, list_w, "│", attr=self.attr(self.P_DIM, dim=True))
|
|
2011
|
+
self._draw_preview(3, list_w + 2, preview_w - 2, list_h)
|
|
2012
|
+
|
|
2013
|
+
self._draw_footer(h - 1, w)
|
|
2014
|
+
|
|
2015
|
+
if self.mode == "help":
|
|
2016
|
+
self._draw_help_overlay()
|
|
2017
|
+
|
|
2018
|
+
self.stdscr.refresh()
|
|
2019
|
+
|
|
2020
|
+
def _draw_header(self, y: int, w: int):
|
|
2021
|
+
self._addnstr(y, 1, "▰ sift", attr=self.attr(self.P_CYAN, bold=True))
|
|
2022
|
+
self._addnstr(y, 9, f"v{__version__}", attr=self.attr(self.P_DIM, dim=True))
|
|
2023
|
+
info = f" · {len(self.filtered)}/{len(self.all_sessions)} sessions"
|
|
2024
|
+
self._addnstr(y, 9 + len(__version__) + 1, info, attr=self.attr(self.P_DIM))
|
|
2025
|
+
hint = "? help q quit"
|
|
2026
|
+
self._addnstr(y, max(0, w - len(hint) - 2), hint, attr=self.attr(self.P_DIM, dim=True))
|
|
2027
|
+
|
|
2028
|
+
def _draw_status_bar(self, y: int, w: int):
|
|
2029
|
+
if self.mode == "filter":
|
|
2030
|
+
self._addnstr(y, 1, "filter: ", attr=self.attr(self.P_YELLOW, bold=True))
|
|
2031
|
+
self._addnstr(y, 9, self.filter_text + "▎",
|
|
2032
|
+
attr=self.attr(self.P_TITLE, bold=True))
|
|
2033
|
+
return
|
|
2034
|
+
if self.mode == "search":
|
|
2035
|
+
self._addnstr(y, 1, "search: ", attr=self.attr(self.P_MAGENTA, bold=True))
|
|
2036
|
+
self._addnstr(y, 9, self.search_query + "▎",
|
|
2037
|
+
attr=self.attr(self.P_TITLE, bold=True))
|
|
2038
|
+
hint = " (enter: search inside conversations · esc: cancel)"
|
|
2039
|
+
self._addnstr(y, 9 + len(self.search_query) + 2, hint,
|
|
2040
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2041
|
+
return
|
|
2042
|
+
parts = []
|
|
2043
|
+
if self.filter_text:
|
|
2044
|
+
parts.append(f"filter: {self.filter_text}")
|
|
2045
|
+
if self.search_active:
|
|
2046
|
+
parts.append(f'content: "{self.search_query}" ({len(self.search_matches)})')
|
|
2047
|
+
if parts:
|
|
2048
|
+
self._addnstr(y, 1, " · ".join(parts), attr=self.attr(self.P_TITLE))
|
|
2049
|
+
else:
|
|
2050
|
+
self._addnstr(
|
|
2051
|
+
y, 1,
|
|
2052
|
+
"press / to filter titles & paths · s to search conversation contents",
|
|
2053
|
+
attr=self.attr(self.P_DIM, dim=True),
|
|
2054
|
+
)
|
|
2055
|
+
|
|
2056
|
+
def _draw_list(self, y: int, x: int, w: int, h: int):
|
|
2057
|
+
if not self.filtered:
|
|
2058
|
+
self._addnstr(y + 1, x + 2, "no sessions match",
|
|
2059
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2060
|
+
return
|
|
2061
|
+
if self.cursor < self.scroll:
|
|
2062
|
+
self.scroll = self.cursor
|
|
2063
|
+
if self.cursor >= self.scroll + h:
|
|
2064
|
+
self.scroll = self.cursor - h + 1
|
|
2065
|
+
visible = self.filtered[self.scroll:self.scroll + h]
|
|
2066
|
+
for i, s in enumerate(visible):
|
|
2067
|
+
self._draw_list_row(y + i, x, w, s, self.scroll + i == self.cursor)
|
|
2068
|
+
# mini scrollbar on the right edge
|
|
2069
|
+
total = len(self.filtered)
|
|
2070
|
+
if total > h:
|
|
2071
|
+
top_frac = self.scroll / total
|
|
2072
|
+
bar_h = max(1, int(h * h / total))
|
|
2073
|
+
bar_y = y + int(top_frac * h)
|
|
2074
|
+
for i in range(bar_h):
|
|
2075
|
+
if bar_y + i < y + h:
|
|
2076
|
+
self._addnstr(bar_y + i, x + w - 1, "▐",
|
|
2077
|
+
attr=self.attr(self.P_CYAN))
|
|
2078
|
+
|
|
2079
|
+
def _draw_list_row(self, y: int, x: int, w: int, s: SessionInfo, is_sel: bool):
|
|
2080
|
+
marker = "▸ " if is_sel else " "
|
|
2081
|
+
sid = s.session_id[:7]
|
|
2082
|
+
proj = truncate(Path(s.project_path).name or s.project_path, 18)
|
|
2083
|
+
ts = fmt_ts(s.last_ts)
|
|
2084
|
+
title = s.title or s.first_user_prompt or "(no title)"
|
|
2085
|
+
|
|
2086
|
+
# column layout (offsets from row start):
|
|
2087
|
+
# 0 marker (2) | 2 sid (7) | 10 proj (18)
|
|
2088
|
+
# 30 msgs (5R) | 37 ts (10R) | 49 title
|
|
2089
|
+
if is_sel:
|
|
2090
|
+
row_attr = self.attr(self.P_SELECT, bold=True)
|
|
2091
|
+
self._addnstr(y, x, " " * w, attr=row_attr)
|
|
2092
|
+
self._addnstr(y, x, marker + sid, attr=row_attr)
|
|
2093
|
+
self._addnstr(y, x + 10, proj, attr=row_attr)
|
|
2094
|
+
self._addnstr(y, x + 30, f"{s.total_msgs:>5}", attr=row_attr)
|
|
2095
|
+
self._addnstr(y, x + 37, f"{ts:>10}", attr=row_attr)
|
|
2096
|
+
self._addnstr(y, x + 49, truncate(title, max(1, w - 50)), attr=row_attr)
|
|
2097
|
+
else:
|
|
2098
|
+
self._addnstr(y, x, marker, attr=self.attr(self.P_DIM, dim=True))
|
|
2099
|
+
self._addnstr(y, x + 2, sid, attr=self.attr(self.P_YELLOW))
|
|
2100
|
+
self._addnstr(y, x + 10, proj, attr=self.attr(self.P_CYAN))
|
|
2101
|
+
self._addnstr(y, x + 30, f"{s.total_msgs:>5}",
|
|
2102
|
+
attr=self.attr(self.P_DIM))
|
|
2103
|
+
self._addnstr(y, x + 37, f"{ts:>10}",
|
|
2104
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2105
|
+
self._addnstr(y, x + 49, truncate(title, max(1, w - 50)))
|
|
2106
|
+
|
|
2107
|
+
def _draw_preview(self, y: int, x: int, w: int, h: int):
|
|
2108
|
+
if not self.filtered:
|
|
2109
|
+
return
|
|
2110
|
+
s = self.filtered[self.cursor]
|
|
2111
|
+
line = y
|
|
2112
|
+
end = y + h
|
|
2113
|
+
|
|
2114
|
+
def label_row(label: str, value: str, value_attr: int = 0):
|
|
2115
|
+
nonlocal line
|
|
2116
|
+
if line >= end:
|
|
2117
|
+
return
|
|
2118
|
+
self._addnstr(line, x, f"{label:<9}",
|
|
2119
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2120
|
+
self._addnstr(line, x + 9, value, max_len=w - 9, attr=value_attr)
|
|
2121
|
+
line += 1
|
|
2122
|
+
|
|
2123
|
+
title = s.title or s.first_user_prompt or "(no title)"
|
|
2124
|
+
self._addnstr(line, x, title, max_len=w,
|
|
2125
|
+
attr=self.attr(self.P_CYAN, bold=True))
|
|
2126
|
+
line += 1
|
|
2127
|
+
self._addnstr(line, x, s.session_id, max_len=w,
|
|
2128
|
+
attr=self.attr(self.P_YELLOW, dim=True))
|
|
2129
|
+
line += 2
|
|
2130
|
+
|
|
2131
|
+
proj = s.project_path
|
|
2132
|
+
if len(proj) > w - 9:
|
|
2133
|
+
proj = "…" + proj[-(w - 10):]
|
|
2134
|
+
label_row("project", proj, value_attr=self.attr(self.P_CYAN))
|
|
2135
|
+
if s.git_branch:
|
|
2136
|
+
label_row("branch", s.git_branch)
|
|
2137
|
+
if s.first_ts:
|
|
2138
|
+
label_row("started",
|
|
2139
|
+
s.first_ts.astimezone().strftime("%Y-%m-%d %H:%M"))
|
|
2140
|
+
if s.duration:
|
|
2141
|
+
label_row("duration", fmt_duration(s.duration))
|
|
2142
|
+
if s.models:
|
|
2143
|
+
label_row("models", ", ".join(sorted(s.models)),
|
|
2144
|
+
value_attr=self.attr(self.P_MAGENTA))
|
|
2145
|
+
label_row("messages",
|
|
2146
|
+
f"{s.user_msgs} user, {s.assistant_msgs} assistant")
|
|
2147
|
+
label_row("tokens",
|
|
2148
|
+
f"in {fmt_num(s.input_tokens)} out {fmt_num(s.output_tokens)}")
|
|
2149
|
+
line += 1
|
|
2150
|
+
|
|
2151
|
+
if s.first_user_prompt and line < end - 2:
|
|
2152
|
+
self._addnstr(line, x, "first prompt",
|
|
2153
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2154
|
+
line += 1
|
|
2155
|
+
for chunk in textwrap.wrap(s.first_user_prompt, max(10, w)):
|
|
2156
|
+
if line >= end - 1:
|
|
2157
|
+
break
|
|
2158
|
+
self._addnstr(line, x, chunk, max_len=w)
|
|
2159
|
+
line += 1
|
|
2160
|
+
line += 1
|
|
2161
|
+
|
|
2162
|
+
if line >= end - 2:
|
|
2163
|
+
return
|
|
2164
|
+
preview = self._get_preview(s)
|
|
2165
|
+
tools = preview["tools"]
|
|
2166
|
+
files = preview["files"]
|
|
2167
|
+
if tools:
|
|
2168
|
+
self._addnstr(line, x, "top tools",
|
|
2169
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2170
|
+
line += 1
|
|
2171
|
+
max_t = max(tools.values())
|
|
2172
|
+
bar_w = max(6, w - 22)
|
|
2173
|
+
for name, c in tools.most_common(5):
|
|
2174
|
+
if line >= end - 1:
|
|
2175
|
+
break
|
|
2176
|
+
bar_len = max(1, int((c / max_t) * bar_w))
|
|
2177
|
+
self._addnstr(line, x, f" {name:<12}",
|
|
2178
|
+
attr=self.attr(self.P_CYAN))
|
|
2179
|
+
self._addnstr(line, x + 14, "█" * bar_len,
|
|
2180
|
+
max_len=bar_w,
|
|
2181
|
+
attr=self.attr(self.P_CYAN, dim=True))
|
|
2182
|
+
self._addnstr(line, x + 14 + bar_len + 1, str(c),
|
|
2183
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2184
|
+
line += 1
|
|
2185
|
+
line += 1
|
|
2186
|
+
if files and line < end - 1:
|
|
2187
|
+
self._addnstr(line, x, "top files",
|
|
2188
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2189
|
+
line += 1
|
|
2190
|
+
for fp, c in files.most_common(4):
|
|
2191
|
+
if line >= end:
|
|
2192
|
+
break
|
|
2193
|
+
sp = short_path(fp, w - 6)
|
|
2194
|
+
self._addnstr(line, x, f" {sp}",
|
|
2195
|
+
attr=self.attr(self.P_DIM))
|
|
2196
|
+
self._addnstr(line, x + len(sp) + 3, f"×{c}",
|
|
2197
|
+
attr=self.attr(self.P_DIM, dim=True))
|
|
2198
|
+
line += 1
|
|
2199
|
+
|
|
2200
|
+
def _draw_footer(self, y: int, w: int):
|
|
2201
|
+
import time
|
|
2202
|
+
if self.message and time.time() < self.message_until:
|
|
2203
|
+
attr = (self.attr(self.P_RED, bold=True)
|
|
2204
|
+
if self.message_is_error
|
|
2205
|
+
else self.attr(self.P_GREEN, bold=True))
|
|
2206
|
+
self._addnstr(y, 1, self.message, attr=attr)
|
|
2207
|
+
return
|
|
2208
|
+
keys = "↑↓ nav / filter s search ⏎ show e export r resume c copy id ? help q quit"
|
|
2209
|
+
self._addnstr(y, 1, keys, attr=self.attr(self.P_DIM, dim=True))
|
|
2210
|
+
|
|
2211
|
+
def _draw_help_overlay(self):
|
|
2212
|
+
h, w = self.stdscr.getmaxyx()
|
|
2213
|
+
lines = [
|
|
2214
|
+
("navigation", True),
|
|
2215
|
+
(" ↑ / ↓ k / j move cursor", False),
|
|
2216
|
+
(" g / G top / bottom", False),
|
|
2217
|
+
(" PgUp / PgDn u / d page", False),
|
|
2218
|
+
("", False),
|
|
2219
|
+
("filter & search", True),
|
|
2220
|
+
(" / filter list (live, on titles & paths)", False),
|
|
2221
|
+
(" s search inside conversation contents", False),
|
|
2222
|
+
(" esc clear the current filter or search", False),
|
|
2223
|
+
("", False),
|
|
2224
|
+
("actions", True),
|
|
2225
|
+
(" enter render the selected session", False),
|
|
2226
|
+
(" e export session to ~/sift-exports/<id>.md", False),
|
|
2227
|
+
(" r copy `cd … && claude --resume …` to clipboard", False),
|
|
2228
|
+
(" c copy session id to clipboard", False),
|
|
2229
|
+
(" p copy session JSONL path to clipboard", False),
|
|
2230
|
+
(" R reload the archive from disk", False),
|
|
2231
|
+
("", False),
|
|
2232
|
+
(" ? this help", False),
|
|
2233
|
+
(" q quit", False),
|
|
2234
|
+
]
|
|
2235
|
+
box_w = min(70, w - 4)
|
|
2236
|
+
box_h = min(len(lines) + 5, h - 2)
|
|
2237
|
+
by = (h - box_h) // 2
|
|
2238
|
+
bx = (w - box_w) // 2
|
|
2239
|
+
# top border
|
|
2240
|
+
title = " keybindings "
|
|
2241
|
+
top = "╭" + title + "─" * (box_w - len(title) - 2) + "╮"
|
|
2242
|
+
self._addnstr(by, bx, top, attr=self.attr(self.P_CYAN, bold=True))
|
|
2243
|
+
# body
|
|
2244
|
+
for i in range(box_h - 2):
|
|
2245
|
+
self._addnstr(by + 1 + i, bx, "│", attr=self.attr(self.P_CYAN))
|
|
2246
|
+
self._addnstr(by + 1 + i, bx + 1, " " * (box_w - 2))
|
|
2247
|
+
self._addnstr(by + 1 + i, bx + box_w - 1, "│",
|
|
2248
|
+
attr=self.attr(self.P_CYAN))
|
|
2249
|
+
# bottom border
|
|
2250
|
+
bottom = "╰" + "─" * (box_w - 2) + "╯"
|
|
2251
|
+
self._addnstr(by + box_h - 1, bx, bottom,
|
|
2252
|
+
attr=self.attr(self.P_CYAN, bold=True))
|
|
2253
|
+
# content
|
|
2254
|
+
for i, (text, is_section) in enumerate(lines[:box_h - 4]):
|
|
2255
|
+
if is_section:
|
|
2256
|
+
self._addnstr(by + 2 + i, bx + 2, text,
|
|
2257
|
+
max_len=box_w - 4,
|
|
2258
|
+
attr=self.attr(self.P_YELLOW, bold=True))
|
|
2259
|
+
else:
|
|
2260
|
+
self._addnstr(by + 2 + i, bx + 2, text, max_len=box_w - 4)
|
|
2261
|
+
hint = "press any key to close"
|
|
2262
|
+
self._addnstr(by + box_h - 2, bx + (box_w - len(hint)) // 2,
|
|
2263
|
+
hint, attr=self.attr(self.P_DIM, dim=True))
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
# ============================================================
|
|
2267
|
+
# argparse
|
|
2268
|
+
# ============================================================
|
|
2269
|
+
|
|
2270
|
+
def _sub(sub, name: str, *, help: str, aliases=None, epilog: str | None = None):
|
|
2271
|
+
return sub.add_parser(
|
|
2272
|
+
name,
|
|
2273
|
+
help=help,
|
|
2274
|
+
aliases=aliases or [],
|
|
2275
|
+
epilog=epilog,
|
|
2276
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2277
|
+
)
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2281
|
+
p = argparse.ArgumentParser(
|
|
2282
|
+
prog="sift",
|
|
2283
|
+
description="Mine your Claude Code conversation archive.",
|
|
2284
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
2285
|
+
epilog=textwrap.dedent("""\
|
|
2286
|
+
archive root: $SIFT_ARCHIVE (default: ~/.claude/projects)
|
|
2287
|
+
|
|
2288
|
+
running `sift` with no arguments opens the interactive TUI.
|
|
2289
|
+
|
|
2290
|
+
quick start
|
|
2291
|
+
sift interactive TUI (this is the default)
|
|
2292
|
+
sift projects summary per project
|
|
2293
|
+
sift list --days 7 recent sessions
|
|
2294
|
+
sift last render your most recent session
|
|
2295
|
+
sift pick one-shot picker (uses fzf if available)
|
|
2296
|
+
sift search "prompt caching" full-text search across every session
|
|
2297
|
+
sift related <id> find sessions similar to one you know
|
|
2298
|
+
sift stats --year 52-week activity heatmap
|
|
2299
|
+
|
|
2300
|
+
time filters accept '7d', '2w', '12h', or ISO dates (e.g. '2026-05-01').
|
|
2301
|
+
session IDs accept unique prefixes — `sift show 003f1707` works if unique.
|
|
2302
|
+
"""),
|
|
2303
|
+
)
|
|
2304
|
+
p.add_argument("--version", action="version", version=f"sift {__version__}")
|
|
2305
|
+
sub = p.add_subparsers(dest="cmd", metavar="<command>")
|
|
2306
|
+
|
|
2307
|
+
sp = _sub(sub, "projects", help="list projects with session counts")
|
|
2308
|
+
sp.add_argument("--json", action="store_true")
|
|
2309
|
+
sp.set_defaults(func=cmd_projects)
|
|
2310
|
+
|
|
2311
|
+
sp = _sub(sub, "list", aliases=["ls"], help="list sessions, newest first")
|
|
2312
|
+
sp.add_argument("-p", "--project", help="filter by substring of project path")
|
|
2313
|
+
sp.add_argument("--days", type=int, help="sessions touched in the last N days")
|
|
2314
|
+
sp.add_argument("--since", help="lower time bound (e.g. 7d, 2w, 2026-05-01)")
|
|
2315
|
+
sp.add_argument("--until", help="upper time bound")
|
|
2316
|
+
sp.add_argument("--sort", choices=["recent", "tokens", "messages", "duration", "input", "output"],
|
|
2317
|
+
default="recent", help="sort key (default: recent)")
|
|
2318
|
+
sp.add_argument("--limit", type=int, default=40, help="max rows (default 40; 0 = no limit)")
|
|
2319
|
+
sp.add_argument("--json", action="store_true")
|
|
2320
|
+
sp.set_defaults(func=cmd_list)
|
|
2321
|
+
|
|
2322
|
+
sp = _sub(sub, "last", help="render the most recent session")
|
|
2323
|
+
sp.add_argument("-p", "--project", help="filter by substring of project path")
|
|
2324
|
+
sp.add_argument("--thinking", action="store_true")
|
|
2325
|
+
sp.add_argument("--tool-results", action="store_true")
|
|
2326
|
+
sp.add_argument("--no-pager", action="store_true")
|
|
2327
|
+
sp.set_defaults(func=cmd_last)
|
|
2328
|
+
|
|
2329
|
+
sp = _sub(sub, "search", aliases=["grep"], help="full-text search across sessions")
|
|
2330
|
+
sp.add_argument("query")
|
|
2331
|
+
sp.add_argument("-p", "--project")
|
|
2332
|
+
sp.add_argument("--days", type=int)
|
|
2333
|
+
sp.add_argument("--since", help="lower time bound")
|
|
2334
|
+
sp.add_argument("--regex", action="store_true")
|
|
2335
|
+
sp.add_argument("--case-sensitive", action="store_true")
|
|
2336
|
+
sp.add_argument("--user", action="store_true", help="only user messages")
|
|
2337
|
+
sp.add_argument("--assistant", action="store_true", help="only assistant messages")
|
|
2338
|
+
sp.add_argument("-C", "--context", type=int, default=1, help="lines of context")
|
|
2339
|
+
sp.add_argument("--limit", type=int, default=0, help="stop after N matching sessions")
|
|
2340
|
+
sp.add_argument("-l", "--files-only", action="store_true",
|
|
2341
|
+
help="print only session IDs of matching sessions")
|
|
2342
|
+
sp.set_defaults(func=cmd_search)
|
|
2343
|
+
|
|
2344
|
+
sp = _sub(sub, "show", help="render a session readably")
|
|
2345
|
+
sp.add_argument("session")
|
|
2346
|
+
sp.add_argument("--thinking", action="store_true", help="include thinking blocks")
|
|
2347
|
+
sp.add_argument("--tool-results", action="store_true", help="include tool-result user turns")
|
|
2348
|
+
sp.add_argument("--no-pager", action="store_true")
|
|
2349
|
+
sp.set_defaults(func=cmd_show)
|
|
2350
|
+
|
|
2351
|
+
sp = _sub(sub, "code", help="extract fenced code blocks")
|
|
2352
|
+
sp.add_argument("session")
|
|
2353
|
+
sp.add_argument("--lang", help="only blocks with this language tag")
|
|
2354
|
+
sp.add_argument("--out-dir", help="write each block to a file in this dir")
|
|
2355
|
+
sp.set_defaults(func=cmd_code)
|
|
2356
|
+
|
|
2357
|
+
sp = _sub(sub, "stats", help="usage summary + activity charts")
|
|
2358
|
+
sp.add_argument("-p", "--project")
|
|
2359
|
+
sp.add_argument("--days", type=int)
|
|
2360
|
+
sp.add_argument("--since")
|
|
2361
|
+
sp.add_argument("--until")
|
|
2362
|
+
sp.add_argument("--year", action="store_true",
|
|
2363
|
+
help="show 52-week heatmap instead of 30-day strip")
|
|
2364
|
+
sp.add_argument("--json", action="store_true")
|
|
2365
|
+
sp.set_defaults(func=cmd_stats)
|
|
2366
|
+
|
|
2367
|
+
sp = _sub(sub, "path", help="print the JSONL file path")
|
|
2368
|
+
sp.add_argument("session")
|
|
2369
|
+
sp.set_defaults(func=cmd_path)
|
|
2370
|
+
|
|
2371
|
+
sp = _sub(sub, "open", help="open the JSONL in $EDITOR")
|
|
2372
|
+
sp.add_argument("session")
|
|
2373
|
+
sp.set_defaults(func=cmd_open)
|
|
2374
|
+
|
|
2375
|
+
sp = _sub(sub, "resume", help="print the `claude --resume` command for a session")
|
|
2376
|
+
sp.add_argument("session")
|
|
2377
|
+
sp.add_argument("--exec", action="store_true",
|
|
2378
|
+
help="cd into the session's cwd and exec claude")
|
|
2379
|
+
sp.set_defaults(func=cmd_resume)
|
|
2380
|
+
|
|
2381
|
+
sp = _sub(sub, "files", help="files touched by tool calls in a session")
|
|
2382
|
+
sp.add_argument("session")
|
|
2383
|
+
sp.set_defaults(func=cmd_files)
|
|
2384
|
+
|
|
2385
|
+
sp = _sub(sub, "tools", help="tool usage breakdown for a session")
|
|
2386
|
+
sp.add_argument("session")
|
|
2387
|
+
sp.set_defaults(func=cmd_tools)
|
|
2388
|
+
|
|
2389
|
+
sp = _sub(sub, "bash", help="list Bash commands from a session")
|
|
2390
|
+
sp.add_argument("session")
|
|
2391
|
+
sp.add_argument("--plain", action="store_true",
|
|
2392
|
+
help="commands only, no timestamps")
|
|
2393
|
+
sp.set_defaults(func=cmd_bash)
|
|
2394
|
+
|
|
2395
|
+
sp = _sub(sub, "links", help="extract URLs from a session")
|
|
2396
|
+
sp.add_argument("session")
|
|
2397
|
+
sp.add_argument("--plain", action="store_true")
|
|
2398
|
+
sp.set_defaults(func=cmd_links)
|
|
2399
|
+
|
|
2400
|
+
sp = _sub(sub, "export", help="export a session as clean markdown")
|
|
2401
|
+
sp.add_argument("session")
|
|
2402
|
+
sp.add_argument("-o", "--output", help="write to file instead of stdout")
|
|
2403
|
+
sp.add_argument("--thinking", action="store_true",
|
|
2404
|
+
help="include collapsible thinking blocks")
|
|
2405
|
+
sp.add_argument("--no-tools", action="store_true",
|
|
2406
|
+
help="omit tool-call summaries")
|
|
2407
|
+
sp.add_argument("--tool-results", action="store_true",
|
|
2408
|
+
help="include tool-result messages")
|
|
2409
|
+
sp.set_defaults(func=cmd_export)
|
|
2410
|
+
|
|
2411
|
+
sp = _sub(sub, "related",
|
|
2412
|
+
help="find sessions similar to a given one (TF-IDF)")
|
|
2413
|
+
sp.add_argument("session")
|
|
2414
|
+
sp.add_argument("-p", "--project", help="restrict comparison to one project")
|
|
2415
|
+
sp.add_argument("--limit", type=int, default=10)
|
|
2416
|
+
sp.set_defaults(func=cmd_related)
|
|
2417
|
+
|
|
2418
|
+
sp = _sub(sub, "pick",
|
|
2419
|
+
help="interactive session picker (uses fzf if installed)")
|
|
2420
|
+
sp.add_argument("-p", "--project")
|
|
2421
|
+
sp.add_argument("--since")
|
|
2422
|
+
sp.add_argument("--action",
|
|
2423
|
+
choices=["show", "path", "resume", "files", "tools", "id"],
|
|
2424
|
+
default="show",
|
|
2425
|
+
help="what to do after picking (default: show)")
|
|
2426
|
+
sp.set_defaults(func=cmd_pick)
|
|
2427
|
+
|
|
2428
|
+
sp = _sub(sub, "prompts",
|
|
2429
|
+
help="first user prompts across recent sessions")
|
|
2430
|
+
sp.add_argument("-p", "--project")
|
|
2431
|
+
sp.add_argument("--days", type=int)
|
|
2432
|
+
sp.add_argument("--since")
|
|
2433
|
+
sp.add_argument("--limit", type=int, default=20)
|
|
2434
|
+
sp.set_defaults(func=cmd_prompts)
|
|
2435
|
+
|
|
2436
|
+
sp = _sub(sub, "completion",
|
|
2437
|
+
help="generate shell completion (bash/zsh/fish)")
|
|
2438
|
+
sp.add_argument("shell", choices=["bash", "zsh", "fish"])
|
|
2439
|
+
sp.set_defaults(func=cmd_completion)
|
|
2440
|
+
|
|
2441
|
+
return p
|
|
2442
|
+
|
|
2443
|
+
|
|
2444
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2445
|
+
parser = build_parser()
|
|
2446
|
+
args = parser.parse_args(argv)
|
|
2447
|
+
if not getattr(args, "cmd", None):
|
|
2448
|
+
# No subcommand: launch the TUI if interactive, else print help.
|
|
2449
|
+
return run_tui()
|
|
2450
|
+
try:
|
|
2451
|
+
return args.func(args)
|
|
2452
|
+
except BrokenPipeError:
|
|
2453
|
+
return 0
|
|
2454
|
+
except KeyboardInterrupt:
|
|
2455
|
+
return 130
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
if __name__ == "__main__":
|
|
2459
|
+
sys.exit(main())
|