Skip to content

Instantly share code, notes, and snippets.

@sugyan
Last active June 18, 2025 13:28
Show Gist options
  • Save sugyan/159d6a1caa1d75cc58c8d21bdcb5f478 to your computer and use it in GitHub Desktop.
Save sugyan/159d6a1caa1d75cc58c8d21bdcb5f478 to your computer and use it in GitHub Desktop.
const express = require('express');
const fs = require('fs');
const path = require('path');
const os = require('os');
const app = express();
const PORT = process.env.PORT || 3000;
class ChatLogParser {
constructor() {
this.claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
}
parseJsonlLine(line) {
try {
return JSON.parse(line.trim());
} catch (error) {
return null;
}
}
parseJsonlFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
const entries = [];
for (const line of lines) {
const entry = this.parseJsonlLine(line);
if (!entry || entry.type === 'summary') continue;
const processedEntry = {
uuid: entry.uuid,
timestamp: entry.timestamp ? new Date(entry.timestamp) : null,
sessionId: entry.sessionId,
type: entry.type,
content: '',
toolInfo: []
};
if (entry.type === 'user') {
if (entry.message?.content) {
if (typeof entry.message.content === 'string') {
processedEntry.content = entry.message.content;
} else if (Array.isArray(entry.message.content)) {
processedEntry.content = entry.message.content
.filter(item => typeof item === 'string')
.join(' ')
.trim();
}
}
} else if (entry.type === 'assistant') {
if (entry.message?.content && Array.isArray(entry.message.content)) {
for (const item of entry.message.content) {
if (item.type === 'text') {
processedEntry.content += item.text + ' ';
} else if (item.type === 'tool_use') {
processedEntry.toolInfo.push({
type: 'tool_use',
name: item.name,
input: item.input
});
}
}
processedEntry.content = processedEntry.content.trim();
}
}
if (processedEntry.content || processedEntry.toolInfo.length > 0) {
if (entry.type === 'assistant' && !processedEntry.content && processedEntry.toolInfo.length > 0) {
processedEntry.isToolOnly = true;
}
entries.push(processedEntry);
}
}
return entries;
} catch (error) {
console.error(`Error parsing file ${filePath}:`, error);
return [];
}
}
getProjectConversations(projectPath) {
const fullProjectPath = path.join(this.claudeProjectsPath, projectPath);
if (!fs.existsSync(fullProjectPath)) {
throw new Error(`Project path not found: ${projectPath}`);
}
const files = fs.readdirSync(fullProjectPath)
.filter(file => file.endsWith('.jsonl'))
.map(file => path.join(fullProjectPath, file));
let allEntries = [];
for (const file of files) {
const entries = this.parseJsonlFile(file);
allEntries = allEntries.concat(entries);
}
allEntries.sort((a, b) => {
if (!a.timestamp || !b.timestamp) return 0;
return a.timestamp - b.timestamp;
});
const groupedBySessions = {};
for (const entry of allEntries) {
if (!groupedBySessions[entry.sessionId]) {
groupedBySessions[entry.sessionId] = [];
}
groupedBySessions[entry.sessionId].push(entry);
}
return {
entries: allEntries,
sessions: groupedBySessions,
projectPath: projectPath
};
}
getAvailableProjects() {
try {
return fs.readdirSync(this.claudeProjectsPath)
.filter(dir => {
const fullPath = path.join(this.claudeProjectsPath, dir);
return fs.statSync(fullPath).isDirectory();
});
} catch (error) {
return [];
}
}
}
const parser = new ChatLogParser();
app.get('/', (req, res) => {
const projects = parser.getAvailableProjects();
const html = `
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code Chat Log Reader</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; border-bottom: 2px solid #e1e1e1; padding-bottom: 20px; }
.project-list { list-style: none; padding: 0; }
.project-list li { margin: 10px 0; }
.project-list a { color: #0066cc; text-decoration: none; padding: 10px; display: block; border: 1px solid #e1e1e1; border-radius: 4px; transition: background 0.2s; }
.project-list a:hover { background: #f0f8ff; }
.usage { background: #f9f9f9; padding: 20px; border-radius: 4px; margin-top: 20px; }
code { background: #e1e1e1; padding: 2px 4px; border-radius: 2px; }
</style>
</head>
<body>
<div class="container">
<h1>Claude Code Chat Log Reader</h1>
<p>各プロジェクトのClaude Codeとの対話履歴を閲覧できます。</p>
<h2>利用可能なプロジェクト:</h2>
<ul class="project-list">
${projects.map(project =>
`<li><a href="/project/${encodeURIComponent(project)}">${project}</a></li>`
).join('')}
</ul>
<div class="usage">
<h3>使い方:</h3>
<p>上記のプロジェクト名をクリックするか、URLに直接プロジェクトパスを指定してください:</p>
<code>http://localhost:${PORT}/project/[プロジェクトパス]</code>
</div>
</div>
</body>
</html>`;
res.send(html);
});
app.get('/project/:projectPath(*)', (req, res) => {
try {
const projectPath = req.params.projectPath;
const conversations = parser.getProjectConversations(projectPath);
res.send(generateConversationHtml(conversations));
} catch (error) {
res.status(404).send(`
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error - Claude Code Chat Log Reader</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.error { color: #d73a49; background: #ffeef0; padding: 20px; border-radius: 4px; border-left: 4px solid #d73a49; }
</style>
</head>
<body>
<div class="container">
<h1>Error</h1>
<div class="error">
<strong>プロジェクトが見つかりません:</strong> ${error.message}
</div>
<p><a href="/">← プロジェクト一覧に戻る</a></p>
</div>
</body>
</html>`);
}
});
function generateConversationHtml(conversations) {
const { entries, sessions, projectPath } = conversations;
return `
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chat Log - ${projectPath}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #f5f5f5;
line-height: 1.6;
}
.container {
max-width: 1000px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: #2d3748;
color: white;
padding: 20px 30px;
}
.header h1 { margin: 0; font-size: 24px; }
.header .project-path { opacity: 0.8; font-size: 14px; margin-top: 5px; }
.content { padding: 30px; }
.controls {
margin-bottom: 30px;
padding: 15px;
background: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
}
.toggle-button {
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
margin-right: 10px;
font-size: 14px;
}
.toggle-button:hover { background: #0056b3; }
.toggle-button.active { background: #28a745; }
.session-separator {
margin: 25px 0;
border-top: 2px solid #e1e1e1;
padding-top: 15px;
position: relative;
}
.session-separator::before {
content: "New Session";
position: absolute;
top: -12px;
left: 20px;
background: white;
padding: 0 10px;
color: #666;
font-size: 12px;
font-weight: bold;
}
.message {
margin: 12px 0;
padding: 12px 16px;
border-radius: 6px;
}
.message.user {
background: #e3f2fd;
border-left: 4px solid #2196f3;
margin-right: 60px;
}
.message.assistant {
background: #f1f8e9;
border-left: 4px solid #4caf50;
margin-left: 60px;
}
.message-header {
font-size: 12px;
color: #666;
margin-bottom: 6px;
font-weight: bold;
}
.message-content {
white-space: pre-wrap;
word-wrap: break-word;
}
.timestamp {
font-size: 11px;
color: #999;
margin-top: 6px;
}
.tool-use {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-left: 4px solid #6c757d;
border-radius: 4px;
padding: 12px;
margin: 12px 0;
margin-left: 30%;
margin-right: 20px;
font-size: 13px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.tool-use.todo {
border-left-color: #28a745;
background: #f8fff9;
}
.tool-name {
font-weight: bold;
color: #495057;
margin-bottom: 8px;
font-size: 14px;
}
.tool-params {
list-style: none;
padding: 0;
margin: 0;
}
.tool-param {
margin: 1px 0;
padding: 1px 0;
line-height: 1.2;
}
.param-key {
font-weight: 600;
color: #6c757d;
display: inline-block;
min-width: 120px;
}
.param-value {
color: #212529;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 450px;
display: inline-block;
}
.todo-item {
background: #fff;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 8px;
margin: 4px 0;
}
.todo-content {
font-weight: 500;
margin-bottom: 4px;
}
.todo-meta {
font-size: 11px;
color: #6c757d;
}
.todo-status {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-weight: bold;
text-transform: uppercase;
}
.todo-status.pending { background: #ffeaa7; color: #856404; }
.todo-status.in_progress { background: #a8e6cf; color: #155724; }
.todo-status.completed { background: #c3e6cb; color: #155724; }
.todo-priority.high { color: #dc3545; }
.todo-priority.medium { color: #fd7e14; }
.todo-priority.low { color: #6c757d; }
.back-link {
color: #007bff;
text-decoration: none;
margin-bottom: 20px;
display: inline-block;
}
.back-link:hover { text-decoration: underline; }
.stats {
background: #f8f9fa;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
font-size: 14px;
color: #666;
}
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Claude Code Chat Log</h1>
<div class="project-path">${projectPath}</div>
</div>
<div class="content">
<a href="/" class="back-link">← プロジェクト一覧に戻る</a>
<div class="stats">
<strong>統計:</strong>
${Object.keys(sessions).length} セッション,
${entries.length} メッセージ
</div>
<div class="controls">
<button class="toggle-button" id="toggle-tools" onclick="toggleTools()">
ツール使用を表示
</button>
<span style="color: #666; font-size: 12px;">
ツール使用の表示/非表示を切り替えます
</span>
</div>
<div class="conversation">
${generateMessagesHtml(entries)}
</div>
</div>
</div>
<script>
let showTools = false;
function toggleTools() {
showTools = !showTools;
const button = document.getElementById('toggle-tools');
const toolElements = document.querySelectorAll('.tool-use');
if (showTools) {
button.textContent = 'ツール使用を非表示';
button.classList.add('active');
toolElements.forEach(el => el.classList.remove('hidden'));
} else {
button.textContent = 'ツール使用を表示';
button.classList.remove('active');
toolElements.forEach(el => el.classList.add('hidden'));
}
}
</script>
</body>
</html>`;
}
function generateMessagesHtml(entries) {
let html = '';
let currentSessionId = null;
for (const entry of entries) {
if (currentSessionId && currentSessionId !== entry.sessionId) {
html += '<div class="session-separator"></div>';
}
currentSessionId = entry.sessionId;
if (!entry.isToolOnly) {
const timestamp = entry.timestamp ? entry.timestamp.toLocaleString('ja-JP') : 'Unknown time';
const messageClass = entry.type === 'user' ? 'user' : 'assistant';
const messageHeader = entry.type === 'user' ? 'User' : 'Claude';
html += `
<div class="message ${messageClass}">
<div class="message-header">${messageHeader}</div>
<div class="message-content">${escapeHtml(entry.content)}</div>
<div class="timestamp">${timestamp}</div>
</div>
`;
}
html += generateToolInfoHtml(entry.toolInfo);
}
return html;
}
function generateToolInfoHtml(toolInfo) {
if (!toolInfo || toolInfo.length === 0) return '';
return toolInfo.map(tool => {
if (tool.type === 'tool_use') {
if (tool.name === 'TodoWrite') {
return generateTodoHtml(tool);
}
const params = Object.entries(tool.input).map(([key, value]) => {
let displayValue;
if (typeof value === 'object') {
displayValue = JSON.stringify(value, null, 2);
} else {
displayValue = String(value);
}
return `
<li class="tool-param">
<span class="param-key">${escapeHtml(key)}:</span>
<span class="param-value">${escapeHtml(displayValue)}</span>
</li>
`;
}).join('');
return `
<div class="tool-use hidden">
<div class="tool-name">🔧 ${tool.name}</div>
<ul class="tool-params">
${params}
</ul>
</div>
`;
}
return '';
}).join('');
}
function generateTodoHtml(tool) {
try {
const todos = tool.input.todos;
if (!todos || !Array.isArray(todos)) {
return generateRegularToolHtml(tool);
}
const todoItems = todos.map(todo => `
<div class="todo-item">
<div class="todo-content">${escapeHtml(todo.content)}</div>
<div class="todo-meta">
<span class="todo-status ${todo.status}">${todo.status}</span>
<span class="todo-priority ${todo.priority}">${todo.priority}</span>
</div>
</div>
`).join('');
return `
<div class="tool-use todo hidden">
<div class="tool-name">✓ Todo List (${todos.length} items)</div>
<div>
${todoItems}
</div>
</div>
`;
} catch (error) {
return generateRegularToolHtml(tool);
}
}
function generateRegularToolHtml(tool) {
const params = Object.entries(tool.input).map(([key, value]) => {
let displayValue;
if (typeof value === 'object') {
displayValue = JSON.stringify(value, null, 2);
} else {
displayValue = String(value);
}
return `
<li class="tool-param">
<span class="param-key">${escapeHtml(key)}:</span>
<span class="param-value">${escapeHtml(displayValue)}</span>
</li>
`;
}).join('');
return `
<div class="tool-use hidden">
<div class="tool-name">🔧 ${tool.name}</div>
<ul class="tool-params">
${params}
</ul>
</div>
`;
}
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
app.listen(PORT, () => {
console.log(`Claude Code Chat Log Reader running at http://localhost:${PORT}`);
console.log(`Available projects: ${parser.getAvailableProjects().join(', ')}`);
});

claude codeとの対話履歴を1ページで閲覧するツール

各directoryでclaude codeにて自分が入力した履歴、claude codeからのresponseで表示されたtext、の履歴を時系列に一覧表示したい

前提

~/.claude/projects/ には 各directoryで claude code との対話の履歴がsessionごとに保存されている

表示要件

みやすく、時系列で古いものから最新のものへ、順番にtimestampつきで表示。directoryごとに1ページ

  • 「自分の発言」と「claude codeからのtext content」はMUST
  • Toolの使用やTODO listなどについては表示・非表示切り替えられる機能があると良いかも

sessionごとに区切りを入れる。

実装例

整形したhtmlを出力するweb server。

project pathを指定すると該当directoryのなかからjsonlを読んでいいかんじに整形してhtml出力。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment