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:
@@ -0,0 +1,7 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
|
||||
# Claude Code のローカル設定
|
||||
.claude/settings.local.json
|
||||
@@ -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
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
rem chktime - Claude Code chat timing viewer (console mode)
|
||||
cd /d "%~dp0"
|
||||
python chktime.py %*
|
||||
pause
|
||||
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>`;
|
||||
});
|
||||
@@ -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
@@ -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); }
|
||||
Reference in New Issue
Block a user