@teddysc/claude-run 0.11.0 → 0.13.0

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.
package/README.md CHANGED
@@ -30,8 +30,22 @@ bun install -g @teddysc/claude-run@latest
30
30
 
31
31
  The browser will open automatically at http://localhost:12001.
32
32
 
33
+ ## Specifications
34
+
35
+ See [spec.md](spec.md)
36
+
33
37
  ## Changelog
34
38
 
39
+ ### 0.13.0
40
+ - Display message count, duration, and JSONL size in conversation header
41
+ - Sidebar shows compact message count, duration, and JSONL size per session
42
+ - Add sorting options for sessions (by duration or JSONL size, asc/desc)
43
+
44
+ ### 0.12.0
45
+ - Rename sessions directly from the web UI
46
+ - Searchable project dropdown with fuzzy matching on name and path
47
+ - Session summary caching for improved performance
48
+
35
49
  ### 0.11.0
36
50
  - Display session metadata (model, start/end times) in conversation header
37
51
  - Show full project path with ~ shorthand in header and sidebar tooltips
@@ -87,10 +101,13 @@ The browser will open automatically at http://localhost:12001.
87
101
  - **Truncation options** - Limit long tool outputs by line count or character count
88
102
  - **Batch export** - Export multiple conversations at once
89
103
  - **Real-time streaming** - Watch conversations update live as Claude responds
90
- - **Filter by project** - Focus on specific projects
104
+ - **Filter by project** - Searchable dropdown with fuzzy matching on project name and path
91
105
  - **Resume sessions** - Copy the resume command to continue any conversation in your terminal
92
106
  - **Copy messages** - Click the copy button on any message to copy its text content
93
- - **Session metadata** - See model and start/end times in the conversation header
107
+ - **Session metadata** - See model, message count, duration, and JSONL size in the conversation header
108
+ - **Sidebar stats** - Compact message count, duration, and JSONL size per session
109
+ - **Sidebar sorting** - Sort sessions by duration or JSONL size (asc/desc)
110
+ - **Rename sessions** - Click the conversation title to rename a session
94
111
  - **Collapsible sidebar** - Maximize your viewing area
95
112
  - **Dark mode** - Easy on the eyes
96
113
  - **Clean UI** - Familiar chat interface with collapsible tool calls
