diff --git a/README.md b/README.md index e23db56..1936bc4 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ python chktime.py | `--port` | 8787 | ポート番号 | | `--host` | 127.0.0.1 | 待ち受けアドレス | | `--dir` | `~/.claude/projects` | ログの置き場所 | +| `--interval` | 2.0 | ログ更新を見に行く間隔(秒)。自動追従の反応速度 | | `--no-browser` | – | ブラウザを自動で開かない | ## 画面 @@ -75,13 +76,24 @@ python chktime.py `CSV` ボタンでタスク一覧・ターン一覧・日別集計・ガントのタスク一覧をそれぞれ書き出せます(Excel 用 BOM 付き UTF-8)。 横断モードのタスク一覧 CSV にはプロジェクト列が付きます。日別集計 CSV はプロジェクトごとの列を持つ横持ち形式です。 +## 自動追従 + +ヘッダの **自動追従** をオンにすると、ログが増えるたびに表示を作り直します。Claude Code を +動かしながら開いておく用途向けです。オン/オフの状態はブラウザに記憶されます(既定はオン)。 + +- サーバが `--interval` 秒ごとに jsonl を stat して変化を検出し、SSE(`/api/watch`)でブラウザに通知します。ブラウザ側は 1.2 秒まとめてから読み直します。 +- 更新時も**選択中のプロジェクト・タスク・スクロール位置・絞り込み・開いているタブは保たれます**。やりとりを末尾まで見ていた場合は、追記された行に追従して末尾に留まります。 +- ヘッダ右の表示が状態です。`● 追従中` → 変化を検出すると `● 更新あり…` → 反映後に `● 更新 hh:mm:ss`。切断されると `● 切断(再接続待ち)` となり、自動で再接続します。 +- オフの間に増えたぶんは、次にオンにした時点でまとめて取り込みます。 +- オフのときは従来どおり **再読込** ボタンで更新します(解析キャッシュも捨てる強制再読込)。 + ## 解析の仕様(ざっくり) - ターンの起点は「人間の発言」。ツール実行結果・システム挿入・サブエージェントの発言は起点にしません。 - `/model` などのスラッシュコマンドは `command`、コンパクト後の自動継続は `resume` とラベル表示します。 - `[Request interrupted...]`(Esc 中断)が含まれるターンには ⚠ を付けます。 - セッション再開時に書かれるメタ行(attachment / system / mode 等)は、ターンの所要時間には数えません。 -- 解析結果はファイルの更新時刻+サイズでキャッシュします。ログを更新したらヘッダの **再読込** を押してください。 +- 解析結果はファイルの更新時刻+サイズでキャッシュします。変わったファイルだけ再解析するので、自動追従がオンなら手動操作は不要です。 ## ファイル diff --git a/chktime.py b/chktime.py index 14e72ad..09d35a7 100644 --- a/chktime.py +++ b/chktime.py @@ -21,6 +21,7 @@ import json import os import re import threading +import time import webbrowser from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -586,6 +587,72 @@ class Store: self._cache.clear() +class Watcher(threading.Thread): + """projects 配下の jsonl を定期的に stat して変更を検出する (自動追従の通知用)。 + + ファイル数・合計サイズ・最新 mtime の組を「版」として持ち、変わったら rev を進めて + 待っているクライアント (SSE) を起こす。解析結果は Store 側が (mtime, size) で + キャッシュしているので、ここでは通知だけを担う。 + """ + + def __init__(self, root: Path, interval=2.0): + super().__init__(daemon=True) + self.root = root + self.interval = max(0.5, interval) + self.rev = 0 + self.newest = None # (project_id, session_id) + self._cv = threading.Condition() + self._token, self.newest = self._scan() + + def _scan(self): + files = 0 + total = 0 + newest_mtime = 0.0 + newest = None + if self.root.is_dir(): + for entry in self.root.iterdir(): + if not entry.is_dir(): + continue + for path in entry.glob("*.jsonl"): + try: + stat = path.stat() + except OSError: + continue + files += 1 + total += stat.st_size + if stat.st_mtime > newest_mtime: + newest_mtime = stat.st_mtime + newest = (entry.name, path.stem) + return (files, total, round(newest_mtime, 3)), newest + + def run(self): + while True: + time.sleep(self.interval) + try: + token, newest = self._scan() + except OSError: + continue + with self._cv: + if token != self._token: + self._token = token + self.newest = newest + self.rev += 1 + self._cv.notify_all() + + def wait_change(self, rev, timeout): + """rev より進むまで待って、現在の rev を返す (タイムアウト時は同じ値)。""" + with self._cv: + if self.rev != rev: + return self.rev + self._cv.wait(timeout) + return self.rev + + def info(self): + with self._cv: + project, session = self.newest or (None, None) + return {"rev": self.rev, "project": project, "session": session, "interval": self.interval} + + CWD_RE = re.compile(rb'"cwd":"((?:[^"\\]|\\.)*)"') @@ -750,8 +817,37 @@ class Handler(BaseHTTPRequestHandler): if route == "/api/reload": store.clear() return self.send_json({"ok": True}) + if route == "/api/rev": + return self.send_json(self.server.watcher.info()) + if route == "/api/watch": + return self.watch() raise FileNotFoundError(route) + def watch(self): + """SSE。ログに変更があるまでブロックし、あれば change イベントを流す。""" + watcher = self.server.watcher + self.send_response(200) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + rev = watcher.rev + try: + self.stream(f"retry: 3000\n\nevent: hello\ndata: {json.dumps(watcher.info())}\n\n") + while True: + current = watcher.wait_change(rev, 20) + if current != rev: + rev = current + self.stream(f"event: change\ndata: {json.dumps(watcher.info())}\n\n") + else: + self.stream(": ping\n\n") # 接続維持 + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError, ValueError): + return # タブを閉じた等 + + def stream(self, text): + self.wfile.write(text.encode("utf-8")) + self.wfile.flush() + def static(self, route): name = "index.html" if route in ("/", "") else route.lstrip("/") target = (WEB_DIR / name).resolve() @@ -793,6 +889,7 @@ def main(): 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("--interval", type=float, default=2.0, help="ログ更新を見に行く間隔(秒)") parser.add_argument("--no-browser", action="store_true") args = parser.parse_args() @@ -812,6 +909,8 @@ def main(): webbrowser.open(url) return server.store = Store(root) + server.watcher = Watcher(root, args.interval) + server.watcher.start() print(f"chktime: {root}") print(f" -> {url} (Ctrl+C で終了)") if not args.no_browser: diff --git a/web/app.js b/web/app.js index 4a675f6..450ca96 100644 --- a/web/app.js +++ b/web/app.js @@ -15,6 +15,11 @@ const state = { daily: null, ganttDays: 7, gantt: null, + follow: false, + es: null, + rev: null, + followTimer: null, + refreshing: false, }; const PALETTE = ['#7aa2f7', '#9ece6a', '#e0af68', '#bb9af7', '#7dcfff', '#f7768e', '#73daca']; @@ -87,7 +92,7 @@ async function api(path) { // ---------------------------------------------------------------- プロジェクト -async function loadProjects() { +async function loadProjects({ select = true } = {}) { const data = await api('/api/projects'); state.projects = data.projects; $('#root').textContent = data.root; @@ -113,26 +118,37 @@ async function loadProjects() { ul.appendChild(li); }); - // 再読込では選択を保つ (null = 横断モードもそのまま) + // 再読込・自動追従では選択を保つ (null = 横断モードもそのまま) if (state.project === undefined) state.project = data.projects.length ? data.projects[0].id : null; else if (state.project && !data.projects.some((p) => p.id === state.project)) state.project = null; - selectProject(state.project); + markProject(); + if (select) selectProject(state.project); +} + +function markProject() { + document.querySelectorAll('#project-list li').forEach((li) => li.classList.toggle('sel', li.dataset.id === (state.project ?? ''))); + $('#csv-sessions').href = state.project + ? `/api/sessions?project=${encodeURIComponent(state.project)}&format=csv` + : '/api/all?format=csv'; } 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 = id ? `/api/sessions?project=${encodeURIComponent(id)}&format=csv` : '/api/all?format=csv'; - const tbody = $('#session-table tbody'); - tbody.innerHTML = '解析中…'; + markProject(); + $('#session-table tbody').innerHTML = '解析中…'; if (state.view === 'daily') loadDaily(); else if (state.view === 'gantt') loadGantt(); + await reloadSessions(); +} + +async function reloadSessions() { + const id = state.project; try { const data = await api(id ? `/api/sessions?project=${encodeURIComponent(id)}` : '/api/all'); state.sessions = data.sessions; renderSessions(); } catch (e) { - tbody.innerHTML = `${esc(e.message)}`; + $('#session-table tbody').innerHTML = `${esc(e.message)}`; } } @@ -186,8 +202,15 @@ async function selectSession(project, id) { document.querySelectorAll('#session-table tr').forEach((tr) => tr.classList.toggle('sel', tr.dataset.id === id)); updateCsv(); $('#detail-body').innerHTML = '

読み込み中…

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

${esc(e.message)}

`; @@ -319,9 +342,11 @@ function setView(view) { else renderDetail(); } -async function loadDaily() { - $('#detail-title').textContent = '日別集計'; - $('#detail-body').innerHTML = '

集計中…

'; +async function loadDaily({ silent = false } = {}) { + if (!silent) { + $('#detail-title').textContent = '日別集計'; + $('#detail-body').innerHTML = '

集計中…

'; + } try { state.daily = await api('/api/daily?' + dailyQuery()); renderDaily(); @@ -488,9 +513,11 @@ function renderDaily() { // ---------------------------------------------------------------- ガント -async function loadGantt() { - $('#detail-title').textContent = 'ガント'; - $('#detail-body').innerHTML = '

集計中…

'; +async function loadGantt({ silent = false } = {}) { + if (!silent) { + $('#detail-title').textContent = 'ガント'; + $('#detail-body').innerHTML = '

集計中…

'; + } try { state.gantt = await api('/api/timeline?' + ganttQuery()); renderGantt(); @@ -680,6 +707,95 @@ function wireRanges(key, reload) { }); } +// ---------------------------------------------------------------- 自動追従 + +const FOLLOW_KEY = 'chktime.follow'; +const pad2 = (n) => String(n).padStart(2, '0'); + +function followStatus(kind, text) { + const el = $('#follow-state'); + el.className = 'fstate ' + kind; + el.textContent = text ?? ''; +} + +function nowHms() { + const d = new Date(); + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; +} + +/** ログが変わったとき、表示中のものだけを黙って作り直す (選択・スクロール位置は保つ)。 */ +async function refreshFollow() { + if (state.refreshing) return; + state.refreshing = true; + const box = $('#detail-body'); + const list = $('#sessions .scroll'); + const boxTop = box.scrollTop; + const listTop = list.scrollTop; + const atBottom = box.scrollHeight - box.clientHeight - boxTop < 40; + try { + await loadProjects({ select: false }); + await reloadSessions(); + if (state.view === 'daily') await loadDaily({ silent: true }); + else if (state.view === 'gantt') await loadGantt({ silent: true }); + else await reloadDetail(); + list.scrollTop = listTop; + // やりとりを末尾まで見ていたら、追記された行に追従する (tail -f と同じ感覚) + box.scrollTop = atBottom && state.view === 'turns' ? box.scrollHeight : boxTop; + followStatus('live', `更新 ${nowHms()}`); + } catch (e) { + followStatus('err', esc(e.message).slice(0, 40)); + } finally { + state.refreshing = false; + } +} + +function scheduleRefresh() { + // 書き込み中は数秒おきに飛んでくるので、まとめてから読み直す + followStatus('busy', '更新あり…'); + clearTimeout(state.followTimer); + state.followTimer = setTimeout(refreshFollow, 1200); +} + +function revOf(event) { + try { + return JSON.parse(event.data).rev; + } catch { + return null; + } +} + +function startFollow() { + if (state.es) return; + state.follow = true; + followStatus('wait', '接続中…'); + const es = new EventSource('/api/watch'); + es.onopen = () => followStatus('live', '追従中'); + es.addEventListener('hello', (e) => { + const rev = revOf(e); + // OFF の間や切断中の変更を取り込む (初回接続時は読み込み済みなので不要) + const stale = state.rev !== null && rev !== state.rev; + state.rev = rev; + followStatus('live', '追従中'); + if (stale) scheduleRefresh(); + }); + es.addEventListener('change', (e) => { + state.rev = revOf(e); + scheduleRefresh(); + }); + es.onerror = () => followStatus('err', '切断(再接続待ち)'); + state.es = es; +} + +function stopFollow() { + state.follow = false; + clearTimeout(state.followTimer); + if (state.es) { + state.es.close(); + state.es = null; + } + followStatus('off', ''); +} + // ---------------------------------------------------------------- 起動 $('#filter').oninput = renderSessions; @@ -707,6 +823,17 @@ window.addEventListener('resize', () => { }, 150); }); -loadProjects().catch((e) => { - $('#project-list').innerHTML = `
  • ${esc(e.message)}
  • `; -}); +$('#follow').checked = localStorage.getItem(FOLLOW_KEY) !== '0'; // 既定は ON +$('#follow').onchange = (e) => { + localStorage.setItem(FOLLOW_KEY, e.target.checked ? '1' : '0'); + if (e.target.checked) startFollow(); + else stopFollow(); +}; + +loadProjects() + .then(() => { + if ($('#follow').checked) startFollow(); + }) + .catch((e) => { + $('#project-list').innerHTML = `
  • ${esc(e.message)}
  • `; + }); diff --git a/web/index.html b/web/index.html index 70b74c4..dfc0714 100644 --- a/web/index.html +++ b/web/index.html @@ -12,6 +12,8 @@ + + diff --git a/web/style.css b/web/style.css index 855fc0b..41db165 100644 --- a/web/style.css +++ b/web/style.css @@ -39,6 +39,20 @@ header .sub { color: var(--dim); font-size: 11px; } header .spacer { flex: 1; } .chk { color: var(--dim); user-select: none; cursor: pointer; } +/* 自動追従の状態表示 */ +.fstate { color: var(--dim); font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; } +.fstate:not(.off):not(:empty)::before { + content: "●"; + font-size: 9px; + margin-right: 4px; + vertical-align: 1px; +} +.fstate.live::before { color: var(--work); } +.fstate.busy::before { color: var(--accent); } +.fstate.wait::before { color: var(--warn); } +.fstate.err { color: var(--warn); } +.fstate.err::before { color: var(--warn); } + button, .btn { background: var(--pane2); color: var(--fg);