Created
January 8, 2026 07:30
-
-
Save dpavlin/28eaa39a178bca8ee27ca17d560d9458 to your computer and use it in GitHub Desktop.
Gemini CLI chat session viewer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Gemini CLI chat session viewer</title> | |
| <style> | |
| :root { | |
| --bg-color: #ffffff; | |
| --text-color: #212529; | |
| --border-color: #dee2e6; | |
| --user-bg: #e7f5ff; | |
| --model-bg: #f8f9fa; | |
| --tool-bg: #fffde7; | |
| --tool-border: #fdd835; | |
| --thought-bg: #f1f3f5; | |
| --thought-border: #adb5bd; | |
| --header-text: #495057; | |
| --button-bg: #f8f9fa; | |
| --button-hover-bg: #e9ecef; | |
| --code-bg: #f6f8fa; | |
| --code-text: #24292e; | |
| --quote-border: #dfe2e5; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| :root { | |
| --bg-color: #0d1117; | |
| --text-color: #c9d1d9; | |
| --border-color: #30363d; | |
| --user-bg: #16263a; | |
| --model-bg: #21262d; | |
| --tool-bg: #4b462c; | |
| --tool-border: #fdd835; | |
| --thought-bg: #2d333b; | |
| --thought-border: #444c56; | |
| --header-text: #8b949e; | |
| --button-bg: #21262d; | |
| --button-hover-bg: #30363d; | |
| --code-bg: #161b22; | |
| --code-text: #c9d1d9; | |
| --quote-border: #444c56; | |
| } | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| background-color: var(--bg-color); | |
| color: var(--text-color); | |
| margin: 0; | |
| padding: 1.5rem; | |
| line-height: 1.6; | |
| } | |
| .container { max-width: 900px; margin: 0 auto; } | |
| h1 { border-bottom: 2px solid var(--border-color); padding-bottom: 0.5rem; margin-bottom: 1.5rem; color: var(--header-text); font-weight: 500; } | |
| .controls { | |
| position: fixed; | |
| top: 10px; | |
| right: 10px; | |
| background: rgba(255, 255, 255, 0.95); | |
| padding: 8px 12px; | |
| border: 1px solid var(--border-color); | |
| border-radius: 8px; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
| z-index: 1000; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| backdrop-filter: blur(5px); | |
| font-size: 0.85rem; | |
| max-width: 250px; | |
| max-height: 90vh; | |
| overflow-y: auto; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| .controls { background: rgba(33, 38, 45, 0.95); } | |
| } | |
| /* Collapsed State */ | |
| .controls.collapsed { | |
| width: 40px; | |
| height: 40px; | |
| padding: 0; | |
| border-radius: 50%; | |
| overflow: hidden; | |
| justify-content: center; | |
| align-items: center; | |
| } | |
| .controls.collapsed .controls-content { | |
| display: none; | |
| } | |
| .controls .controls-icon { | |
| display: none; | |
| cursor: pointer; | |
| width: 40px; | |
| height: 40px; | |
| align-items: center; | |
| justify-content: center; | |
| color: var(--header-text); | |
| } | |
| .controls.collapsed .controls-icon { | |
| display: flex; | |
| } | |
| /* Removed hover expansion to rely on click */ | |
| .controls-header-bar { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 8px; | |
| padding-bottom: 4px; | |
| border-bottom: 1px solid var(--border-color); | |
| } | |
| .controls-title { font-weight: bold; color: var(--header-text); font-size: 0.9em; } | |
| .minimize-btn { | |
| background: none; border: none; font-size: 1.2em; line-height: 1; cursor: pointer; color: var(--header-text); padding: 0 4px; | |
| } | |
| .minimize-btn:hover { background-color: var(--button-hover-bg); border-radius: 4px; } | |
| .controls-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| .controls-section-title { font-weight: bold; margin-bottom: 4px; font-size: 0.75rem; text-transform: uppercase; color: var(--header-text); border-bottom: 1px solid var(--border-color); padding-bottom: 2px; } | |
| .control-group { display: flex; flex-direction: column; gap: 4px; } | |
| .checkbox-label { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; } | |
| .checkbox-label input { accent-color: #007bff; cursor: pointer; } | |
| .btn-small { padding: 4px 8px; font-size: 0.75rem; border: 1px solid var(--border-color); border-radius: 4px; cursor: pointer; background-color: var(--button-bg); color: var(--text-color); } | |
| .btn-small:hover { background-color: var(--button-hover-bg); } | |
| .btn-small:disabled { opacity: 0.5; cursor: not-allowed; } | |
| .turn { margin-bottom: 1.5rem; border-radius: 8px; border: 1px solid var(--border-color); overflow: hidden; position: relative; } | |
| .role { font-weight: bold; text-transform: uppercase; font-size: 0.8em; padding: 0.5rem 1.25rem; color: var(--header-text); border-bottom: 1px solid var(--border-color); } | |
| .user .role { background-color: var(--user-bg); } | |
| .model .role, .gemini .role { background-color: var(--model-bg); } | |
| .info .role { background-color: var(--bg-color); font-style: italic; } | |
| .content-wrapper { padding: 0.25rem 1.25rem 1.25rem; background-color: var(--model-bg); } | |
| .user .content-wrapper { background-color: var(--user-bg); } | |
| .info .content-wrapper { background-color: var(--bg-color); } | |
| .close-turn-btn { | |
| position: absolute; | |
| top: 6px; right: 12px; | |
| font-size: 1.5rem; font-weight: bold; color: var(--header-text); cursor: pointer; | |
| border: none; background: none; line-height: 1; padding: 0.25rem; | |
| } | |
| .close-turn-btn:hover { color: var(--text-color); } | |
| .markdown-container { position: relative; } | |
| .button-group { | |
| position: absolute; | |
| top: 10px; right: 10px; | |
| opacity: 0; | |
| transition: opacity 0.2s ease-in-out; | |
| display: flex; | |
| gap: 5px; | |
| } | |
| .markdown-container:hover .button-group { opacity: 1; } | |
| .button-group button { border: 1px solid var(--border-color); background-color: var(--button-bg); color: var(--text-color); padding: 2px 8px; border-radius: 6px; font-size: 0.8em; cursor: pointer; } | |
| .button-group button:hover { background-color: var(--button-hover-bg); } | |
| /* Compact Details Styles */ | |
| details { | |
| border-left: 4px solid var(--tool-border); | |
| background-color: var(--tool-bg); | |
| border-radius: 4px; | |
| margin: 0.5rem 0; /* Reduced margin */ | |
| } | |
| details.thoughts { border-left-color: var(--thought-border); background-color: var(--thought-bg); } | |
| summary { | |
| cursor: pointer; | |
| padding: 0.5rem 1rem; /* Reduced padding */ | |
| font-weight: bold; | |
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; | |
| list-style: none; | |
| outline: none; | |
| font-size: 0.9em; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| summary::-webkit-details-marker { display: none; } | |
| summary::before { content: '▶'; margin-right: 0.6em; display: inline-block; transition: transform 0.2s; font-size: 0.8em; } | |
| details[open] > summary::before { transform: rotate(90deg); } | |
| details > .tool-content, details > .thought-content { margin: 0.5rem 1rem 1rem 2rem; } | |
| .user pre { background: var(--bg-color); padding: 1em; border-radius: 5px; overflow-x: auto; white-space: pre-wrap; word-wrap: break-word; } | |
| /* ... Markdown Styles ... */ | |
| .markdown-body { font-size: 16px; line-height: 1.5; word-wrap: break-word; } | |
| .markdown-body > *:first-child { margin-top: 0 !important; } | |
| .markdown-body > *:last-child { margin-bottom: 0 !important; } | |
| .markdown-body h1, .markdown-body h2 { border-bottom: 1px solid var(--border-color); padding-bottom: .3em; margin-top: 24px; margin-bottom: 16px; } | |
| .markdown-body h1 { font-size: 2em; } .markdown-body h2 { font-size: 1.5em; } .markdown-body h3 { font-size: 1.25em; } | |
| .markdown-body code { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; font-size: 85%; padding: .2em .4em; margin: 0; background-color: var(--code-bg); border-radius: 6px; } | |
| .markdown-body pre { font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; font-size: 85%; line-height: 1.45; background-color: var(--code-bg); border-radius: 6px; padding: 16px; overflow: auto; } | |
| .markdown-body pre code { background: none; padding: 0; } | |
| .markdown-body ul, .markdown-body ol { padding-left: 2em; } | |
| .markdown-body blockquote { padding: 0 1em; color: var(--header-text); border-left: .25em solid var(--quote-border); } | |
| .markdown-body table { display: block; width: 100%; overflow: auto; border-spacing: 0; border-collapse: collapse; } | |
| .markdown-body tr { background-color: var(--bg-color); border-top: 1px solid var(--border-color); } | |
| .markdown-body th, .markdown-body td { padding: 6px 13px; border: 1px solid var(--border-color); } | |
| .error { color: #d32f2f; font-weight: bold; padding: 1rem; border: 1px solid #d32f2f; background-color: #ffcdd2; border-radius: 5px; } | |
| .thought-item { margin-bottom: 1rem; border-bottom: 1px solid var(--thought-border); padding-bottom: 0.5rem; } | |
| .thought-item:last-child { border-bottom: none; } | |
| .thought-subject { font-weight: bold; margin-bottom: 0.25rem; } | |
| .thought-description { font-size: 0.9em; } | |
| /* Tool Specific Styles */ | |
| .tool-header { display: flex; align-items: center; justify-content: space-between; font-family: monospace; font-size: 0.9em; } | |
| .tool-name { font-weight: bold; color: var(--header-text); } | |
| .terminal-block { background-color: #1e1e1e; color: #d4d4d4; padding: 10px; border-radius: 4px; font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; overflow-x: auto; margin-top: 5px; } | |
| .terminal-command { color: #4ec9b0; margin-bottom: 5px; border-bottom: 1px solid #333; padding-bottom: 5px; } | |
| .terminal-command::before { content: '$ '; color: #6a9955; } | |
| .file-block { border: 1px solid var(--border-color); border-radius: 4px; margin-top: 5px; overflow: hidden; } | |
| .file-header { background-color: var(--button-bg); padding: 5px 10px; font-size: 0.85em; font-weight: bold; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; } | |
| .file-content { padding: 10px; background-color: var(--code-bg); overflow-x: auto; margin: 0; font-family: monospace; font-size: 0.85em; } | |
| .diff-container { display: flex; flex-direction: column; gap: 5px; margin-top: 5px; } | |
| .diff-block { border: 1px solid var(--border-color); border-radius: 4px; overflow: hidden; } | |
| .diff-header { padding: 4px 8px; font-size: 0.8em; font-weight: bold; color: #fff; } | |
| .diff-old .diff-header { background-color: #d73a49; } | |
| .diff-new .diff-header { background-color: #28a745; } | |
| .diff-content { padding: 8px; background-color: var(--code-bg); overflow-x: auto; margin: 0; font-family: monospace; font-size: 0.85em; white-space: pre-wrap; } | |
| .instruction-text { font-style: italic; color: var(--header-text); margin: 5px 0; padding: 5px; background: var(--bg-color); border-left: 3px solid var(--tool-border); } | |
| /* Turn Header Controls */ | |
| .turn-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| border-bottom: 1px solid var(--border-color); | |
| padding: 0.5rem 1.25rem; | |
| background-color: inherit; /* Inherit from .turn subclass */ | |
| } | |
| .role-name { font-weight: bold; text-transform: uppercase; font-size: 0.8em; color: var(--header-text); } | |
| .turn-controls { display: flex; align-items: center; gap: 8px; } | |
| .json-btn { | |
| background: none; border: 1px solid var(--border-color); border-radius: 4px; | |
| padding: 2px 6px; font-size: 0.7em; cursor: pointer; color: var(--header-text); | |
| opacity: 0.7; transition: opacity 0.2s; | |
| } | |
| .json-btn:hover { opacity: 1; background-color: rgba(0,0,0,0.05); } | |
| .warning-icon { cursor: help; font-size: 1.2em; line-height: 1; } | |
| .json-view { | |
| display: none; | |
| background-color: var(--code-bg); | |
| padding: 10px; | |
| margin: 0; | |
| border-bottom: 1px solid var(--border-color); | |
| font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace; | |
| font-size: 0.8em; | |
| overflow-x: auto; | |
| white-space: pre-wrap; | |
| color: var(--code-text); | |
| } | |
| .token-info { font-size: 0.75em; color: var(--header-text); margin-left: auto; margin-right: 1rem; opacity: 0.8; font-family: monospace; } | |
| .warning-block { | |
| background-color: #fff3cd; | |
| border-bottom: 1px solid #ffeeba; | |
| color: #856404; | |
| padding: 0.5rem 1.25rem; | |
| font-size: 0.85em; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| .warning-block { | |
| background-color: #3e3315; | |
| border-bottom: 1px solid #5e4d20; | |
| color: #e8d69b; | |
| } | |
| } | |
| body.hide-warnings .warning-block { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Gemini CLI chat session viewer</h1> | |
| <div class="controls" id="mainControls"> | |
| <div class="controls-icon" id="expandBtn"> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg> | |
| </div> | |
| <div class="controls-content"> | |
| <div class="controls-header-bar"> | |
| <span class="controls-title">Settings</span> | |
| <button id="minimizeBtn" class="minimize-btn" title="Minimize">−</button> | |
| </div> | |
| <div class="controls-row"> | |
| <input type="file" id="fileInput" accept=".json" style="width: 180px;"> | |
| <button id="reloadBtn" class="btn-small" disabled>Reload</button> | |
| </div> | |
| <div class="controls-section-title">Expand / Collapse</div> | |
| <div class="control-group"> | |
| <label class="checkbox-label"><input type="checkbox" id="expandThoughts"> Thoughts</label> | |
| <label class="checkbox-label"><input type="checkbox" id="expandToolCalls"> Tool Calls</label> | |
| <label class="checkbox-label"><input type="checkbox" id="showWarnings" checked> Warnings</label> | |
| </div> | |
| <div class="controls-section-title">Expand by Tool Type</div> <div class="control-group" id="toolTypeFilters"> | |
| <!-- Checkboxes will be populated dynamically --> | |
| <div style="font-style: italic; font-size: 0.8em; color: gray;">Load a file...</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="output"> | |
| <p>Please select a JSON chat log file to begin.</p> | |
| </div> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/dompurify@2.4.1/dist/purify.min.js"></script> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', () => { | |
| const fileInput = document.getElementById('fileInput'); | |
| const outputDiv = document.getElementById('output'); | |
| const reloadBtn = document.getElementById('reloadBtn'); | |
| const mainControls = document.getElementById('mainControls'); | |
| const minimizeBtn = document.getElementById('minimizeBtn'); | |
| const expandBtn = document.getElementById('expandBtn'); | |
| let userManuallyExpanded = false; | |
| // Scroll handling for controls collapse | |
| window.addEventListener('scroll', () => { | |
| if (userManuallyExpanded) return; // Don't auto-hide if user specifically opened it while scrolled | |
| if (window.scrollY > 50) { | |
| mainControls.classList.add('collapsed'); | |
| } else { | |
| mainControls.classList.remove('collapsed'); | |
| // Reset manual flag when back at top so it auto-hides again on next scroll | |
| userManuallyExpanded = false; | |
| } | |
| }, { passive: true }); | |
| // Manual Minimize | |
| minimizeBtn.addEventListener('click', () => { | |
| mainControls.classList.add('collapsed'); | |
| userManuallyExpanded = false; | |
| }); | |
| // Manual Expand | |
| expandBtn.addEventListener('click', () => { | |
| mainControls.classList.remove('collapsed'); | |
| // If we are scrolled down, mark as manually expanded so it stays open | |
| if (window.scrollY > 50) { | |
| userManuallyExpanded = true; | |
| } | |
| }); | |
| // Initial check for scroll position | |
| if (window.scrollY > 50) { | |
| mainControls.classList.add('collapsed'); | |
| } | |
| // Toggle Controls | |
| const expandThoughtsCb = document.getElementById('expandThoughts'); | |
| const expandToolCallsCb = document.getElementById('expandToolCalls'); | |
| const showWarningsCb = document.getElementById('showWarnings'); | |
| const toolTypeFiltersDiv = document.getElementById('toolTypeFilters'); | |
| let currentFile = null; | |
| let currentChatData = null; | |
| // State for expansion (Default: everything False/Collapsed) | |
| let state = { | |
| thoughts: false, | |
| toolCalls: false, | |
| expandedToolTypes: new Set() | |
| }; | |
| // Load persisted data on init | |
| function init() { | |
| // ... (existing init logic) ... | |
| const savedData = localStorage.getItem('gemini_last_session'); | |
| // ... | |
| } | |
| // (Keeping init logic implicitly via ... but replacing the listener block below) | |
| // Listeners | |
| expandThoughtsCb.addEventListener('change', (e) => { | |
| state.thoughts = e.target.checked; | |
| applyExpansion(); | |
| }); | |
| expandToolCallsCb.addEventListener('change', (e) => { | |
| state.toolCalls = e.target.checked; | |
| applyExpansion(); | |
| }); | |
| showWarningsCb.addEventListener('change', (e) => { | |
| if (e.target.checked) { | |
| document.body.classList.remove('hide-warnings'); | |
| } else { | |
| document.body.classList.add('hide-warnings'); | |
| } | |
| }); | |
| function applyExpansion() { | |
| // Thoughts | |
| document.querySelectorAll('details.thoughts').forEach(el => { | |
| el.open = state.thoughts; | |
| }); | |
| // Tool Entries (Calls & Responses) | |
| document.querySelectorAll('.tool-entry').forEach(el => { | |
| const type = el.dataset.toolType; | |
| // Logic: Expand if Master is True OR Specific Type is True | |
| const shouldExpand = state.toolCalls || state.expandedToolTypes.has(type); | |
| if (shouldExpand) { | |
| el.open = true; | |
| } else { | |
| el.open = false; | |
| } | |
| }); | |
| } | |
| outputDiv.addEventListener('click', async function(event) { | |
| const btn = event.target; | |
| if (btn.classList.contains('close-turn-btn')) { | |
| btn.closest('.turn').style.display = 'none'; | |
| } else if (btn.classList.contains('copy-btn')) { | |
| const originalText = btn.textContent; | |
| let success = false; | |
| try { | |
| if (btn.classList.contains('copy-markdown-btn')) { | |
| const textToCopy = btn.closest('.markdown-container').dataset.rawMarkdown; | |
| await navigator.clipboard.writeText(textToCopy); | |
| success = true; | |
| } else if (btn.classList.contains('copy-html-btn')) { | |
| const body = btn.closest('.markdown-container').querySelector('.markdown-body'); | |
| success = copyHtmlToClipboard(body.innerHTML); | |
| } | |
| } catch (err) { console.error('Copy failed:', err); } | |
| btn.textContent = success ? 'Copied!' : 'Failed!'; | |
| setTimeout(() => { btn.textContent = originalText; }, 2000); | |
| } | |
| }); | |
| function copyHtmlToClipboard(html) { | |
| const listener = (e) => { | |
| e.clipboardData.setData('text/html', html); | |
| e.clipboardData.setData('text/plain', html); | |
| e.preventDefault(); | |
| }; | |
| document.addEventListener('copy', listener); | |
| document.execCommand('copy'); | |
| document.removeEventListener('copy', listener); | |
| return true; | |
| } | |
| function handleFileSelect(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| currentFile = file; | |
| loadFile(file); | |
| reloadBtn.disabled = false; | |
| } | |
| function loadFile(file) { | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const rawJson = e.target.result; | |
| let chatData = JSON.parse(rawJson); | |
| // Handle new session format with 'messages' array | |
| if (!Array.isArray(chatData) && chatData.messages && Array.isArray(chatData.messages)) { | |
| chatData = chatData.messages.map(m => { | |
| // Preserve all original fields, add normalized ones for renderer | |
| return { | |
| ...m, | |
| role: m.type === 'gemini' ? 'model' : m.type, | |
| // Ensure these exist if not present, but prefer original if compatible | |
| content: m.content, | |
| thoughts: m.thoughts, | |
| toolCalls: m.toolCalls, | |
| timestamp: m.timestamp, | |
| parts: m.parts | |
| }; | |
| }); | |
| } | |
| if (!Array.isArray(chatData)) { throw new Error("Invalid format."); } | |
| currentChatData = chatData; | |
| renderChat(); | |
| } catch (err) { | |
| displayError(`Failed to parse JSON file. <br><br>Error: ${err.message}`); | |
| } | |
| }; | |
| reader.onerror = () => displayError("Error reading the file."); | |
| reader.readAsText(file); | |
| } | |
| function renderChat() { | |
| outputDiv.innerHTML = ''; | |
| const toolTypes = new Set(); | |
| currentChatData.forEach((turn) => { | |
| if (turn.toolCalls) turn.toolCalls.forEach(tc => toolTypes.add(tc.name)); | |
| if (turn.parts) { | |
| turn.parts.forEach(p => { | |
| if (p.functionCall) toolTypes.add(p.functionCall.name); | |
| }); | |
| } | |
| }); | |
| // Populate Tool Filters | |
| toolTypeFiltersDiv.innerHTML = ''; | |
| state.expandedToolTypes.clear(); // Reset specifics | |
| if (toolTypes.size === 0) { | |
| toolTypeFiltersDiv.innerHTML = '<div style="font-style: italic; font-size: 0.8em; color: gray;">No tools used</div>'; | |
| } else { | |
| const toolNameMap = { | |
| 'run_shell_command': 'Shell', | |
| 'read_file': 'Read File', | |
| 'write_file': 'Write File', | |
| 'replace': 'Edit File', | |
| 'delegate_to_agent': 'Delegate' | |
| }; | |
| const sortedTypes = Array.from(toolTypes).sort(); | |
| sortedTypes.forEach(type => { | |
| const label = document.createElement('label'); | |
| label.className = 'checkbox-label'; | |
| const checkbox = document.createElement('input'); | |
| checkbox.type = 'checkbox'; | |
| checkbox.checked = false; // Default: Collapsed | |
| checkbox.dataset.toolType = type; | |
| checkbox.addEventListener('change', (e) => { | |
| if (e.target.checked) { | |
| state.expandedToolTypes.add(type); | |
| } else { | |
| state.expandedToolTypes.delete(type); | |
| } | |
| applyExpansion(); | |
| }); | |
| const displayName = toolNameMap[type] || type; | |
| label.appendChild(checkbox); | |
| label.appendChild(document.createTextNode(displayName)); | |
| toolTypeFiltersDiv.appendChild(label); | |
| }); | |
| } | |
| currentChatData.forEach((turn, index) => renderTurn(turn, index)); | |
| // Apply initial state (Collapsed) | |
| applyExpansion(); | |
| } | |
| function renderTurn(turn, index) { | |
| const turnDiv = document.createElement('div'); | |
| const role = (turn.role || turn.type || 'unknown').toLowerCase(); | |
| turnDiv.className = `turn ${role}`; | |
| // --- Header Construction --- | |
| const headerDiv = document.createElement('div'); | |
| headerDiv.className = 'turn-header'; | |
| // Role Label | |
| const roleSpan = document.createElement('span'); | |
| roleSpan.className = 'role-name'; | |
| roleSpan.textContent = role; | |
| headerDiv.appendChild(roleSpan); | |
| // Token & Model Info | |
| if (turn.model || turn.tokens) { | |
| const tokenInfo = document.createElement('span'); | |
| tokenInfo.className = 'token-info'; | |
| let infoParts = []; | |
| if (turn.model) infoParts.push(turn.model); | |
| if (turn.tokens) { | |
| if (turn.tokens.input) infoParts.push(`In: ${turn.tokens.input}`); | |
| if (turn.tokens.output) infoParts.push(`Out: ${turn.tokens.output}`); | |
| if (turn.tokens.total) infoParts.push(`Total: ${turn.tokens.total}`); | |
| } | |
| tokenInfo.textContent = infoParts.join(' | '); | |
| headerDiv.appendChild(tokenInfo); | |
| } else { | |
| // Push controls to right if no token info | |
| roleSpan.style.marginRight = 'auto'; | |
| } | |
| // Controls Container | |
| const controlsDiv = document.createElement('div'); | |
| controlsDiv.className = 'turn-controls'; | |
| // JSON Button | |
| const jsonBtn = document.createElement('button'); | |
| jsonBtn.className = 'json-btn'; | |
| jsonBtn.textContent = '{}'; | |
| jsonBtn.title = 'Show original JSON'; | |
| jsonBtn.addEventListener('click', () => { | |
| const jsonView = turnDiv.querySelector('.json-view'); | |
| jsonView.style.display = jsonView.style.display === 'none' ? 'block' : 'none'; | |
| }); | |
| controlsDiv.appendChild(jsonBtn); | |
| // Close Button | |
| const closeBtn = document.createElement('button'); | |
| closeBtn.className = 'close-turn-btn'; | |
| closeBtn.innerHTML = '×'; | |
| closeBtn.style.position = 'static'; | |
| closeBtn.addEventListener('click', () => turnDiv.style.display = 'none'); | |
| controlsDiv.appendChild(closeBtn); | |
| headerDiv.appendChild(controlsDiv); | |
| turnDiv.appendChild(headerDiv); | |
| // --- JSON View (Hidden) --- | |
| const jsonPre = document.createElement('pre'); | |
| jsonPre.className = 'json-view'; | |
| jsonPre.textContent = JSON.stringify(turn, null, 2); | |
| turnDiv.appendChild(jsonPre); | |
| // --- Unparsed Fields Warning --- | |
| // Known/Rendered keys | |
| const renderedKeys = new Set(['role', 'type', 'content', 'thoughts', 'toolCalls', 'parts', 'timestamp', 'id', 'tokens', 'model']); | |
| const unparsedKeys = Object.keys(turn).filter(k => !renderedKeys.has(k)); | |
| if (unparsedKeys.length > 0) { | |
| const warningDiv = document.createElement('div'); | |
| warningDiv.className = 'warning-block'; | |
| warningDiv.innerHTML = `<strong>⚠️ Unparsed Fields:</strong> ${escapeHtml(unparsedKeys.join(', '))}`; | |
| turnDiv.appendChild(warningDiv); | |
| } | |
| const contentWrapper = document.createElement('div'); | |
| contentWrapper.className = 'content-wrapper'; | |
| // Thoughts | |
| if (turn.thoughts && turn.thoughts.length > 0) { | |
| turnDiv.classList.add('has-thoughts'); | |
| const thoughtsDetails = document.createElement('details'); | |
| thoughtsDetails.className = 'thoughts'; | |
| // Note: 'open' attribute NOT set initially | |
| const summary = document.createElement('summary'); | |
| summary.textContent = 'THOUGHTS'; | |
| thoughtsDetails.appendChild(summary); | |
| const thoughtContent = document.createElement('div'); | |
| thoughtContent.className = 'thought-content'; | |
| turn.thoughts.forEach(thought => { | |
| const item = document.createElement('div'); | |
| item.className = 'thought-item'; | |
| item.innerHTML = `<div class="thought-subject">${escapeHtml(thought.subject)}</div> | |
| <div class="thought-description">${escapeHtml(thought.description)}</div>`; | |
| thoughtContent.appendChild(item); | |
| }); | |
| thoughtsDetails.appendChild(thoughtContent); | |
| contentWrapper.appendChild(thoughtsDetails); | |
| } | |
| // Content | |
| const text = turn.content || (turn.parts && turn.parts.find(p => p.text)?.text); | |
| if (text) { | |
| contentWrapper.innerHTML += getTextHtml(text, role); | |
| } | |
| // Tool Calls | |
| if (turn.toolCalls && turn.toolCalls.length > 0) { | |
| turn.toolCalls.forEach(call => { | |
| contentWrapper.innerHTML += getToolCallHtml(call); | |
| }); | |
| } | |
| // Legacy | |
| if (turn.parts && turn.parts.length > 0) { | |
| turn.parts.forEach(part => { | |
| if (part.functionCall) { | |
| const call = { | |
| name: part.functionCall.name, | |
| args: part.functionCall.args, | |
| result: [] | |
| }; | |
| contentWrapper.innerHTML += getToolCallHtml(call); | |
| } | |
| if (part.functionResponse) { | |
| const response = part.functionResponse; | |
| const output = JSON.stringify(response.response, null, 2); | |
| const prettyOutput = `<pre><code>${escapeHtml(output)}</code></pre>`; | |
| contentWrapper.innerHTML += `<details class="tool-response-details tool-entry" data-tool-type="${escapeHtml(response.name)}"><summary>TOOL RESPONSE for ${escapeHtml(response.name)}</summary><div class="tool-content">${prettyOutput}</div></details>`; | |
| } | |
| }); | |
| } | |
| turnDiv.appendChild(contentWrapper); | |
| outputDiv.appendChild(turnDiv); | |
| } | |
| function getToolCallHtml(call) { | |
| const name = call.name; | |
| const displayName = call.displayName || name; | |
| const args = call.args; | |
| let summaryText = `TOOL CALL: ${escapeHtml(displayName)}`; | |
| let contentHtml = ''; | |
| if (name === 'run_shell_command') { | |
| summaryText = `SHELL: ${escapeHtml(args.command)}`; | |
| } else if (name === 'read_file') { | |
| summaryText = `READ FILE: ${escapeHtml(args.file_path)}`; | |
| } else if (name === 'write_file') { | |
| summaryText = `WRITE FILE: ${escapeHtml(args.file_path)}`; | |
| contentHtml += `<div class="file-block"> | |
| <div class="file-header">Writing to: ${escapeHtml(args.file_path)}</div> | |
| <pre class="file-content"><code>${escapeHtml(args.content)}</code></pre> | |
| </div>`; | |
| } else if (name === 'replace') { | |
| summaryText = `EDIT FILE: ${escapeHtml(args.file_path)}`; | |
| contentHtml += `<div class="instruction-text">Instruction: ${escapeHtml(args.instruction)}</div>`; | |
| contentHtml += `<div class="diff-container"> | |
| <div class="diff-block diff-old"> | |
| <div class="diff-header">Original (Old String)</div> | |
| <pre class="diff-content"><code>${escapeHtml(args.old_string)}</code></pre> | |
| </div> | |
| <div class="diff-block diff-new"> | |
| <div class="diff-header">Replacement (New String)</div> | |
| <pre class="diff-content"><code>${escapeHtml(args.new_string)}</code></pre> | |
| </div> | |
| </div>`; | |
| } else { | |
| contentHtml += `<pre><code>${escapeHtml(JSON.stringify(args, null, 2))}</code></pre>`; | |
| } | |
| // Note: removed 'open' attribute | |
| const callHtml = `<details class="tool-call-details tool-entry" data-tool-type="${escapeHtml(name)}"><summary>${summaryText}</summary><div class="tool-content">${contentHtml}</div></details>`; | |
| let responseHtml = ''; | |
| if (call.resultDisplay) { | |
| responseHtml += `<div class="terminal-block"> | |
| <div class="terminal-command">Result Display</div> | |
| <pre>${escapeHtml(call.resultDisplay)}</pre> | |
| </div>`; | |
| } else if (call.result && Array.isArray(call.result)) { | |
| call.result.forEach(res => { | |
| if (res.functionResponse) { | |
| const output = res.functionResponse.response.output; | |
| if (name === 'run_shell_command') { | |
| responseHtml += `<div class="terminal-block"> | |
| <div class="terminal-command">${escapeHtml(args.command)}</div> | |
| <pre>${escapeHtml(output)}</pre> | |
| </div>`; | |
| } else if (name === 'read_file') { | |
| responseHtml += `<div class="file-block"> | |
| <div class="file-header">Content of ${escapeHtml(args.file_path)}</div> | |
| <pre class="file-content"><code>${escapeHtml(output)}</code></pre> | |
| </div>`; | |
| } else { | |
| const prettyOutput = (typeof output === 'string') ? output : JSON.stringify(res.functionResponse.response, null, 2); | |
| // Note: removed 'open' attribute | |
| responseHtml += `<details class="tool-response-details tool-entry" data-tool-type="${escapeHtml(name)}"><summary>Response</summary><div class="tool-content"><pre><code>${escapeHtml(prettyOutput)}</code></pre></div></details>`; | |
| } | |
| } | |
| }); | |
| } | |
| if (responseHtml) { | |
| if (responseHtml.startsWith('<details')) { | |
| return callHtml + responseHtml; | |
| } else { | |
| // Embedded response | |
| return `<details class="tool-call-details tool-entry" data-tool-type="${escapeHtml(name)}"> | |
| <summary>${summaryText}</summary> | |
| <div class="tool-content"> | |
| ${contentHtml} | |
| <div class="tool-response-details tool-entry" data-tool-type="${escapeHtml(name)}"> | |
| ${responseHtml} | |
| </div> | |
| </div> | |
| </details>`; | |
| } | |
| } | |
| return callHtml; | |
| } | |
| function getTextHtml(text, role) { | |
| if (role === 'model' || role === 'gemini') { | |
| const rawHtml = marked.parse(text); | |
| return `<div class="markdown-container" data-raw-markdown="${escapeHtml(text)}"> | |
| <div class="button-group"> | |
| <button class="copy-btn copy-markdown-btn" title="Copy raw Markdown">Copy MD</button> | |
| <button class="copy-btn copy-html-btn" title="Copy rendered HTML">Copy HTML</button> | |
| </div> | |
| <div class="markdown-body">${DOMPurify.sanitize(rawHtml)}</div> | |
| </div>`; | |
| } else { | |
| return `<pre><code>${escapeHtml(text)}</code></pre>`; | |
| } | |
| } | |
| function displayError(message) { | |
| outputDiv.innerHTML = `<div class="error">${message}</div>`; | |
| } | |
| function escapeHtml(unsafe) { | |
| if (typeof unsafe !== 'string') return ''; | |
| return unsafe.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); | |
| } | |
| fileInput.addEventListener('change', handleFileSelect); | |
| reloadBtn.addEventListener('click', () => loadFile(currentFile)); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment