From cf17cf3fa00ea262d9396cf1861c8d1e87553883 Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Wed, 29 Jul 2026 15:10:48 +0900 Subject: [PATCH] =?UTF-8?q?Claude=20Code=20=E3=81=AE=E3=82=84=E3=82=8A?= =?UTF-8?q?=E3=81=A8=E3=82=8A=E6=99=82=E5=88=BB=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=82=A2=20chktime=20=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/.claude/projects の会話ログ (jsonl) を解析し、タスク(セッション)ごとの やりとりの時刻を一覧表示するローカル Web アプリ。標準ライブラリのみで動作する。 - プロジェクト / タスク / ターンの3ペイン表示 - ターンごとに 発言時刻・空き時間・初回応答までの待ち・応答時間・完了時刻・ ツール呼び出し内訳を表示、タイムラインバー付き - 絶対時刻と経過時間の切替、絞り込み、CSV書き出し - ファイルの更新時刻+サイズでの解析キャッシュ、tool_result 行はパースを省略 - デスクトップ用の起動 (pythonw) / 停止 (stop.cmd) スクリプト Co-Authored-By: Claude Opus 5 --- .gitignore | 7 + README.md | 73 +++++++ chktime.py | 580 +++++++++++++++++++++++++++++++++++++++++++++++++ run.bat | 5 + stop.cmd | 3 + web/app.js | 245 +++++++++++++++++++++ web/index.html | 46 ++++ web/style.css | 164 ++++++++++++++ 8 files changed, 1123 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 chktime.py create mode 100644 run.bat create mode 100644 stop.cmd create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6fdd889 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ + +# Claude Code のローカル設定 +.claude/settings.local.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..046de4b --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# chktime + +Claude Code の **タスク(セッション)ごとのチャットのやりとりの時刻**を一覧表示するローカル Web アプリ。 + +`~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`(Claude Code の会話ログ)を解析して、 +「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。 +外部通信なし・追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。 + +## 起動 + +```bash +python chktime.py +``` + +ブラウザで が開きます。 + +デスクトップに以下のショートカットを作成済みです。 + +| ショートカット | 動作 | +| --- | --- | +| **chktime 起動** | `pythonw chktime.py` をコンソールなしで起動し、ブラウザを開く。すでに起動中ならタブを開くだけ | +| **chktime 停止** | `stop.cmd` を実行。`chktime.py` を実行中の python/pythonw を終了(結果を2秒表示) | + +`run.bat`(コンソール表示・ログを見たい時用)と `stop.cmd` は直接ダブルクリックでも使えます。 +※ `.cmd` / `.bat` は cmd の既定コードページで読まれるため、中身は ASCII のみにしています。 + +| オプション | 既定 | 説明 | +| --- | --- | --- | +| `--port` | 8787 | ポート番号 | +| `--host` | 127.0.0.1 | 待ち受けアドレス | +| `--dir` | `~/.claude/projects` | ログの置き場所 | +| `--no-browser` | – | ブラウザを自動で開かない | + +## 画面 + +- **左:プロジェクト** — 作業ディレクトリごと。タスク数・ログ容量・最終更新。 +- **中央:タスク一覧** — 1 セッション = 1 行。タイトル(`/title` 指定 → AI 命名 → 最初のプロンプトの順)、開始日時、往復数、全体時間、作業時間。上の検索窓でタイトル/プロンプト本文を絞り込み。 +- **右:やりとりの時刻** — 1 ターン(あなたの発言 → Claude の応答一式)が 1 行。 + +### 右ペインの列 + +| 列 | 意味 | +| --- | --- | +| 発言時刻 | あなたが送信した日時 `MM/DD hh:mm:ss.s`(ヘッダの「経過時間で表示」で、セッション開始からの相対表示に切替) | +| 空き | 前のターンが終わってから次の発言までの間隔。前の応答中に送信した場合は **並行** | +| 初回応答 | 送信から Claude の最初のメッセージが返るまで | +| 応答時間 | 送信からそのターンの最後のメッセージ(ツール実行・サブエージェント含む)まで | +| 完了時刻 | そのターンの応答が終わった時刻(相対表示時はセッション開始からの経過) | +| タイムライン | 灰=空き、青=初回応答待ち、緑=応答中 | +| 内訳 | そのターンの応答メッセージ数 / ツール呼び出し回数 | +| やりとり | 送信内容(クリックで全文展開)と使用ツールの内訳 | + +上部サマリの **作業時間合計** は各ターンの応答時間の合計(=実際に Claude が動いていた時間)、 +**全体** は最初から最後までの経過時間です。放置時間が長いと両者は大きく離れます。 + +`CSV` ボタンでタスク一覧・ターン一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 + +## 解析の仕様(ざっくり) + +- ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。 +- `/model` などのスラッシュコマンドは `command`、コンパクト後の自動継続は `resume` とラベル表示します。 +- `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。 +- セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。 +- 解析結果はファイルの更新時刻+サイズでキャッシュします。ログを更新したらヘッダの **再読込** を押してください。 + +## ファイル + +``` +chktime.py サーバ + jsonl 解析 +web/index.html 画面 +web/app.js 表示ロジック +web/style.css スタイル +``` diff --git a/chktime.py b/chktime.py new file mode 100644 index 0000000..b404e69 --- /dev/null +++ b/chktime.py @@ -0,0 +1,580 @@ +#!/usr/bin/env python3 +"""chktime - Claude Code のタスク(セッション)ごとのやりとりの時刻を一覧表示するローカルWebアプリ. + +~/.claude/projects//.jsonl を解析し、 + * プロジェクト一覧 + * セッション(タスク)一覧 … 開始/終了時刻・所要時間・ターン数 + * ターン(ユーザー発言→Claude応答)ごとの時刻・初回応答までの待ち・処理時間・空き時間 +を JSON API + ブラウザUI で提供する。標準ライブラリのみで動作する。 + + python chktime.py # http://127.0.0.1:8787 を開く + python chktime.py --port 9000 --no-browser +""" + +from __future__ import annotations + +import argparse +import csv +import errno +import io +import json +import os +import re +import threading +import webbrowser +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +APP_DIR = Path(__file__).resolve().parent +WEB_DIR = APP_DIR / "web" + +TS_RE = re.compile(rb'"timestamp":"([^"]+)"') +SYSTEM_REMINDER_RE = re.compile(r".*?", re.S) +TAG_RE = re.compile(r"<([a-z-]+)>(.*?)", re.S) + +PROMPT_LIMIT = 4000 +SUMMARY_LIMIT = 200 + +# ---------------------------------------------------------------- 解析ロジック + + +def parse_ts(value): + """ISO8601(UTC) 文字列 -> epoch 秒(float)。失敗したら None。""" + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +def iso(epoch): + if epoch is None: + return None + return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") + + +def clean_text(text): + """system-reminder などノイズを落とした本文を返す。""" + text = SYSTEM_REMINDER_RE.sub("", text or "") + return text.strip() + + +def classify_user(obj): + """user レコードを人間の発言として解釈する。対象外なら None。 + + 戻り値: (kind, text) + kind: prompt / command / interrupt / resume + """ + if obj.get("isMeta") or obj.get("isSidechain"): + return None + origin = obj.get("origin") + if isinstance(origin, dict) and origin.get("kind") not in (None, "human"): + return None + + content = (obj.get("message") or {}).get("content") + extras = [] + if isinstance(content, str): + text = content + elif isinstance(content, list): + parts = [] + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "tool_result": + return None # ツール実行結果 = 人間の発言ではない + if btype == "text": + parts.append(block.get("text") or "") + elif btype in ("image", "document"): + extras.append(btype) + text = "\n".join(parts) + else: + return None + + text = clean_text(text) + + if text.startswith("[Request interrupted"): + return ("interrupt", text) + if "" in text: + return None + if text.startswith("Caveat: The messages below were generated by the user while running"): + return None + if "" in text: + tags = dict(TAG_RE.findall(text)) + name = (tags.get("command-name") or "").strip() + args = (tags.get("command-args") or "").strip() + if not name: + return None + return ("command", (name + " " + args).strip()) + if text.startswith("This session is being continued from a previous conversation"): + return ("resume", "(前の会話からの継続 / コンパクト後の再開)") + + if extras: + text = (text + " " if text else "") + "[" + "+".join(extras) + "]" + if not text: + return None + return ("prompt", text) + + +def new_turn(index, kind, text, ts): + return { + "i": index, + "kind": kind, + "prompt": text[:PROMPT_LIMIT], + "truncated": len(text) > PROMPT_LIMIT, + "ts": ts, + "firstReplyTs": None, + "endTs": ts, + "assistant": 0, + "toolCalls": 0, + "tools": {}, + "sidechain": 0, + "interrupted": False, + "models": [], + } + + +def parse_file(path: Path): + """1セッションの jsonl を解析して dict を返す。""" + turns = [] + titles = {} + meta = {} + counts = {"user": 0, "assistant": 0, "toolResult": 0, "sidechain": 0} + start = end = None + pending_prompt = None # turn 開始待ち (queue-operation より後の user 本体を採用) + + with path.open("rb") as fh: + for raw in fh: + raw = raw.strip() + if not raw: + continue + + # tool_result 行は巨大なので JSON パースせず時刻だけ拾う + if b'"tool_result"' in raw and b'"type":"user"' in raw: + match = TS_RE.search(raw) + ts = parse_ts(match.group(1).decode("utf-8")) if match else None + counts["toolResult"] += 1 + if ts is not None: + start = ts if start is None else min(start, ts) + end = ts if end is None else max(end, ts) + if turns: + turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts) + continue + + try: + obj = json.loads(raw) + except (ValueError, UnicodeDecodeError): + continue + + rtype = obj.get("type") + + if rtype == "custom-title": + titles["custom"] = obj.get("customTitle") + continue + if rtype == "ai-title": + titles["ai"] = obj.get("aiTitle") + continue + if rtype == "last-prompt": + titles["last"] = obj.get("lastPrompt") + continue + + ts = parse_ts(obj.get("timestamp")) + if ts is not None: + start = ts if start is None else min(start, ts) + end = ts if end is None else max(end, ts) + + for key in ("cwd", "gitBranch", "version", "slug", "sessionId"): + if not meta.get(key) and obj.get(key): + meta[key] = obj[key] + + if rtype == "user": + counts["user"] += 1 + parsed = classify_user(obj) + if not parsed: + if obj.get("isSidechain"): + counts["sidechain"] += 1 + if turns and ts is not None: + turns[-1]["sidechain"] += 1 + turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts) + continue + kind, text = parsed + if kind == "interrupt": + if turns: + turns[-1]["interrupted"] = True + if ts is not None: + turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts) + continue + pending_prompt = None + turns.append(new_turn(len(turns) + 1, kind, text, ts)) + continue + + if rtype == "assistant": + if obj.get("isSidechain"): + counts["sidechain"] += 1 + if turns and ts is not None: + turns[-1]["sidechain"] += 1 + turns[-1]["endTs"] = max(turns[-1]["endTs"] or ts, ts) + continue + counts["assistant"] += 1 + if turns: + turn = turns[-1] + turn["assistant"] += 1 + if ts is not None: + if turn["firstReplyTs"] is None: + turn["firstReplyTs"] = ts + turn["endTs"] = max(turn["endTs"] or ts, ts) + model = (obj.get("message") or {}).get("model") + if model and model not in turn["models"]: + turn["models"].append(model) + content = (obj.get("message") or {}).get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + name = block.get("name") or "?" + turn["tools"][name] = turn["tools"].get(name, 0) + 1 + turn["toolCalls"] += 1 + continue + + # その他 (system / attachment / mode / queue-operation) はセッション範囲にのみ反映。 + # 再開時に書かれる事があり、ターンの所要時間を伸ばしてしまうため無視する。 + + _ = pending_prompt # 予約 + + # ターンごとの派生値 + prev_end = None + active = 0.0 + tool_total = 0 + for turn in turns: + ts = turn["ts"] + turn["waitMs"] = ms(turn["firstReplyTs"], ts) + turn["durMs"] = ms(turn["endTs"], ts) + turn["idleMs"] = ms(ts, prev_end) + turn["tsIso"] = iso(ts) + turn["endIso"] = iso(turn["endTs"]) + turn["summary"] = one_line(turn["prompt"], SUMMARY_LIMIT) + turn["toolList"] = sorted(turn["tools"].items(), key=lambda kv: (-kv[1], kv[0])) + tool_total += turn["toolCalls"] + if turn["durMs"]: + active += turn["durMs"] + prev_end = turn["endTs"] if turn["endTs"] is not None else prev_end + + first_prompt = next((t["prompt"] for t in turns if t["kind"] == "prompt"), "") + title = ( + titles.get("custom") + or titles.get("ai") + or one_line(first_prompt, 120) + or titles.get("last") + or meta.get("slug") + or path.stem + ) + + stat = path.stat() + return { + "id": path.stem, + "file": str(path), + "title": title, + "titleSource": ("custom" if titles.get("custom") else "ai" if titles.get("ai") else "prompt"), + "cwd": meta.get("cwd"), + "gitBranch": meta.get("gitBranch"), + "version": meta.get("version"), + "slug": meta.get("slug"), + "start": iso(start), + "end": iso(end), + "startEpoch": start, + "endEpoch": end, + "wallMs": ms(end, start), + "activeMs": int(active) if active else 0, + "turnCount": sum(1 for t in turns if t["kind"] in ("prompt", "command")), + "userMsgs": counts["user"], + "assistantMsgs": counts["assistant"], + "toolResults": counts["toolResult"], + "sidechainMsgs": counts["sidechain"], + "toolCalls": tool_total, + "bytes": stat.st_size, + "mtime": stat.st_mtime, + "turns": turns, + } + + +def ms(later, earlier): + if later is None or earlier is None: + return None + return int(round((later - earlier) * 1000)) + + +def one_line(text, limit): + text = " ".join((text or "").split()) + return text[:limit] + ("…" if len(text) > limit else "") + + +def summary_of(session): + out = {k: v for k, v in session.items() if k != "turns"} + turns = session["turns"] + out["firstPrompt"] = one_line(next((t["prompt"] for t in turns if t["kind"] == "prompt"), ""), SUMMARY_LIMIT) + out["lastPrompt"] = one_line(turns[-1]["prompt"] if turns else "", SUMMARY_LIMIT) + models = [] + for turn in turns: + for model in turn["models"]: + if model not in models: + models.append(model) + out["models"] = models + return out + + +# ---------------------------------------------------------------- ストア + + +class Store: + """jsonl の解析結果を (mtime, size) でキャッシュする。""" + + def __init__(self, root: Path): + self.root = root + self._cache = {} + self._lock = threading.Lock() + + def projects(self): + items = [] + if not self.root.is_dir(): + return items + for entry in sorted(self.root.iterdir()): + if not entry.is_dir(): + continue + files = list(entry.glob("*.jsonl")) + if not files: + continue + stats = [f.stat() for f in files] + newest = max(files, key=lambda f: f.stat().st_mtime) + items.append( + { + "id": entry.name, + "path": sniff_cwd(newest) or decode_project_dir(entry.name), + "sessions": len(files), + "bytes": sum(s.st_size for s in stats), + "lastActivity": iso(max(s.st_mtime for s in stats)), + "lastEpoch": max(s.st_mtime for s in stats), + } + ) + items.sort(key=lambda p: p["lastEpoch"], reverse=True) + return items + + def session_files(self, project_id): + directory = self.root / project_id + if not directory.is_dir() or os.path.sep in project_id or project_id in ("..", "."): + raise FileNotFoundError(project_id) + return sorted(directory.glob("*.jsonl")) + + def session(self, path: Path): + stat = path.stat() + key = str(path) + with self._lock: + hit = self._cache.get(key) + if hit and hit[0] == (stat.st_mtime, stat.st_size): + return hit[1] + parsed = parse_file(path) + with self._lock: + self._cache[key] = ((stat.st_mtime, stat.st_size), parsed) + return parsed + + def sessions(self, project_id): + out = [summary_of(self.session(p)) for p in self.session_files(project_id)] + out.sort(key=lambda s: s["startEpoch"] or 0, reverse=True) + return out + + def one(self, project_id, session_id): + for path in self.session_files(project_id): + if path.stem == session_id: + return self.session(path) + raise FileNotFoundError(session_id) + + def clear(self): + with self._lock: + self._cache.clear() + + +CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"') + + +def sniff_cwd(path: Path, max_lines=200): + """jsonl の先頭から cwd を拾う (プロジェクトの実パス表示用)。""" + try: + with path.open("rb") as fh: + for _, raw in zip(range(max_lines), fh): + match = CWD_RE.search(raw) + if match: + return json.loads(b'"' + match.group(1) + b'"') + except OSError: + pass + return None + + +def decode_project_dir(name): + """'C--debug-ImgChkSystem' -> 'C:\\debug\\ImgChkSystem' (推定)。""" + if len(name) > 3 and name[1:3] == "--" and name[0].isalpha(): + return name[0].upper() + ":\\" + name[3:].replace("-", "\\") + return name.replace("-", "/") + + +# ---------------------------------------------------------------- HTTP + + +def fmt_dur(msec): + if msec is None: + return "" + seconds = msec / 1000.0 + if seconds < 60: + return f"{seconds:.1f}s" + minutes, seconds = divmod(int(round(seconds)), 60) + if minutes < 60: + return f"{minutes}m{seconds:02d}s" + hours, minutes = divmod(minutes, 60) + return f"{hours}h{minutes:02d}m" + + +def sessions_csv(sessions): + buf = io.StringIO(newline="") + writer = csv.writer(buf) + writer.writerow(["session_id", "title", "start", "end", "wall", "active", "turns", "assistant_msgs", "tool_calls", "branch"]) + for s in sessions: + writer.writerow( + [s["id"], s["title"], s["start"] or "", s["end"] or "", fmt_dur(s["wallMs"]), + fmt_dur(s["activeMs"]), s["turnCount"], s["assistantMsgs"], s["toolCalls"], s.get("gitBranch") or ""] + ) + return buf.getvalue() + + +def turns_csv(session): + buf = io.StringIO(newline="") + writer = csv.writer(buf) + writer.writerow(["#", "kind", "time", "end", "first_reply", "duration", "idle_before", "assistant_msgs", "tool_calls", "prompt"]) + for t in session["turns"]: + writer.writerow( + [t["i"], t["kind"], t["tsIso"] or "", t["endIso"] or "", fmt_dur(t["waitMs"]), fmt_dur(t["durMs"]), + fmt_dur(t["idleMs"]), t["assistant"], t["toolCalls"], one_line(t["prompt"], 500)] + ) + return buf.getvalue() + + +class Server(ThreadingHTTPServer): + # Windows では SO_REUSEADDR が「使用中のポートにも bind できる」意味になり、 + # 二重起動が検出できなくなるため無効にする。 + allow_reuse_address = False + daemon_threads = True + + +class Handler(BaseHTTPRequestHandler): + server_version = "chktime" + store: Store = None # set on the server class + + def log_message(self, fmt, *args): # 静かに + pass + + def do_GET(self): + parsed = urlparse(self.path) + route = parsed.path + query = parse_qs(parsed.query) + try: + if route.startswith("/api/"): + return self.api(route, query) + return self.static(route) + except FileNotFoundError as exc: + self.send_json({"error": f"not found: {exc}"}, 404) + except Exception as exc: # noqa: BLE001 - UI にエラーを返す + self.send_json({"error": f"{type(exc).__name__}: {exc}"}, 500) + + def api(self, route, query): + store = self.server.store + if route == "/api/projects": + return self.send_json({"root": str(store.root), "projects": store.projects()}) + if route == "/api/sessions": + project = query.get("project", [""])[0] + sessions = store.sessions(project) + if query.get("format", [""])[0] == "csv": + return self.send_text(sessions_csv(sessions), "text/csv", f"{project}-sessions.csv") + return self.send_json({"project": project, "sessions": sessions}) + if route == "/api/session": + project = query.get("project", [""])[0] + session_id = query.get("id", [""])[0] + session = store.one(project, session_id) + if query.get("format", [""])[0] == "csv": + return self.send_text(turns_csv(session), "text/csv", f"{session_id}-turns.csv") + return self.send_json(session) + if route == "/api/reload": + store.clear() + return self.send_json({"ok": True}) + raise FileNotFoundError(route) + + def static(self, route): + name = "index.html" if route in ("/", "") else route.lstrip("/") + target = (WEB_DIR / name).resolve() + if not str(target).startswith(str(WEB_DIR)) or not target.is_file(): + raise FileNotFoundError(name) + ctype = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + }.get(target.suffix, "application/octet-stream") + body = target.read_bytes() + self.send_response(200) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + + def send_json(self, payload, status=200): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def send_text(self, text, ctype, filename): + body = ("\ufeff" + text).encode("utf-8") # Excel 対応の BOM + self.send_response(200) + self.send_header("Content-Type", f"{ctype}; charset=utf-8") + self.send_header("Content-Disposition", f'attachment; filename="{filename}"') + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + +def main(): + parser = argparse.ArgumentParser(description="Claude Code のタスク別やりとり時刻ビューア") + parser.add_argument("--port", type=int, default=8787) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--dir", default=str(Path.home() / ".claude" / "projects"), help="projects ディレクトリ") + parser.add_argument("--no-browser", action="store_true") + args = parser.parse_args() + + root = Path(args.dir).expanduser() + if not root.is_dir(): + raise SystemExit(f"見つかりません: {root}") + + url = f"http://{args.host}:{args.port}/" + try: + server = Server((args.host, args.port), Handler) + except OSError as exc: + if exc.errno not in (errno.EADDRINUSE, getattr(errno, "WSAEADDRINUSE", 10048)): + raise + # 既に起動済み: ブラウザだけ開いて終了する (ショートカットの二重起動対策) + print(f"すでに起動しています: {url}") + if not args.no_browser: + webbrowser.open(url) + return + server.store = Store(root) + print(f"chktime: {root}") + print(f" -> {url} (Ctrl+C で終了)") + if not args.no_browser: + threading.Timer(0.4, lambda: webbrowser.open(url)).start() + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nbye") + + +if __name__ == "__main__": + main() diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..b38577a --- /dev/null +++ b/run.bat @@ -0,0 +1,5 @@ +@echo off +rem chktime - Claude Code chat timing viewer (console mode) +cd /d "%~dp0" +python chktime.py %* +pause diff --git a/stop.cmd b/stop.cmd new file mode 100644 index 0000000..d7b9f64 --- /dev/null +++ b/stop.cmd @@ -0,0 +1,3 @@ +@echo off +rem chktime - stop the running server (python/pythonw running chktime.py) +powershell -NoProfile -ExecutionPolicy Bypass -Command "$p = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -like 'python*' -and $_.CommandLine -like '*chktime.py*' }); if ($p.Count) { $p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }; Write-Host ('chktime stopped (' + $p.Count + ' process)') } else { Write-Host 'chktime is not running' }; Start-Sleep -Seconds 2" diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..db02161 --- /dev/null +++ b/web/app.js @@ -0,0 +1,245 @@ +'use strict'; + +const $ = (sel) => document.querySelector(sel); +const state = { project: null, session: null, sessions: [], detail: null, relative: false }; + +// ---------------------------------------------------------------- 表示ヘルパ + +function fmtDur(ms) { + if (ms === null || ms === undefined) return '–'; + if (ms < 0) return '-' + fmtDur(-ms); + const s = ms / 1000; + if (s < 1) return `${ms}ms`; + if (s < 60) return `${s.toFixed(1)}秒`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}分${String(Math.round(s - m * 60)).padStart(2, '0')}秒`; + const h = Math.floor(m / 60); + return `${h}時間${String(m - h * 60).padStart(2, '0')}分`; +} + +function fmtTime(iso) { + if (!iso) return '–'; + const d = new Date(iso); + const p = (n) => String(n).padStart(2, '0'); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')[0]}`; +} + +function fmtDayTime(iso) { + if (!iso) return '–'; + const d = new Date(iso); + const day = `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`; + return `${day} ${fmtTime(iso)}`; +} + +function sameDay(a, b) { + if (!a || !b) return false; + const x = new Date(a); + const y = new Date(b); + return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate(); +} + +function fmtDateTime(iso) { + if (!iso) return '–'; + const d = new Date(iso); + return d.toLocaleString('ja-JP', { hour12: false, year: '2-digit', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); +} + +function fmtBytes(n) { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}KB`; + return `${(n / 1024 / 1024).toFixed(1)}MB`; +} + +function ago(iso) { + if (!iso) return ''; + const diff = (Date.now() - new Date(iso).getTime()) / 1000; + if (diff < 3600) return `${Math.max(1, Math.round(diff / 60))}分前`; + if (diff < 86400) return `${Math.round(diff / 3600)}時間前`; + return `${Math.round(diff / 86400)}日前`; +} + +const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + +async function api(path) { + const res = await fetch(path); + const data = await res.json(); + if (data.error) throw new Error(data.error); + return data; +} + +// ---------------------------------------------------------------- プロジェクト + +async function loadProjects() { + const data = await api('/api/projects'); + $('#root').textContent = data.root; + const ul = $('#project-list'); + ul.innerHTML = ''; + data.projects.forEach((p) => { + const li = document.createElement('li'); + li.dataset.id = p.id; + li.innerHTML = `
${esc(p.path)}
+
${p.sessions} タスク · ${fmtBytes(p.bytes)} · ${ago(p.lastActivity)}
`; + li.onclick = () => selectProject(p.id); + ul.appendChild(li); + }); + if (data.projects.length) selectProject(state.project && data.projects.some((p) => p.id === state.project) ? state.project : data.projects[0].id); +} + +async function selectProject(id) { + state.project = id; + document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === id)); + $('#csv-sessions').href = `/api/sessions?project=${encodeURIComponent(id)}&format=csv`; + const tbody = $('#session-table tbody'); + tbody.innerHTML = '解析中…'; + try { + const data = await api(`/api/sessions?project=${encodeURIComponent(id)}`); + state.sessions = data.sessions; + renderSessions(); + } catch (e) { + tbody.innerHTML = `${esc(e.message)}`; + } +} + +function renderSessions() { + const q = $('#filter').value.trim().toLowerCase(); + const rows = state.sessions.filter( + (s) => !q || (s.title + ' ' + s.firstPrompt + ' ' + s.lastPrompt).toLowerCase().includes(q) + ); + $('#sessions-title').textContent = `タスク ${rows.length}/${state.sessions.length}`; + const tbody = $('#session-table tbody'); + tbody.innerHTML = ''; + if (!rows.length) { + tbody.innerHTML = '該当なし'; + return; + } + rows.forEach((s) => { + const tr = document.createElement('tr'); + tr.dataset.id = s.id; + tr.className = s.id === state.session ? 'sel' : ''; + tr.innerHTML = ` + +
${esc(s.title)}
+
+ ${fmtDateTime(s.start)} + ${s.turnCount} 往復 + 全体 ${fmtDur(s.wallMs)} + 作業 ${fmtDur(s.activeMs)} + ${s.gitBranch ? `⑂ ${esc(s.gitBranch)}` : ''} +
+ `; + tr.onclick = () => selectSession(s.id); + tbody.appendChild(tr); + }); +} + +// ---------------------------------------------------------------- 詳細 + +async function selectSession(id) { + state.session = id; + document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id)); + $('#csv-turns').href = `/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}&format=csv`; + $('#detail-body').innerHTML = '

読み込み中…

'; + try { + state.detail = await api(`/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}`); + renderDetail(); + } catch (e) { + $('#detail-body').innerHTML = `

${esc(e.message)}

`; + } +} + +function renderDetail() { + const d = state.detail; + if (!d) return; + $('#detail-title').textContent = d.title; + + const maxSpan = Math.max(1, ...d.turns.map((t) => (t.idleMs || 0) + (t.durMs || 0))); + const scale = (ms) => { + const v = Math.max(0, ms || 0); + return v ? Math.max(2, Math.round((v / maxSpan) * 95)) : 0; + }; + const t0 = d.turns.length ? new Date(d.turns[0].tsIso).getTime() : 0; + + const head = ` +
+
開始${fmtDateTime(d.start)}
+
終了${fmtDateTime(d.end)}
+
全体${fmtDur(d.wallMs)}
+
作業時間合計${fmtDur(d.activeMs)}
+
往復${d.turnCount}
+
応答メッセージ${d.assistantMsgs}
+
ツール呼び出し${d.toolCalls}
+
サブエージェント${d.sidechainMsgs}
+
ログ${fmtBytes(d.bytes)} · ${esc(d.id.slice(0, 8))}
+
`; + + const rows = d.turns + .map((t) => { + const rel = (iso) => '+' + fmtDur(new Date(iso).getTime() - t0); + const when = state.relative ? rel(t.tsIso) : fmtDayTime(t.tsIso); + // 完了時刻は日をまたいだ時だけ日付を出す + const done = !t.endIso + ? '–' + : state.relative + ? rel(t.endIso) + : sameDay(t.endIso, t.tsIso) + ? fmtTime(t.endIso) + : fmtDayTime(t.endIso); + const tools = t.toolList.map(([n, c]) => (c > 1 ? `${n}×${c}` : n)).join(', '); + return ` + + ${t.i} + ${when}${t.kind !== 'prompt' ? ` ${t.kind}` : ''} + ${t.idleMs !== null && t.idleMs < 0 + ? `並行` + : fmtDur(t.idleMs)} + ${fmtDur(t.waitMs)} + ${fmtDur(t.durMs)} + ${done} +
+ + + +
+ ${t.assistant}msg${t.toolCalls ? ` ${t.toolCalls}tool` : ''} + +
${esc(t.prompt)}${t.truncated ? ' …' : ''}
+ ${tools ? `
${esc(tools)}
` : ''} + ${t.interrupted ? '
⚠ 中断あり
' : ''} + + `; + }) + .join(''); + + $('#detail-body').innerHTML = + head + + (d.turns.length + ? ` + + + + + ${rows}
#${state.relative ? '経過' : '発言時刻'}空き初回応答応答時間${state.relative ? '完了(経過)' : '完了時刻'}タイムライン内訳やりとり
` + : '

やりとりが記録されていません。

'); + + document.querySelectorAll('#turns .msg').forEach((td) => { + td.onclick = () => td.parentElement.classList.toggle('open'); + }); +} + +// ---------------------------------------------------------------- 起動 + +$('#filter').oninput = renderSessions; +$('#relative').onchange = (e) => { + state.relative = e.target.checked; + renderDetail(); +}; +$('#reload').onclick = async () => { + await fetch('/api/reload'); + const keepSession = state.session; + await loadProjects(); + if (keepSession) selectSession(keepSession).catch(() => {}); +}; + +loadProjects().catch((e) => { + $('#project-list').innerHTML = `
  • ${esc(e.message)}
  • `; +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..98aa384 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + +chktime — Claude Code タスク時刻ビューア + + + +
    +

    chktime

    + + + + +
    + +
    + + +
    +
    + タスク(セッション) + + CSV +
    +
    +
    + +
    +
    + やりとりの時刻 + CSV +
    +
    +

    左のタスクを選んでください。

    +
    +
    +
    + + + + diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..ab477ac --- /dev/null +++ b/web/style.css @@ -0,0 +1,164 @@ +:root { + --bg: #14161a; + --pane: #1a1d23; + --pane2: #20242c; + --line: #2c313b; + --fg: #e6e9ef; + --dim: #8f98a8; + --accent: #7aa2f7; + --user: #7aa2f7; + --work: #9ece6a; + --idle: #3d4350; + --warn: #e0af68; + --cmd: #bb9af7; + font-synthesis: none; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--fg); + font: 13px/1.5 "Segoe UI", "Yu Gothic UI", system-ui, sans-serif; +} + +header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--pane); + border-bottom: 1px solid var(--line); +} +header h1 { margin: 0; font-size: 15px; letter-spacing: .04em; } +header .sub { color: var(--dim); font-size: 11px; } +header .spacer { flex: 1; } +.chk { color: var(--dim); user-select: none; cursor: pointer; } + +button, .btn { + background: var(--pane2); + color: var(--fg); + border: 1px solid var(--line); + border-radius: 5px; + padding: 3px 10px; + font: inherit; + font-size: 12px; + cursor: pointer; + text-decoration: none; +} +button:hover, .btn:hover { border-color: var(--accent); color: var(--accent); } + +main { flex: 1; display: flex; min-height: 0; } + +.pane { display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line); } +#projects { width: 220px; flex: none; resize: horizontal; overflow: hidden; } +#sessions { width: 330px; flex: none; resize: horizontal; overflow: hidden; } +#detail { flex: 1; border-right: none; } + +.pane-head { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 11px; + letter-spacing: .08em; + color: var(--dim); + text-transform: uppercase; + background: var(--pane); + border-bottom: 1px solid var(--line); +} +.pane-head > span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#filter { + flex: 1 1 120px; + min-width: 80px; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 5px; + color: var(--fg); + padding: 3px 8px; + font: inherit; + font-size: 12px; +} + +.scroll { flex: 1; overflow: auto; } + +/* プロジェクト一覧 */ +#project-list { list-style: none; margin: 0; padding: 0; overflow: auto; } +#project-list li { padding: 7px 10px; border-bottom: 1px solid var(--line); cursor: pointer; } +#project-list li:hover { background: var(--pane2); } +#project-list li.sel { background: var(--pane2); box-shadow: inset 3px 0 var(--accent); } +#project-list .p-path { font-size: 12px; word-break: break-all; } +#project-list .p-meta { color: var(--dim); font-size: 11px; } + +/* セッション一覧 */ +table { width: 100%; border-collapse: collapse; } +#session-table tr { border-bottom: 1px solid var(--line); cursor: pointer; } +#session-table tr:hover { background: var(--pane2); } +#session-table tr.sel { background: var(--pane2); box-shadow: inset 3px 0 var(--accent); } +#session-table td { padding: 6px 10px; vertical-align: top; } +.s-title { font-size: 12.5px; margin-bottom: 2px; } +.s-meta { color: var(--dim); font-size: 11px; display: flex; gap: 10px; flex-wrap: wrap; } +.s-when { white-space: nowrap; } + +/* 詳細 */ +#detail-body { padding: 0 0 40px; } +.empty, .loading { color: var(--dim); padding: 20px; } +.err { color: #f7768e; padding: 20px; white-space: pre-wrap; } + +.sum { + display: flex; + flex-wrap: wrap; + gap: 18px; + padding: 10px 14px; + background: var(--pane); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 2; +} +.sum div { display: flex; flex-direction: column; } +.sum .k { color: var(--dim); font-size: 10.5px; letter-spacing: .06em; } +.sum .v { font-size: 13px; } + +#turns { width: 100%; border-collapse: collapse; } +#turns th { + position: sticky; + top: 0; + background: var(--pane); + border-bottom: 1px solid var(--line); + color: var(--dim); + font-weight: 500; + font-size: 11px; + text-align: left; + padding: 6px 8px; + white-space: nowrap; + z-index: 1; +} +#turns td { padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } +#turns tr:hover td { background: var(--pane2); } +#turns .num, #turns .n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +#turns .time { font-variant-numeric: tabular-nums; white-space: nowrap; } +#turns .done { color: var(--dim); } +#turns .d { color: var(--dim); font-size: 11px; margin-right: 1px; } +#turns .msg { width: 40%; min-width: 160px; cursor: pointer; } +#turns .small { color: var(--dim); font-size: 11px; } +#turns .small .u { font-size: 9px; opacity: .65; margin: 0 3px 0 1px; } +#turns .msg .txt { white-space: pre-wrap; word-break: break-word; max-height: 3em; overflow: hidden; } +#turns tr.open .msg .txt { max-height: none; } +#turns .tools { color: var(--dim); font-size: 11px; margin-top: 3px; word-break: break-all; } + +.kind { font-size: 10px; padding: 0 5px; border-radius: 3px; border: 1px solid var(--line); color: var(--dim); } +.kind.command { color: var(--cmd); border-color: var(--cmd); } +.kind.resume { color: var(--warn); border-color: var(--warn); } +.int { color: var(--warn); font-size: 11px; } +.ovl { color: var(--cmd); font-size: 11px; cursor: help; } + +.bar { display: flex; align-items: center; gap: 2px; height: 12px; min-width: 95px; } +.bar i { display: block; height: 8px; border-radius: 2px; } +.bar .b-idle { background: var(--idle); } +.bar .b-wait { background: var(--user); } +.bar .b-work { background: var(--work); }