@longshot/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,321 @@
1
+ import { html as diff2htmlHtml } from "diff2html";
2
+ import { ColorSchemeType } from "diff2html/lib/types.js";
3
+ import { marked } from "marked";
4
+ import { layout } from "./layout.js";
5
+ export function diffPage(diffId, diffContent, task, taskId, chatMessages = [], chatPending = false, acceptanceCriteria, agentSummary, checks) {
6
+ const diffHtml = diff2htmlHtml(diffContent, {
7
+ drawFileList: true,
8
+ matching: "lines",
9
+ outputFormat: "line-by-line",
10
+ colorScheme: ColorSchemeType.DARK,
11
+ });
12
+ const verifyBtn = taskId
13
+ ? `<a href="/task/${taskId}/verify?diffId=${diffId}" class="btn btn-verify">Verify</a>`
14
+ : "";
15
+ // Chat section — only for task diffs
16
+ const chatHtml = taskId
17
+ ? `
18
+ <div class="diff-chat-section">
19
+ <div class="task-chat">
20
+ <div class="messages" id="diff-messages">
21
+ ${chatMessages.map(renderMessage).join("\n")}
22
+ ${chatPending ? `<div class="message message-assistant" id="thinking-indicator">
23
+ <div class="message-role">Claude</div>
24
+ <div class="message-content"><em>Thinking...</em></div>
25
+ </div>` : ""}
26
+ </div>
27
+ <div class="chat-input-area">
28
+ <form class="chat-input" id="diff-chat-form">
29
+ <textarea
30
+ name="message"
31
+ id="diff-message-input"
32
+ placeholder="Describe what needs fixing..."
33
+ rows="2"
34
+ ></textarea>
35
+ <div class="task-chat-buttons">
36
+ <button type="button" id="diff-fix-btn" class="btn btn-primary">Fix</button>
37
+ </div>
38
+ </form>
39
+ </div>
40
+ </div>
41
+ </div>`
42
+ : "";
43
+ // Acceptance criteria section
44
+ const criteriaHtml = acceptanceCriteria
45
+ ? `<details class="diff-criteria">
46
+ <summary>Acceptance Criteria</summary>
47
+ <div class="diff-criteria-content spec-content">${marked.parse(acceptanceCriteria, { async: false })}</div>
48
+ </details>`
49
+ : "";
50
+ // Change summary section
51
+ const summaryHtml = agentSummary
52
+ ? `<div class="diff-summary">
53
+ <div class="diff-summary-text"><strong>${escapeHtml(agentSummary.summary)}</strong></div>
54
+ ${agentSummary.filesChanged.length > 0
55
+ ? `<div class="diff-summary-files">${agentSummary.filesChanged.map((f) => `<span class="diff-summary-file">${escapeHtml(f)}</span>`).join("")}</div>`
56
+ : ""}
57
+ ${agentSummary.actions.length > 0
58
+ ? `<ul class="diff-summary-actions">${agentSummary.actions.map((a) => `<li>${escapeHtml(a)}</li>`).join("")}</ul>`
59
+ : ""}
60
+ </div>`
61
+ : "";
62
+ // Checks section
63
+ let checksHtml = "";
64
+ if (checks && (checks.testsRun || (checks.todoItems && checks.todoItems.length > 0))) {
65
+ const testStatusHtml = checks.testsRun
66
+ ? `<div class="diff-check-item">
67
+ <span class="diff-check-indicator ${checks.testsPassed ? "check-pass" : "check-fail"}">${checks.testsPassed ? "PASS" : "FAIL"}</span>
68
+ <span>Tests</span>
69
+ </div>`
70
+ : "";
71
+ const todoHtml = checks.todoItems && checks.todoItems.length > 0
72
+ ? `<div class="diff-check-todos">${checks.todoItems.map((t) => `<div class="diff-check-todo ${t.status === "completed" ? "todo-done" : ""}">
73
+ <span class="todo-check">${t.status === "completed" ? "&#10003;" : "&#9711;"}</span>
74
+ <span>${escapeHtml(t.content)}</span>
75
+ </div>`).join("")}</div>`
76
+ : "";
77
+ checksHtml = `<details class="diff-checks" open>
78
+ <summary>Checks</summary>
79
+ <div class="diff-checks-content">
80
+ ${testStatusHtml}
81
+ ${todoHtml}
82
+ </div>
83
+ </details>`;
84
+ }
85
+ const body = `
86
+ <div class="diff-container">
87
+ <div class="diff-header">
88
+ <h1>Review Changes</h1>
89
+ <p class="diff-task">${escapeHtml(task)}</p>
90
+ </div>
91
+ ${summaryHtml}
92
+ <div class="diff-actions diff-actions-top">
93
+ ${verifyBtn}
94
+ <button class="btn btn-approve" onclick="reviewDiff('${diffId}', 'approve')">Approve</button>
95
+ <button class="btn btn-reject" onclick="reviewDiff('${diffId}', 'reject')">Reject</button>
96
+ </div>
97
+ ${criteriaHtml}
98
+ ${checksHtml}
99
+ ${chatHtml}
100
+ <div class="diff-content">
101
+ ${diffHtml}
102
+ </div>
103
+ <div class="diff-actions diff-actions-bottom">
104
+ ${verifyBtn}
105
+ <button class="btn btn-approve" onclick="reviewDiff('${diffId}', 'approve')">Approve</button>
106
+ <button class="btn btn-reject" onclick="reviewDiff('${diffId}', 'reject')">Reject</button>
107
+ </div>
108
+ </div>
109
+ <script>
110
+ async function reviewDiff(id, action) {
111
+ const btns = document.querySelectorAll('.diff-actions .btn');
112
+ btns.forEach(b => { b.disabled = true; });
113
+
114
+ // Hide action bars and chat section
115
+ const actionBars = document.querySelectorAll('.diff-actions');
116
+ const chatSection = document.querySelector('.diff-chat' + '-section');
117
+ actionBars.forEach(el => { el.style.display = 'none'; });
118
+ if (chatSection) chatSection.style.display = 'none';
119
+
120
+ // Show status banner
121
+ const statusBanner = document.createElement('div');
122
+ statusBanner.className = 'agent-done';
123
+ statusBanner.id = 'review-status-banner';
124
+ statusBanner.style.cssText = 'text-align:center;padding:16px;margin:16px';
125
+ statusBanner.textContent = action === 'approve' ? 'Approving...' : 'Rejecting...';
126
+ document.querySelector('.diff-container').prepend(statusBanner);
127
+
128
+ try {
129
+ const res = await fetch('/diff/' + id + '/' + action, { method: 'POST' });
130
+ const data = await res.json();
131
+ if (data.ok) {
132
+ if (data.specUpdate) {
133
+ statusBanner.textContent = 'Approved \\u2014 updating spec...';
134
+ setTimeout(function() { window.location.href = '/tasks'; }, 1500);
135
+ } else {
136
+ window.location.href = '/chat';
137
+ }
138
+ } else {
139
+ // Restore everything on error
140
+ statusBanner.remove();
141
+ actionBars.forEach(el => { el.style.display = ''; });
142
+ if (chatSection) chatSection.style.display = '';
143
+ btns.forEach(b => { b.disabled = false; });
144
+ alert('Error: ' + (data.error || 'unknown'));
145
+ }
146
+ } catch (err) {
147
+ // Restore everything on network error
148
+ statusBanner.remove();
149
+ actionBars.forEach(el => { el.style.display = ''; });
150
+ if (chatSection) chatSection.style.display = '';
151
+ btns.forEach(b => { b.disabled = false; });
152
+ alert('Error: network failure');
153
+ }
154
+ }
155
+
156
+ ${taskId ? diffChatScript(taskId, diffId, chatMessages.length, chatPending) : ""}
157
+ </script>`;
158
+ return layout("Diff", "chat", body, `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/diff2html/bundles/css/diff2html.min.css">`);
159
+ }
160
+ function diffChatScript(taskId, diffId, messageCount, chatPending) {
161
+ return `
162
+ (function() {
163
+ const taskId = ${taskId};
164
+ const diffId = '${diffId}';
165
+ const form = document.getElementById('diff-chat-form');
166
+ const input = document.getElementById('diff-message-input');
167
+ const messagesEl = document.getElementById('diff-messages');
168
+ const fixBtn = document.getElementById('diff-fix-btn');
169
+ let messageCount = ${messageCount};
170
+ let polling = ${chatPending ? "true" : "false"};
171
+
172
+ function scrollToBottom() {
173
+ messagesEl.scrollTop = messagesEl.scrollHeight;
174
+ }
175
+ scrollToBottom();
176
+
177
+ function escapeHtml(str) {
178
+ const div = document.createElement('div');
179
+ div.textContent = str;
180
+ return div.innerHTML;
181
+ }
182
+
183
+ input.addEventListener('input', () => {
184
+ input.style.height = 'auto';
185
+ input.style.height = Math.min(input.scrollHeight, 120) + 'px';
186
+ });
187
+
188
+ input.addEventListener('keydown', (e) => {
189
+ if (e.key === 'Enter' && !e.shiftKey) {
190
+ e.preventDefault();
191
+ fixBtn.click();
192
+ }
193
+ });
194
+
195
+ function showThinking() {
196
+ let indicator = document.getElementById('thinking-indicator');
197
+ if (!indicator) {
198
+ indicator = document.createElement('div');
199
+ indicator.id = 'thinking-indicator';
200
+ indicator.className = 'message message-assistant';
201
+ indicator.innerHTML = '<div class="message-role">Claude</div><div class="message-content"><em>Thinking...</em></div>';
202
+ messagesEl.appendChild(indicator);
203
+ }
204
+ scrollToBottom();
205
+ }
206
+
207
+ function removeThinking() {
208
+ const indicator = document.getElementById('thinking-indicator');
209
+ if (indicator) indicator.remove();
210
+ }
211
+
212
+ async function pollForResponse() {
213
+ if (polling) return;
214
+ polling = true;
215
+ showThinking();
216
+
217
+ const poll = async () => {
218
+ try {
219
+ const resp = await fetch('/api/tasks/' + taskId + '/chat?after=' + messageCount);
220
+ const data = await resp.json();
221
+
222
+ if (data.messages.length > 0) {
223
+ removeThinking();
224
+ for (const msg of data.messages) {
225
+ const div = document.createElement('div');
226
+ div.className = 'message ' + (msg.role === 'user' ? 'message-user' : 'message-assistant');
227
+ div.innerHTML = '<div class="message-role">' + (msg.role === 'user' ? 'You' : 'Claude') + '</div><div class="message-content">' + (msg.html || msg.content) + '</div>';
228
+ messagesEl.appendChild(div);
229
+ }
230
+ messageCount = data.total;
231
+ scrollToBottom();
232
+ }
233
+
234
+ if (data.pending) {
235
+ showThinking();
236
+ setTimeout(poll, 2000);
237
+ } else {
238
+ removeThinking();
239
+ polling = false;
240
+ fixBtn.disabled = false;
241
+ fixBtn.textContent = 'Fix';
242
+ }
243
+ } catch {
244
+ polling = false;
245
+ removeThinking();
246
+ fixBtn.disabled = false;
247
+ fixBtn.textContent = 'Fix';
248
+ }
249
+ };
250
+
251
+ setTimeout(poll, 2000);
252
+ }
253
+
254
+ // If already pending on page load, start polling
255
+ if (polling) {
256
+ polling = false;
257
+ pollForResponse();
258
+ }
259
+
260
+ fixBtn.addEventListener('click', async () => {
261
+ const message = input.value.trim();
262
+ if (!message || polling) return;
263
+
264
+ // Add user message to DOM
265
+ const userDiv = document.createElement('div');
266
+ userDiv.className = 'message message-user';
267
+ userDiv.innerHTML = '<div class="message-role">You</div><div class="message-content">[Fix] ' + escapeHtml(message) + '</div>';
268
+ messagesEl.appendChild(userDiv);
269
+
270
+ input.value = '';
271
+ input.style.height = 'auto';
272
+ fixBtn.disabled = true;
273
+ fixBtn.textContent = 'Starting...';
274
+ scrollToBottom();
275
+
276
+ try {
277
+ // Save message to task conversation first
278
+ await fetch('/api/tasks/' + taskId + '/chat', {
279
+ method: 'POST',
280
+ headers: { 'Content-Type': 'application/json' },
281
+ body: JSON.stringify({ message: '[Fix] ' + message }),
282
+ });
283
+
284
+ // Enqueue fix via the fix endpoint
285
+ const resp = await fetch('/api/tasks/' + taskId + '/fix', {
286
+ method: 'POST',
287
+ headers: { 'Content-Type': 'application/json' },
288
+ body: JSON.stringify({ message, diffId }),
289
+ });
290
+ const data = await resp.json();
291
+ if (data.runId) {
292
+ window.location.href = '/agent/' + data.runId;
293
+ } else if (data.ok) {
294
+ // Queued — go to tasks to see progress
295
+ window.location.href = '/tasks';
296
+ } else {
297
+ fixBtn.disabled = false;
298
+ fixBtn.textContent = 'Fix';
299
+ }
300
+ } catch {
301
+ fixBtn.disabled = false;
302
+ fixBtn.textContent = 'Fix';
303
+ }
304
+ });
305
+ })();`;
306
+ }
307
+ function renderMessage(msg) {
308
+ const isUser = msg.role === "user";
309
+ const rendered = marked.parse(msg.content, { async: false });
310
+ return `<div class="message ${isUser ? "message-user" : "message-assistant"}">
311
+ <div class="message-role">${isUser ? "You" : "Claude"}</div>
312
+ <div class="message-content">${rendered}</div>
313
+ </div>`;
314
+ }
315
+ function escapeHtml(str) {
316
+ return str
317
+ .replace(/&/g, "&amp;")
318
+ .replace(/</g, "&lt;")
319
+ .replace(/>/g, "&gt;")
320
+ .replace(/"/g, "&quot;");
321
+ }
@@ -0,0 +1,124 @@
1
+ import { layout } from "./layout.js";
2
+ const typeIcons = {
3
+ spec_update: "S",
4
+ agent_spawn: "A",
5
+ agent_complete: "C",
6
+ checkpoint: "OK",
7
+ rejection: "X",
8
+ action: "i",
9
+ task_created: "T",
10
+ task_complete: "T",
11
+ };
12
+ const typeColors = {
13
+ spec_update: "type-spec",
14
+ agent_spawn: "type-agent",
15
+ agent_complete: "type-agent",
16
+ checkpoint: "type-checkpoint",
17
+ rejection: "type-rejection",
18
+ action: "type-action",
19
+ task_created: "type-spec",
20
+ task_complete: "type-checkpoint",
21
+ };
22
+ function extractRunId(details) {
23
+ if (!details)
24
+ return null;
25
+ const match = details.match(/Run:\s*(\S+)/);
26
+ return match ? match[1] : null;
27
+ }
28
+ function renderEntry(entry) {
29
+ const time = new Date(entry.timestamp).toLocaleString();
30
+ const icon = typeIcons[entry.type] || "?";
31
+ const colorClass = typeColors[entry.type] || "";
32
+ const runId = extractRunId(entry.details);
33
+ return `<div class="history-entry">
34
+ <div class="history-marker ${colorClass}">${icon}</div>
35
+ <div class="history-body">
36
+ <div class="history-summary">${escapeHtml(entry.summary)}</div>
37
+ <div class="history-time">${time}</div>
38
+ ${entry.details ? `<div class="history-details">${escapeHtml(entry.details)}</div>` : ""}
39
+ ${runId ? `<a href="/run/${encodeURIComponent(runId)}" class="history-run-link">View run log →</a>` : ""}
40
+ </div>
41
+ </div>`;
42
+ }
43
+ export function historyPage(entries) {
44
+ const reversed = [...entries].reverse();
45
+ const entriesHtml = reversed.length
46
+ ? reversed.map(renderEntry).join("\n")
47
+ : '<p class="empty-state">No history yet.</p>';
48
+ const body = `
49
+ <div class="history-container">
50
+ <div class="history-header">
51
+ <h1>History</h1>
52
+ <button class="btn btn-small" onclick="location.reload()">Refresh</button>
53
+ </div>
54
+ <div class="history-timeline" id="history-timeline">
55
+ ${entriesHtml}
56
+ </div>
57
+ </div>
58
+ <script>
59
+ let historyTotal = ${entries.length};
60
+
61
+ const typeIcons = ${JSON.stringify(typeIcons)};
62
+ const typeColors = ${JSON.stringify(typeColors)};
63
+
64
+ function escapeHtmlClient(str) {
65
+ const div = document.createElement('div');
66
+ div.textContent = str;
67
+ return div.innerHTML;
68
+ }
69
+
70
+ function extractRunIdClient(details) {
71
+ if (!details) return null;
72
+ const match = details.match(/Run:\\s*(\\S+)/);
73
+ return match ? match[1] : null;
74
+ }
75
+
76
+ function renderEntryClient(entry) {
77
+ const time = new Date(entry.timestamp).toLocaleString();
78
+ const icon = typeIcons[entry.type] || '?';
79
+ const colorClass = typeColors[entry.type] || '';
80
+ const runId = extractRunIdClient(entry.details);
81
+
82
+ return '<div class="history-entry">' +
83
+ '<div class="history-marker ' + colorClass + '">' + icon + '</div>' +
84
+ '<div class="history-body">' +
85
+ '<div class="history-summary">' + escapeHtmlClient(entry.summary) + '</div>' +
86
+ '<div class="history-time">' + time + '</div>' +
87
+ (entry.details ? '<div class="history-details">' + escapeHtmlClient(entry.details) + '</div>' : '') +
88
+ (runId ? '<a href="/run/' + encodeURIComponent(runId) + '" class="history-run-link">View run log \\u2192</a>' : '') +
89
+ '</div>' +
90
+ '</div>';
91
+ }
92
+
93
+ async function pollHistory() {
94
+ try {
95
+ const resp = await fetch('/api/history');
96
+ if (!resp.ok) return;
97
+ const data = await resp.json();
98
+
99
+ if (data.total !== historyTotal) {
100
+ historyTotal = data.total;
101
+ const timeline = document.getElementById('history-timeline');
102
+ if (timeline) {
103
+ const reversed = [...data.entries].reverse();
104
+ if (reversed.length) {
105
+ timeline.innerHTML = reversed.map(renderEntryClient).join('');
106
+ } else {
107
+ timeline.innerHTML = '<p class="empty-state">No history yet.</p>';
108
+ }
109
+ }
110
+ }
111
+ } catch {}
112
+ }
113
+
114
+ setInterval(pollHistory, 5000);
115
+ </script>`;
116
+ return layout("History", "history", body);
117
+ }
118
+ function escapeHtml(str) {
119
+ return str
120
+ .replace(/&/g, "&amp;")
121
+ .replace(/</g, "&lt;")
122
+ .replace(/>/g, "&gt;")
123
+ .replace(/"/g, "&quot;");
124
+ }
@@ -0,0 +1,121 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import * as projects from "../projects.js";
5
+ function getCurrentBranchSync() {
6
+ try {
7
+ const cwd = projects.getProjectRoot();
8
+ if (!existsSync(join(cwd, ".git")))
9
+ return "";
10
+ const result = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
11
+ cwd,
12
+ encoding: "utf-8",
13
+ timeout: 2000,
14
+ });
15
+ return result.trim();
16
+ }
17
+ catch {
18
+ return "";
19
+ }
20
+ }
21
+ export function layout(title, activeTab, body, extraHead) {
22
+ const isMulti = projects.isMultiProject();
23
+ const currentProject = projects.getCurrentProjectName();
24
+ const allProjects = isMulti ? projects.readProjects() : [];
25
+ const currentBranch = getCurrentBranchSync();
26
+ const projectSelector = isMulti
27
+ ? `<div class="project-selector">
28
+ <button class="project-selector-btn" onclick="document.getElementById('project-dropdown').classList.toggle('open')">${escapeHtml(currentProject || "No project")}</button>
29
+ <div id="project-dropdown" class="project-dropdown">
30
+ ${allProjects.map((p) => `<button class="project-option${p.name === currentProject ? " active" : ""}" onclick="switchProject('${escapeAttr(p.name)}')">${escapeHtml(p.name)}</button>`).join("")}
31
+ <div class="project-dropdown-divider"></div>
32
+ <button class="project-option project-option-action" onclick="showNewProjectForm()">+ New Project</button>
33
+ <button class="project-option project-option-action" onclick="showAdoptForm()">+ Adopt Existing</button>
34
+ <div id="new-project-form" class="project-inline-form" style="display:none">
35
+ <input type="text" id="new-project-name" placeholder="project-name" class="project-inline-input" />
36
+ <button onclick="createProject()" class="btn-small btn-primary">Create</button>
37
+ </div>
38
+ <div id="adopt-form" class="project-inline-form" style="display:none">
39
+ <div id="adopt-list" class="adopt-list">Loading...</div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ <script>
44
+ function switchProject(name) {
45
+ fetch('/api/projects/current', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})
46
+ .then(r => { if(r.ok) location.reload(); });
47
+ }
48
+ function showNewProjectForm() {
49
+ document.getElementById('new-project-form').style.display = 'flex';
50
+ document.getElementById('adopt-form').style.display = 'none';
51
+ document.getElementById('new-project-name').focus();
52
+ }
53
+ function createProject() {
54
+ const name = document.getElementById('new-project-name').value.trim();
55
+ if (!name) return;
56
+ fetch('/api/projects', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})
57
+ .then(r => { if(r.ok) location.reload(); else r.json().then(d => alert(d.error)); });
58
+ }
59
+ function showAdoptForm() {
60
+ document.getElementById('adopt-form').style.display = 'block';
61
+ document.getElementById('new-project-form').style.display = 'none';
62
+ fetch('/api/projects/available').then(r=>r.json()).then(d => {
63
+ const list = document.getElementById('adopt-list');
64
+ if (!d.available || d.available.length === 0) {
65
+ list.textContent = 'No available subdirectories';
66
+ return;
67
+ }
68
+ list.innerHTML = d.available.map(name =>
69
+ '<button class="project-option" onclick="adoptProject(\\'' + name + '\\')">' + name + '</button>'
70
+ ).join('');
71
+ });
72
+ }
73
+ function adoptProject(name) {
74
+ fetch('/api/projects/adopt', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})})
75
+ .then(r => { if(r.ok) location.reload(); else r.json().then(d => alert(d.error)); });
76
+ }
77
+ document.addEventListener('click', function(e) {
78
+ const dd = document.getElementById('project-dropdown');
79
+ const btn = document.querySelector('.project-selector-btn');
80
+ if (dd && !dd.contains(e.target) && e.target !== btn) {
81
+ dd.classList.remove('open');
82
+ }
83
+ });
84
+ </script>`
85
+ : "";
86
+ const isDefaultBranch = !currentBranch || currentBranch === "master" || currentBranch === "main";
87
+ const branchIndicator = currentBranch
88
+ ? `<a href="/branches" class="branch-indicator${isDefaultBranch ? "" : " branch-indicator-feature"}">${escapeHtml(currentBranch)}</a>`
89
+ : "";
90
+ return `<!DOCTYPE html>
91
+ <html lang="en">
92
+ <head>
93
+ <meta charset="UTF-8">
94
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
95
+ <title>${title} - longshot</title>
96
+ ${extraHead || ""}
97
+ <link rel="stylesheet" href="/style.css">
98
+ <script src="https://unpkg.com/marked@17.0.2/marked.min.js"></script>
99
+ </head>
100
+ <body>
101
+ <nav class="nav">
102
+ ${projectSelector}
103
+ <a href="/chat" class="nav-tab${activeTab === "chat" ? " active" : ""}">Chat</a>
104
+ <a href="/tasks" class="nav-tab${activeTab === "tasks" ? " active" : ""}">Tasks</a>
105
+ <a href="/services" class="nav-tab${activeTab === "services" ? " active" : ""}">Svc</a>
106
+ <a href="/spec" class="nav-tab${activeTab === "spec" ? " active" : ""}">Spec</a>
107
+ <a href="/history" class="nav-tab${activeTab === "history" ? " active" : ""}">History</a>
108
+ </nav>
109
+ ${branchIndicator}
110
+ <main class="main${branchIndicator ? " main-with-branch" : ""}">
111
+ ${body}
112
+ </main>
113
+ </body>
114
+ </html>`;
115
+ }
116
+ function escapeHtml(s) {
117
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
118
+ }
119
+ function escapeAttr(s) {
120
+ return s.replace(/'/g, "\\'").replace(/"/g, "&quot;");
121
+ }
@@ -0,0 +1,92 @@
1
+ import { layout } from "./layout.js";
2
+ function escapeHtml(str) {
3
+ return str
4
+ .replace(/&/g, "&amp;")
5
+ .replace(/</g, "&lt;")
6
+ .replace(/>/g, "&gt;")
7
+ .replace(/"/g, "&quot;");
8
+ }
9
+ function truncate(str, max) {
10
+ if (str.length <= max)
11
+ return str;
12
+ return str.slice(0, max) + "…";
13
+ }
14
+ function renderEvent(event) {
15
+ const time = new Date(event.ts).toLocaleTimeString();
16
+ switch (event.type) {
17
+ case "user_message":
18
+ return `<div class="run-event run-event-user">
19
+ <div class="run-event-marker">U</div>
20
+ <div class="run-event-body">
21
+ <div class="run-event-label">User message <span class="run-event-time">${time}</span></div>
22
+ <div class="run-event-content">${escapeHtml(truncate(event.content || "", 500))}</div>
23
+ </div>
24
+ </div>`;
25
+ case "text":
26
+ return `<div class="run-event run-event-text">
27
+ <div class="run-event-marker">T</div>
28
+ <div class="run-event-body">
29
+ <div class="run-event-label">Text <span class="run-event-time">${time}</span></div>
30
+ <div class="run-event-content">${escapeHtml(truncate(event.content || "", 500))}</div>
31
+ </div>
32
+ </div>`;
33
+ case "tool_use":
34
+ return `<div class="run-event run-event-tool-use">
35
+ <div class="run-event-marker">⚙</div>
36
+ <div class="run-event-body">
37
+ <div class="run-event-label">Tool: ${escapeHtml(event.name || "unknown")} <span class="run-event-time">${time}</span></div>
38
+ ${event.input ? `<details class="run-event-details"><summary>Input</summary><pre>${escapeHtml(JSON.stringify(event.input, null, 2).slice(0, 2000))}</pre></details>` : ""}
39
+ </div>
40
+ </div>`;
41
+ case "tool_result":
42
+ return `<div class="run-event run-event-tool-result">
43
+ <div class="run-event-marker">✓</div>
44
+ <div class="run-event-body">
45
+ <div class="run-event-label">Result <span class="run-event-time">${time}</span></div>
46
+ ${event.content ? `<details class="run-event-details"><summary>Output</summary><pre>${escapeHtml(truncate(event.content, 2000))}</pre></details>` : ""}
47
+ </div>
48
+ </div>`;
49
+ case "error":
50
+ return `<div class="run-event run-event-error">
51
+ <div class="run-event-marker">!</div>
52
+ <div class="run-event-body">
53
+ <div class="run-event-label">Error <span class="run-event-time">${time}</span></div>
54
+ <div class="run-event-content">${escapeHtml(event.message || "")}</div>
55
+ </div>
56
+ </div>`;
57
+ case "done":
58
+ return `<div class="run-event run-event-done">
59
+ <div class="run-event-marker">●</div>
60
+ <div class="run-event-body">
61
+ <div class="run-event-label">Done <span class="run-event-time">${time}</span></div>
62
+ <div class="run-event-content">Exit code: ${event.exitCode ?? "unknown"}</div>
63
+ </div>
64
+ </div>`;
65
+ default:
66
+ return `<div class="run-event">
67
+ <div class="run-event-marker">?</div>
68
+ <div class="run-event-body">
69
+ <div class="run-event-label">${escapeHtml(event.type)} <span class="run-event-time">${time}</span></div>
70
+ </div>
71
+ </div>`;
72
+ }
73
+ }
74
+ export function runPage(runId, events) {
75
+ const eventsHtml = events.length
76
+ ? events.map(renderEvent).join("\n")
77
+ : '<p class="empty-state">No events recorded.</p>';
78
+ const body = `
79
+ <div class="run-container">
80
+ <div class="run-header">
81
+ <h1>Run Log</h1>
82
+ <div class="run-id">${escapeHtml(runId)}</div>
83
+ </div>
84
+ <div class="run-timeline">
85
+ ${eventsHtml}
86
+ </div>
87
+ <div class="run-footer">
88
+ <a href="/history" class="btn btn-small">← History</a>
89
+ </div>
90
+ </div>`;
91
+ return layout("Run", "history", body);
92
+ }