Claude Code のやりとり時刻ビューア chktime を追加

~/.claude/projects の会話ログ (jsonl) を解析し、タスク(セッション)ごとの
やりとりの時刻を一覧表示するローカル Web アプリ。標準ライブラリのみで動作する。

- プロジェクト / タスク / ターンの3ペイン表示
- ターンごとに 発言時刻・空き時間・初回応答までの待ち・応答時間・完了時刻・
  ツール呼び出し内訳を表示、タイムラインバー付き
- 絶対時刻と経過時間の切替、絞り込み、CSV書き出し
- ファイルの更新時刻+サイズでの解析キャッシュ、tool_result 行はパースを省略
- デスクトップ用の起動 (pythonw) / 停止 (stop.cmd) スクリプト

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 15:10:48 +09:00
co-authored by Claude Opus 5
commit cf17cf3fa0
8 changed files with 1123 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# Python
__pycache__/
*.py[cod]
.venv/
# Claude Code のローカル設定
.claude/settings.local.json
+73
View File
@@ -0,0 +1,73 @@
# chktime
Claude Code の **タスク(セッション)ごとのチャットのやりとりの時刻**を一覧表示するローカル Web アプリ。
`~/.claude/projects/<プロジェクト>/<セッションID>.jsonl`(Claude Code の会話ログ)を解析して、
「いつ何を頼んで、返事が来るまで何秒かかって、その作業が何分続いたか」を表にします。
外部通信なし・追加ライブラリなし(Python 3.9+ の標準ライブラリのみ)。
## 起動
```bash
python chktime.py
```
ブラウザで <http://127.0.0.1:8787/> が開きます。
デスクトップに以下のショートカットを作成済みです。
| ショートカット | 動作 |
| --- | --- |
| **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 スタイル
```
+580
View File
@@ -0,0 +1,580 @@
#!/usr/bin/env python3
"""chktime - Claude Code のタスク(セッション)ごとのやりとりの時刻を一覧表示するローカルWebアプリ.
~/.claude/projects/<project>/<sessionId>.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"<system-reminder>.*?</system-reminder>", re.S)
TAG_RE = re.compile(r"<([a-z-]+)>(.*?)</\1>", 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 "<local-command-stdout>" in text:
return None
if text.startswith("Caveat: The messages below were generated by the user while running"):
return None
if "<command-name>" 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()
+5
View File
@@ -0,0 +1,5 @@
@echo off
rem chktime - Claude Code chat timing viewer (console mode)
cd /d "%~dp0"
python chktime.py %*
pause
+3
View File
@@ -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"
+245
View File
@@ -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 `<span class="d">${day}</span> ${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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 = `<div class="p-path">${esc(p.path)}</div>
<div class="p-meta">${p.sessions} タスク · ${fmtBytes(p.bytes)} · ${ago(p.lastActivity)}</div>`;
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 = '<tr><td class="loading">解析中…</td></tr>';
try {
const data = await api(`/api/sessions?project=${encodeURIComponent(id)}`);
state.sessions = data.sessions;
renderSessions();
} catch (e) {
tbody.innerHTML = `<tr><td class="err">${esc(e.message)}</td></tr>`;
}
}
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 = '<tr><td class="empty">該当なし</td></tr>';
return;
}
rows.forEach((s) => {
const tr = document.createElement('tr');
tr.dataset.id = s.id;
tr.className = s.id === state.session ? 'sel' : '';
tr.innerHTML = `
<td>
<div class="s-title">${esc(s.title)}</div>
<div class="s-meta">
<span class="s-when">${fmtDateTime(s.start)}</span>
<span>${s.turnCount} 往復</span>
<span>全体 ${fmtDur(s.wallMs)}</span>
<span>作業 ${fmtDur(s.activeMs)}</span>
${s.gitBranch ? `<span>⑂ ${esc(s.gitBranch)}</span>` : ''}
</div>
</td>`;
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 = '<p class="loading">読み込み中…</p>';
try {
state.detail = await api(`/api/session?project=${encodeURIComponent(state.project)}&id=${encodeURIComponent(id)}`);
renderDetail();
} catch (e) {
$('#detail-body').innerHTML = `<p class="err">${esc(e.message)}</p>`;
}
}
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 = `
<div class="sum">
<div><span class="k">開始</span><span class="v">${fmtDateTime(d.start)}</span></div>
<div><span class="k">終了</span><span class="v">${fmtDateTime(d.end)}</span></div>
<div><span class="k">全体</span><span class="v">${fmtDur(d.wallMs)}</span></div>
<div><span class="k">作業時間合計</span><span class="v">${fmtDur(d.activeMs)}</span></div>
<div><span class="k">往復</span><span class="v">${d.turnCount}</span></div>
<div><span class="k">応答メッセージ</span><span class="v">${d.assistantMsgs}</span></div>
<div><span class="k">ツール呼び出し</span><span class="v">${d.toolCalls}</span></div>
<div><span class="k">サブエージェント</span><span class="v">${d.sidechainMsgs}</span></div>
<div><span class="k">ログ</span><span class="v">${fmtBytes(d.bytes)} · ${esc(d.id.slice(0, 8))}</span></div>
</div>`;
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 `
<tr data-i="${t.i}">
<td class="n">${t.i}</td>
<td class="time" title="${esc(fmtDateTime(t.tsIso))}">${when}${t.kind !== 'prompt' ? ` <span class="kind ${t.kind}">${t.kind}</span>` : ''}</td>
<td class="num">${t.idleMs !== null && t.idleMs < 0
? `<span class="ovl" title="前のターンの処理中に送信 (${fmtDur(-t.idleMs)} 重複)">並行</span>`
: fmtDur(t.idleMs)}</td>
<td class="num">${fmtDur(t.waitMs)}</td>
<td class="num">${fmtDur(t.durMs)}</td>
<td class="time done" title="${esc(fmtDateTime(t.endIso))}">${done}</td>
<td><div class="bar">
<i class="b-idle" style="width:${scale(t.idleMs)}px" title="空き ${fmtDur(t.idleMs)}"></i>
<i class="b-wait" style="width:${scale(t.waitMs)}px" title="初回応答まで ${fmtDur(t.waitMs)}"></i>
<i class="b-work" style="width:${scale((t.durMs || 0) - (t.waitMs || 0))}px" title="応答中 ${fmtDur((t.durMs || 0) - (t.waitMs || 0))}"></i>
</div></td>
<td class="n small">${t.assistant}<span class="u">msg</span>${t.toolCalls ? ` ${t.toolCalls}<span class="u">tool</span>` : ''}</td>
<td class="msg">
<div class="txt">${esc(t.prompt)}${t.truncated ? ' …' : ''}</div>
${tools ? `<div class="tools">${esc(tools)}</div>` : ''}
${t.interrupted ? '<div class="int">⚠ 中断あり</div>' : ''}
</td>
</tr>`;
})
.join('');
$('#detail-body').innerHTML =
head +
(d.turns.length
? `<table id="turns">
<thead><tr>
<th class="n">#</th><th>${state.relative ? '経過' : '発言時刻'}</th>
<th class="n">空き</th><th class="n">初回応答</th><th class="n">応答時間</th>
<th>${state.relative ? '完了(経過)' : '完了時刻'}</th><th>タイムライン</th><th class="n">内訳</th><th>やりとり</th>
</tr></thead><tbody>${rows}</tbody></table>`
: '<p class="empty">やりとりが記録されていません。</p>');
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 = `<li class="err">${esc(e.message)}</li>`;
});
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>chktime — Claude Code タスク時刻ビューア</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>chktime</h1>
<span class="sub" id="root"></span>
<span class="spacer"></span>
<label class="chk"><input type="checkbox" id="relative"> 経過時間で表示</label>
<button id="reload" title="キャッシュを破棄して再読込">再読込</button>
</header>
<main>
<nav id="projects" class="pane">
<div class="pane-head">プロジェクト</div>
<ul id="project-list"></ul>
</nav>
<section id="sessions" class="pane">
<div class="pane-head">
<span id="sessions-title">タスク(セッション)</span>
<input id="filter" type="search" placeholder="タイトル/プロンプトで絞り込み">
<a id="csv-sessions" class="btn" href="#" download>CSV</a>
</div>
<div class="scroll"><table id="session-table"><tbody></tbody></table></div>
</section>
<section id="detail" class="pane">
<div class="pane-head">
<span id="detail-title">やりとりの時刻</span>
<a id="csv-turns" class="btn" href="#" download>CSV</a>
</div>
<div class="scroll" id="detail-body">
<p class="empty">左のタスクを選んでください。</p>
</div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+164
View File
@@ -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); }