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
+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); }