package/dist/index.js CHANGED
@@ -88,6 +88,8 @@ function mergeSidechainText(messages, sidechainMap) {
88
88
  var claudeDir = join(homedir(), ".claude");
89
89
  var projectsDir = join(claudeDir, "projects");
90
90
  var fileIndex = /* @__PURE__ */ new Map();
91
+ var summaryCache = /* @__PURE__ */ new Map();
92
+ var sessionStatsCache = /* @__PURE__ */ new Map();
91
93
  var historyCache = null;
92
94
  var pendingRequests = /* @__PURE__ */ new Map();
93
95
  function initStorage(dir) {
@@ -102,6 +104,132 @@ function invalidateHistoryCache() {
102
104
  }
103
105
  function addToFileIndex(sessionId, filePath) {
104
106
  fileIndex.set(sessionId, filePath);
107
+ summaryCache.delete(sessionId);
108
+ sessionStatsCache.delete(sessionId);
109
+ }
110
+ function invalidateSessionStats(sessionId) {
111
+ sessionStatsCache.delete(sessionId);
112
+ }
113
+ function parseMessageTimestamp(rawTimestamp) {
114
+ if (typeof rawTimestamp === "string") {
115
+ const parsed = Date.parse(rawTimestamp);
116
+ return Number.isNaN(parsed) ? null : parsed;
117
+ }
118
+ if (typeof rawTimestamp === "number" && Number.isFinite(rawTimestamp)) {
119
+ return rawTimestamp;
120
+ }
121
+ return null;
122
+ }
123
+ async function getSessionStats(sessionId) {
124
+ if (sessionStatsCache.has(sessionId)) {
125
+ return sessionStatsCache.get(sessionId);
126
+ }
127
+ const filePath = await findSessionFile(sessionId);
128
+ if (!filePath) {
129
+ const empty = {
130
+ messageCount: 0,
131
+ startTime: null,
132
+ endTime: null,
133
+ fileSizeBytes: null
134
+ };
135
+ sessionStatsCache.set(sessionId, empty);
136
+ return empty;
137
+ }
138
+ let fileSizeBytes = null;
139
+ try {
140
+ const fileStat = await stat(filePath);
141
+ fileSizeBytes = fileStat.size;
142
+ } catch {
143
+ fileSizeBytes = null;
144
+ }
145
+ let messageCount = 0;
146
+ let startTime = null;
147
+ let endTime = null;
148
+ const stream = createReadStream(filePath, { encoding: "utf-8" });
149
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
150
+ try {
151
+ for await (const line of rl) {
152
+ if (!line.trim()) {
153
+ continue;
154
+ }
155
+ try {
156
+ const msg = JSON.parse(line);
157
+ if (msg.type !== "user" && msg.type !== "assistant") {
158
+ continue;
159
+ }
160
+ messageCount += 1;
161
+ const parsed = parseMessageTimestamp(msg.timestamp);
162
+ if (parsed !== null) {
163
+ startTime = startTime === null ? parsed : Math.min(startTime, parsed);
164
+ endTime = endTime === null ? parsed : Math.max(endTime, parsed);
165
+ }
166
+ } catch {
167
+ continue;
168
+ }
169
+ }
170
+ } finally {
171
+ rl.close();
172
+ stream.close();
173
+ }
174
+ const stats = {
175
+ messageCount,
176
+ startTime,
177
+ endTime,
178
+ fileSizeBytes
179
+ };
180
+ sessionStatsCache.set(sessionId, stats);
181
+ return stats;
182
+ }
183
+ async function readLastNonEmptyLine(filePath) {
184
+ try {
185
+ const fileStat = await stat(filePath);
186
+ const fileSize = fileStat.size;
187
+ if (fileSize === 0) {
188
+ return null;
189
+ }
190
+ const chunkSize = Math.min(8192, fileSize);
191
+ const start = Math.max(0, fileSize - chunkSize);
192
+ const fileHandle = await open(filePath, "r");
193
+ try {
194
+ const buffer = Buffer.alloc(chunkSize);
195
+ await fileHandle.read(buffer, 0, chunkSize, start);
196
+ const text = buffer.toString("utf-8");
197
+ const lines = text.split("\n").filter((line) => line.trim().length > 0);
198
+ if (lines.length === 0) {
199
+ return null;
200
+ }
201
+ return lines[lines.length - 1] ?? null;
202
+ } finally {
203
+ await fileHandle.close();
204
+ }
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+ async function getSessionSummary(sessionId) {
210
+ if (summaryCache.has(sessionId)) {
211
+ return summaryCache.get(sessionId) ?? null;
212
+ }
213
+ const filePath = await findSessionFile(sessionId);
214
+ if (!filePath) {
215
+ summaryCache.set(sessionId, null);
216
+ return null;
217
+ }
218
+ const lastLine = await readLastNonEmptyLine(filePath);
219
+ if (!lastLine) {
220
+ summaryCache.set(sessionId, null);
221
+ return null;
222
+ }
223
+ try {
224
+ const msg = JSON.parse(lastLine);
225
+ if (msg.type === "summary" && typeof msg.summary === "string" && msg.summary.trim().length > 0) {
226
+ summaryCache.set(sessionId, msg.summary);
227
+ return msg.summary;
228
+ }
229
+ } catch {
230
+ }
231
+ summaryCache.set(sessionId, null);
232
+ return null;
105
233
  }
106
234
  function encodeProjectPath(path) {
107
235
  return path.replace(/[/.]/g, "-");
@@ -316,12 +444,18 @@ async function getSessions() {
316
444
  continue;
317
445
  }
318
446
  seenIds.add(sessionId);
447
+ const summary = await getSessionSummary(sessionId);
448
+ const stats = await getSessionStats(sessionId);
319
449
  sessions.push({
320
450
  id: sessionId,
321
- display: entry.display,
451
+ display: summary ?? entry.display,
322
452
  timestamp: entry.timestamp,
323
453
  project: entry.project,
324
- projectName: getProjectName(entry.project)
454
+ projectName: getProjectName(entry.project),
455
+ messageCount: stats.messageCount,
456
+ startTime: stats.startTime,
457
+ endTime: stats.endTime,
458
+ fileSizeBytes: stats.fileSizeBytes
325
459
  });
326
460
  }
327
461
  return sessions.sort((a, b) => b.timestamp - a.timestamp);
@@ -369,6 +503,8 @@ async function deleteSession(sessionId) {
369
503
  console.log(`[deleteSession] No file found for session: ${sessionId}`);
370
504
  }
371
505
  fileIndex.delete(sessionId);
506
+ summaryCache.delete(sessionId);
507
+ sessionStatsCache.delete(sessionId);
372
508
  const historyPath = join(claudeDir, "history.jsonl");
373
509
  let historyChanged = false;
374
510
  try {
@@ -405,6 +541,87 @@ async function deleteSession(sessionId) {
405
541
  }
406
542
  return fileDeleted || historyChanged;
407
543
  }
544
+ async function getSessionFilePath(sessionId) {
545
+ return findSessionFile(sessionId);
546
+ }
547
+ async function renameSessionDisplay(params) {
548
+ const { sessionId, display, project, timestamp } = params;
549
+ const historyPath = join(claudeDir, "history.jsonl");
550
+ let changed = false;
551
+ let summaryChanged = false;
552
+ try {
553
+ const content = await readFile(historyPath, "utf-8");
554
+ const lines = content.split("\n");
555
+ const updated = [];
556
+ for (const line of lines) {
557
+ if (!line.trim()) {
558
+ continue;
559
+ }
560
+ try {
561
+ const entry = JSON.parse(line);
562
+ const matchesId = entry.sessionId && entry.sessionId === sessionId;
563
+ const matchesFallback = !entry.sessionId && project && typeof timestamp === "number" && entry.project === project && entry.timestamp === timestamp;
564
+ if (matchesId || matchesFallback) {
565
+ if (entry.display !== display) {
566
+ entry.display = display;
567
+ changed = true;
568
+ }
569
+ }
570
+ updated.push(JSON.stringify(entry));
571
+ } catch {
572
+ updated.push(line);
573
+ }
574
+ }
575
+ if (changed) {
576
+ await writeFile(historyPath, updated.join("\n") + "\n", "utf-8");
577
+ if (historyCache) {
578
+ historyCache = historyCache.map((entry) => {
579
+ const matchesId = entry.sessionId && entry.sessionId === sessionId;
580
+ const matchesFallback = !entry.sessionId && project && typeof timestamp === "number" && entry.project === project && entry.timestamp === timestamp;
581
+ if (matchesId || matchesFallback) {
582
+ return { ...entry, display };
583
+ }
584
+ return entry;
585
+ });
586
+ }
587
+ }
588
+ } catch (err) {
589
+ console.error(`[renameSessionDisplay] Failed to update history.jsonl for ${sessionId}:`, err);
590
+ return false;
591
+ }
592
+ const sessionFile = await findSessionFile(sessionId);
593
+ if (sessionFile) {
594
+ try {
595
+ const content = await readFile(sessionFile, "utf-8");
596
+ const lines = content.split("\n");
597
+ let summaryUpdated = false;
598
+ const updatedLines = lines.map((line) => {
599
+ if (!line.trim()) {
600
+ return line;
601
+ }
602
+ try {
603
+ const msg = JSON.parse(line);
604
+ if (msg.type === "summary") {
605
+ msg.summary = display;
606
+ summaryUpdated = true;
607
+ return JSON.stringify(msg);
608
+ }
609
+ } catch {
610
+ }
611
+ return line;
612
+ });
613
+ if (!summaryUpdated) {
614
+ updatedLines.push(JSON.stringify({ type: "summary", summary: display }));
615
+ }
616
+ await writeFile(sessionFile, updatedLines.join("\n") + "\n", "utf-8");
617
+ summaryCache.set(sessionId, display);
618
+ summaryChanged = true;
619
+ } catch (err) {
620
+ console.error(`[renameSessionDisplay] Failed to update session file for ${sessionId}:`, err);
621
+ }
622
+ }
623
+ return changed || summaryChanged;
624
+ }
408
625
  async function getConversation(sessionId) {
409
626
  return dedupe(`getConversation:${sessionId}`, async () => {
410
627
  const filePath = await findSessionFile(sessionId);
@@ -771,6 +988,37 @@ function createServer(options) {
771
988
  console.log(`[/api/sessions/delete] Failed:`, failed);
772
989
  return c.json({ deleted, failed });
773
990
  });
991
+ app.post("/api/sessions/rename", async (c) => {
992
+ let body;
993
+ try {
994
+ body = await c.req.json();
995
+ } catch {
996
+ return c.json({ error: "Invalid JSON body" }, 400);
997
+ }
998
+ const id = body?.id?.trim();
999
+ const display = body?.display?.trim();
1000
+ if (!id || !display) {
1001
+ return c.json({ error: "Missing id/display" }, 400);
1002
+ }
1003
+ const ok = await renameSessionDisplay({
1004
+ sessionId: id,
1005
+ display,
1006
+ project: body.project,
1007
+ timestamp: body.timestamp
1008
+ });
1009
+ if (!ok) {
1010
+ return c.json({ error: "Session not found" }, 404);
1011
+ }
1012
+ return c.json({ ok: true });
1013
+ });
1014
+ app.get("/api/sessions/:id/path", async (c) => {
1015
+ const sessionId = c.req.param("id");
1016
+ if (!sessionId) {
1017
+ return c.json({ error: "Missing session id" }, 400);
1018
+ }
1019
+ const path = await getSessionFilePath(sessionId);
1020
+ return c.json({ path });
1021
+ });
774
1022
  app.post("/api/search", async (c) => {
775
1023
  let body;
776
1024
  try {
@@ -929,6 +1177,7 @@ function createServer(options) {
929
1177
  });
930
1178
  onSessionChange((sessionId, filePath) => {
931
1179
  addToFileIndex(sessionId, filePath);
1180
+ invalidateSessionStats(sessionId);
932
1181
  });
933
1182
  startWatcher();
934
1183
  let httpServer = null;