pi-tau-web-server 1.0.8

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.
Files changed (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1,522 @@
1
+ /**
2
+ * Session Sidebar - Lists sessions grouped by project, handles switching
3
+ */
4
+
5
+ export type SidebarSession = {
6
+ filePath?: string;
7
+ file?: string;
8
+ name?: string | null;
9
+ firstMessage?: string | null;
10
+ timestamp?: string;
11
+ sessionName?: string;
12
+ sessionTimestamp?: string;
13
+ live?: boolean;
14
+ tmux?: boolean;
15
+ [key: string]: unknown;
16
+ };
17
+
18
+ export type SidebarProject = {
19
+ path?: string;
20
+ dirName?: string;
21
+ sessions?: SidebarSession[];
22
+ [key: string]: unknown;
23
+ };
24
+
25
+ type SearchResult = SidebarSession & {
26
+ project?: string;
27
+ matches: { snippet?: string }[];
28
+ };
29
+
30
+ export class SessionSidebar {
31
+ container: HTMLElement;
32
+ onSessionSelect: (session: SidebarSession | null, project: SidebarProject | null) => void;
33
+ activeSessionFile: string | null;
34
+ projects: SidebarProject[];
35
+ collapsedProjects: Set<string | undefined>;
36
+ searchQuery: string;
37
+ favourites: string[];
38
+ contextMenu: HTMLElement | null;
39
+ _searchTimer: ReturnType<typeof setTimeout> | null = null;
40
+ _searchResults: SearchResult[] | null = null;
41
+
42
+ constructor(container: HTMLElement, onSessionSelect: (session: SidebarSession | null, project: SidebarProject | null) => void) {
43
+ this.container = container;
44
+ this.onSessionSelect = onSessionSelect;
45
+ this.activeSessionFile = null;
46
+ this.projects = [];
47
+ this.collapsedProjects = new Set();
48
+ this.searchQuery = '';
49
+ this.favourites = JSON.parse(localStorage.getItem('tau-favourites') || '[]');
50
+ this.contextMenu = null;
51
+
52
+ // Close context menu on click anywhere
53
+ document.addEventListener('click', () => this.closeContextMenu());
54
+ document.addEventListener('contextmenu', (e) => {
55
+ // Close if right-clicking outside a session item
56
+ if (!(e.target as Element | null)?.closest('.session-item')) this.closeContextMenu();
57
+ });
58
+ }
59
+
60
+ saveFavourites() {
61
+ localStorage.setItem('tau-favourites', JSON.stringify(this.favourites));
62
+ }
63
+
64
+ isFavourite(filePath?: string) {
65
+ if (!filePath) return false;
66
+ return this.favourites.includes(filePath);
67
+ }
68
+
69
+ toggleFavourite(filePath?: string) {
70
+ if (!filePath) return;
71
+ const idx = this.favourites.indexOf(filePath);
72
+ if (idx >= 0) {
73
+ this.favourites.splice(idx, 1);
74
+ } else {
75
+ this.favourites.push(filePath);
76
+ }
77
+ this.saveFavourites();
78
+ this.render();
79
+ }
80
+
81
+ async loadSessions() {
82
+ try {
83
+ this.container.innerHTML = Array.from({length: 6}, () =>
84
+ '<div class="session-skeleton"><div class="session-skeleton-title"></div><div class="session-skeleton-meta"></div></div>'
85
+ ).join('');
86
+ const res = await fetch('/api/sessions');
87
+ const data = await res.json();
88
+ this.projects = data.projects || [];
89
+ this.render();
90
+ } catch (error) {
91
+ console.error('[Sidebar] Failed to load sessions:', error);
92
+ this.container.innerHTML = '<div class="session-loading">Failed to load sessions</div>';
93
+ }
94
+ }
95
+
96
+ setSearchQuery(query: string) {
97
+ this.searchQuery = query.toLowerCase().trim();
98
+
99
+ // Clear pending full-text search
100
+ if (this._searchTimer) clearTimeout(this._searchTimer);
101
+
102
+ if (!this.searchQuery) {
103
+ this._searchResults = null;
104
+ this.applySearch();
105
+ return;
106
+ }
107
+
108
+ // Instant: filter titles
109
+ this.applySearch();
110
+
111
+ // Debounced: full-text search (300ms)
112
+ if (this.searchQuery.length >= 2) {
113
+ this._searchTimer = setTimeout(() => this.fullTextSearch(this.searchQuery), 300);
114
+ }
115
+ }
116
+
117
+ async fullTextSearch(query: string) {
118
+ // Don't search if query changed since debounce
119
+ if (query !== this.searchQuery) return;
120
+
121
+ try {
122
+ const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
123
+ const data = await res.json();
124
+ if (query !== this.searchQuery) return; // stale
125
+
126
+ this._searchResults = data.results || [];
127
+ this.renderSearchResults();
128
+ } catch (err) {
129
+ console.error('[Sidebar] Search failed:', err);
130
+ }
131
+ }
132
+
133
+ renderSearchResults() {
134
+ if (!this._searchResults || this._searchResults.length === 0) return;
135
+
136
+ // Remove previous search results section
137
+ const existing = this.container.querySelector('.search-results-group');
138
+ if (existing) existing.remove();
139
+
140
+ const group = document.createElement('div');
141
+ group.className = 'search-results-group';
142
+
143
+ const header = document.createElement('div');
144
+ header.className = 'project-header search-results-header';
145
+ header.innerHTML = `<span>🔍</span> <span>Message matches</span> <span class="project-count">${this._searchResults.length}</span>`;
146
+ group.appendChild(header);
147
+
148
+ const sessionsDiv = document.createElement('div');
149
+ sessionsDiv.className = 'project-sessions';
150
+
151
+ for (const result of this._searchResults) {
152
+ const item = document.createElement('div');
153
+ item.className = 'session-item search-result-item';
154
+ item.dataset.filePath = result.filePath;
155
+
156
+ if (result.filePath === this.activeSessionFile) {
157
+ item.classList.add('active');
158
+ }
159
+
160
+ const title = result.sessionName || result.firstMessage || 'Untitled';
161
+ const snippet = result.matches[0]?.snippet || '';
162
+ const matchCount = result.matches.length;
163
+ const time = this.formatTime(result.sessionTimestamp);
164
+
165
+ item.innerHTML = `
166
+ <div class="session-title-row">
167
+ <div class="session-title" title="${this.escapeHtml(title)}">${this.escapeHtml(title)}</div>
168
+ </div>
169
+ <div class="search-snippet">${this.highlightMatch(snippet, this.searchQuery)}</div>
170
+ <div class="session-meta">${time}${matchCount > 1 ? ` · ${matchCount} matches` : ''}</div>
171
+ `;
172
+
173
+ // Find the matching project/session to pass to onSessionSelect
174
+ item.addEventListener('click', () => {
175
+ for (const project of this.projects) {
176
+ const session = project.sessions?.find(s => s.filePath === result.filePath);
177
+ if (session) {
178
+ this.onSessionSelect(session, project);
179
+ return;
180
+ }
181
+ }
182
+ // Session not in loaded list (unlikely) — try switching by path
183
+ this.onSessionSelect({ filePath: result.filePath, name: result.sessionName }, { path: result.project });
184
+ });
185
+
186
+ sessionsDiv.appendChild(item);
187
+ }
188
+
189
+ group.appendChild(sessionsDiv);
190
+ // Insert at top of container
191
+ this.container.insertBefore(group, this.container.firstChild);
192
+ }
193
+
194
+ highlightMatch(text: string, query: string) {
195
+ if (!query) return this.escapeHtml(text);
196
+ const escaped = this.escapeHtml(text);
197
+ const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
198
+ return escaped.replace(re, '<mark>$1</mark>');
199
+ }
200
+
201
+ applySearch() {
202
+ if (!this.searchQuery) {
203
+ this.container.querySelectorAll('.session-item').forEach(el => el.classList.remove('hidden'));
204
+ this.container.querySelectorAll('.project-group').forEach(el => el.style.display = '');
205
+ const favSection = this.container.querySelector('.favourites-group');
206
+ if (favSection) favSection.style.display = '';
207
+ // Remove full-text results
208
+ const searchGroup = this.container.querySelector('.search-results-group');
209
+ if (searchGroup) searchGroup.remove();
210
+ return;
211
+ }
212
+
213
+ // Search favourites section
214
+ const favSection = this.container.querySelector('.favourites-group');
215
+ if (favSection) {
216
+ let hasVisible = false;
217
+ favSection.querySelectorAll('.session-item').forEach(item => {
218
+ const title = (item.querySelector('.session-title')?.textContent || '').toLowerCase();
219
+ const matches = title.includes(this.searchQuery);
220
+ item.classList.toggle('hidden', !matches);
221
+ if (matches) hasVisible = true;
222
+ });
223
+ favSection.style.display = hasVisible ? '' : 'none';
224
+ }
225
+
226
+ this.container.querySelectorAll('.project-group').forEach(group => {
227
+ let hasVisible = false;
228
+ group.querySelectorAll('.session-item').forEach(item => {
229
+ const title = (item.querySelector('.session-title')?.textContent || '').toLowerCase();
230
+ const matches = title.includes(this.searchQuery);
231
+ item.classList.toggle('hidden', !matches);
232
+ if (matches) hasVisible = true;
233
+ });
234
+ group.style.display = hasVisible ? '' : 'none';
235
+ });
236
+ }
237
+
238
+ setActive(filePath?: string | null) {
239
+ this.activeSessionFile = filePath ?? null;
240
+ this.container.querySelectorAll('.session-item').forEach(el => {
241
+ el.classList.toggle('active', el.dataset.filePath === filePath);
242
+ });
243
+ }
244
+
245
+ clearActive() {
246
+ this.activeSessionFile = null;
247
+ this.container.querySelectorAll('.session-item').forEach(el => el.classList.remove('active'));
248
+ }
249
+
250
+ // ═══════════════════════════════════════
251
+ // Context Menu
252
+ // ═══════════════════════════════════════
253
+
254
+ showContextMenu(e: MouseEvent, session: SidebarSession, project: SidebarProject, itemEl: HTMLElement) {
255
+ e.preventDefault();
256
+ this.closeContextMenu();
257
+
258
+ const isFav = this.isFavourite(session.filePath);
259
+ const menu = document.createElement('div');
260
+ menu.className = 'session-context-menu';
261
+
262
+ const items = [
263
+ { icon: isFav ? '★' : '☆', label: isFav ? 'Unfavourite' : 'Favourite', action: () => this.toggleFavourite(session.filePath) },
264
+ { icon: '✎', label: 'Rename', action: () => this.startRename(itemEl, session) },
265
+ { icon: '📋', label: 'Export HTML', action: () => this.exportSession(session) },
266
+ { icon: '🗑', label: 'Delete', action: () => this.deleteSession(session, itemEl) },
267
+ ];
268
+
269
+ for (const item of items) {
270
+ const row = document.createElement('div');
271
+ row.className = 'context-menu-item';
272
+ row.innerHTML = `<span class="context-menu-icon">${item.icon}</span>${item.label}`;
273
+ row.addEventListener('click', (ev) => {
274
+ ev.stopPropagation();
275
+ this.closeContextMenu();
276
+ item.action();
277
+ });
278
+ menu.appendChild(row);
279
+ }
280
+
281
+ // Position
282
+ document.body.appendChild(menu);
283
+ const rect = menu.getBoundingClientRect();
284
+ let x = e.clientX;
285
+ let y = e.clientY;
286
+ if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 8;
287
+ if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 8;
288
+ menu.style.left = `${x}px`;
289
+ menu.style.top = `${y}px`;
290
+
291
+ this.contextMenu = menu;
292
+ }
293
+
294
+ closeContextMenu() {
295
+ if (this.contextMenu) {
296
+ this.contextMenu.remove();
297
+ this.contextMenu = null;
298
+ }
299
+ }
300
+
301
+ startRename(itemEl: HTMLElement, session: SidebarSession) {
302
+ const titleEl = itemEl.querySelector('.session-title');
303
+ if (!titleEl) return;
304
+ const currentName = titleEl.textContent;
305
+
306
+ const input = document.createElement('input');
307
+ input.className = 'session-rename-input';
308
+ input.value = currentName;
309
+ titleEl.replaceWith(input);
310
+ input.focus();
311
+ input.select();
312
+
313
+ const commit = async () => {
314
+ const newName = input.value.trim();
315
+ if (newName && newName !== currentName) {
316
+ try {
317
+ await fetch('/api/rpc', {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify({ type: 'set_session_name', name: newName, filePath: session.filePath }),
321
+ });
322
+ } catch { /* silent */ }
323
+ }
324
+ const newTitle = document.createElement('div');
325
+ newTitle.className = 'session-title';
326
+ newTitle.title = newName || currentName;
327
+ newTitle.textContent = newName || currentName;
328
+ input.replaceWith(newTitle);
329
+ };
330
+
331
+ input.addEventListener('blur', commit);
332
+ input.addEventListener('keydown', (ke) => {
333
+ if (ke.key === 'Enter') { ke.preventDefault(); input.blur(); }
334
+ if (ke.key === 'Escape') { input.value = currentName; input.blur(); }
335
+ });
336
+ }
337
+
338
+ async deleteSession(session: SidebarSession, itemEl: HTMLElement) {
339
+ if (!confirm(`Delete "${session.name || session.firstMessage || 'this session'}"?`)) return;
340
+ try {
341
+ const res = await fetch('/api/sessions/delete', {
342
+ method: 'POST',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({ filePath: session.filePath }),
345
+ });
346
+ if (res.ok) {
347
+ itemEl.remove();
348
+ // Remove from favourites if present
349
+ if (session.filePath) {
350
+ const favIdx = this.favourites.indexOf(session.filePath);
351
+ if (favIdx >= 0) {
352
+ this.favourites.splice(favIdx, 1);
353
+ this.saveFavourites();
354
+ }
355
+ }
356
+ // If this was the active session, clear it
357
+ if (session.filePath === this.activeSessionFile) {
358
+ this.clearActive();
359
+ if (this.onSessionSelect) this.onSessionSelect(null, null);
360
+ }
361
+ }
362
+ } catch (e) {
363
+ console.error('[Sidebar] Delete failed:', e);
364
+ }
365
+ }
366
+
367
+ async exportSession(session: SidebarSession) {
368
+ try {
369
+ const data = await (await fetch('/api/rpc', {
370
+ method: 'POST',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({ type: 'export_html', filePath: session.filePath }),
373
+ })).json();
374
+ if (data?.success && data.data?.path) {
375
+ await fetch('/api/open', {
376
+ method: 'POST',
377
+ headers: { 'Content-Type': 'application/json' },
378
+ body: JSON.stringify({ filePath: data.data.path }),
379
+ });
380
+ }
381
+ } catch { /* silent */ }
382
+ }
383
+
384
+ // ═══════════════════════════════════════
385
+ // Render
386
+ // ═══════════════════════════════════════
387
+
388
+ buildSessionItem(session: SidebarSession, project: SidebarProject) {
389
+ const item = document.createElement('div');
390
+ item.className = 'session-item';
391
+ item.dataset.filePath = session.filePath;
392
+
393
+ if (session.filePath === this.activeSessionFile) {
394
+ item.classList.add('active');
395
+ }
396
+
397
+ const title = session.name || session.firstMessage || 'Empty session';
398
+ const time = this.formatTime(session.timestamp);
399
+ const tmuxTag = session.live ? '<span class="session-tag tmux-tag">live</span>' : (session.tmux ? '<span class="session-tag tmux-tag">tmux</span>' : '');
400
+ const favIcon = this.isFavourite(session.filePath) ? '<span class="session-fav-icon">★</span>' : '';
401
+
402
+ item.innerHTML = `
403
+ <div class="session-title-row">
404
+ ${favIcon}
405
+ <div class="session-title" title="${this.escapeHtml(title)}">${this.escapeHtml(title)}</div>
406
+ ${tmuxTag}
407
+ </div>
408
+ <div class="session-meta">${time}</div>
409
+ `;
410
+
411
+ item.addEventListener('click', () => this.onSessionSelect(session, project));
412
+ item.addEventListener('contextmenu', (e) => this.showContextMenu(e, session, project, item));
413
+
414
+ return item;
415
+ }
416
+
417
+ render() {
418
+ if (this.projects.length === 0) {
419
+ this.container.innerHTML = '<div class="session-loading">No sessions found</div>';
420
+ return;
421
+ }
422
+
423
+ this.container.innerHTML = '';
424
+
425
+ // Favourites section — collect from all projects
426
+ const favSessions = [];
427
+ for (const project of this.projects) {
428
+ for (const session of project.sessions || []) {
429
+ if (this.isFavourite(session.filePath)) {
430
+ favSessions.push({ session, project });
431
+ }
432
+ }
433
+ }
434
+
435
+ if (favSessions.length > 0) {
436
+ const favGroup = document.createElement('div');
437
+ favGroup.className = 'favourites-group';
438
+
439
+ const header = document.createElement('div');
440
+ header.className = 'project-header favourites-header';
441
+ header.innerHTML = `<span class="fav-star">★</span> <span>Favourites</span> <span class="project-count">${favSessions.length}</span>`;
442
+ favGroup.appendChild(header);
443
+
444
+ const sessionsDiv = document.createElement('div');
445
+ sessionsDiv.className = 'project-sessions';
446
+ for (const { session, project } of favSessions) {
447
+ sessionsDiv.appendChild(this.buildSessionItem(session, project));
448
+ }
449
+ favGroup.appendChild(sessionsDiv);
450
+ this.container.appendChild(favGroup);
451
+ }
452
+
453
+ // Regular project groups
454
+ for (const project of this.projects) {
455
+ const group = document.createElement('div');
456
+ group.className = 'project-group';
457
+ const isCollapsed = this.collapsedProjects.has(project.dirName);
458
+
459
+ const header = document.createElement('div');
460
+ header.className = `project-header${isCollapsed ? ' collapsed' : ''}`;
461
+
462
+ const pathParts = (project.path || '').split('/').filter(Boolean);
463
+ const shortPath = pathParts.length > 0 ? pathParts[pathParts.length - 1] : (project.path || '');
464
+
465
+ header.innerHTML = `
466
+ <span class="chevron">▼</span>
467
+ <span title="${this.escapeHtml(project.path)}">${this.escapeHtml(shortPath)}</span>
468
+ <span class="project-count">${(project.sessions || []).length}</span>
469
+ `;
470
+
471
+ header.addEventListener('click', () => {
472
+ if (this.collapsedProjects.has(project.dirName)) {
473
+ this.collapsedProjects.delete(project.dirName);
474
+ } else {
475
+ this.collapsedProjects.add(project.dirName);
476
+ }
477
+ header.classList.toggle('collapsed');
478
+ sessionsDiv.classList.toggle('collapsed');
479
+ });
480
+
481
+ group.appendChild(header);
482
+
483
+ const sessionsDiv = document.createElement('div');
484
+ sessionsDiv.className = `project-sessions${isCollapsed ? ' collapsed' : ''}`;
485
+
486
+ for (const session of project.sessions || []) {
487
+ sessionsDiv.appendChild(this.buildSessionItem(session, project));
488
+ }
489
+
490
+ group.appendChild(sessionsDiv);
491
+ this.container.appendChild(group);
492
+ }
493
+
494
+ if (this.searchQuery) this.applySearch();
495
+ }
496
+
497
+ formatTime(isoTimestamp?: string) {
498
+ try {
499
+ const date = new Date(isoTimestamp || '');
500
+ const now = new Date();
501
+ const diffMs = now.getTime() - date.getTime();
502
+ const diffMins = Math.floor(diffMs / 60000);
503
+ const diffHours = Math.floor(diffMs / 3600000);
504
+ const days = Math.floor(diffMs / 86400000);
505
+
506
+ if (diffMins < 1) return 'Just now';
507
+ if (diffMins < 60) return `${diffMins}m ago`;
508
+ if (diffHours < 24) return `${diffHours}h ago`;
509
+ if (days === 1) return 'Yesterday';
510
+ if (days < 7) return date.toLocaleDateString([], { weekday: 'long' });
511
+ return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
512
+ } catch {
513
+ return '';
514
+ }
515
+ }
516
+
517
+ escapeHtml(text: unknown) {
518
+ const div = document.createElement('div');
519
+ div.textContent = String(text ?? '');
520
+ return div.innerHTML;
521
+ }
522
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Session Stats Card — the popover opened from the header context pill.
3
+ *
4
+ * Shows the same numbers pi's TUI status bar shows: context usage, input and
5
+ * output tokens, cache read, cache hit rate, and session cost. The
6
+ * authoritative data comes from pi's get_session_stats RPC (via the caller's
7
+ * fetchStats); when that is unavailable — no live session, a transient fetch
8
+ * failure, or null contextUsage right after compaction — the card degrades
9
+ * gracefully to the caller's locally-tracked fallback numbers instead of
10
+ * showing nothing.
11
+ */
12
+
13
+ export type SessionStatsTokens = {
14
+ input?: number | null;
15
+ output?: number | null;
16
+ cacheRead?: number | null;
17
+ cacheWrite?: number | null;
18
+ total?: number | null;
19
+ };
20
+
21
+ export type SessionContextUsage = {
22
+ tokens?: number | null;
23
+ contextWindow?: number | null;
24
+ percent?: number | null;
25
+ };
26
+
27
+ export type SessionStats = {
28
+ tokens?: SessionStatsTokens | null;
29
+ cost?: number | null;
30
+ contextUsage?: SessionContextUsage | null;
31
+ };
32
+
33
+ /** Locally-tracked numbers used when authoritative stats are unavailable. */
34
+ export type SessionStatsFallback = {
35
+ usage: { input?: number; output?: number; cacheRead?: number } | null;
36
+ cost: number;
37
+ /** Last known context tokens (fresh input + cache read). 0 = unknown. */
38
+ contextTokens: number;
39
+ /** Model context window size. 0 = unknown. */
40
+ contextWindow: number;
41
+ };
42
+
43
+ export type SessionStatsCardOptions = {
44
+ pillEl: HTMLElement;
45
+ cardEl: HTMLElement;
46
+ /** Fetch authoritative stats; resolve null when unavailable. */
47
+ fetchStats: () => Promise<SessionStats | null>;
48
+ getFallback: () => SessionStatsFallback;
49
+ };
50
+
51
+ function formatTokens(n: number) {
52
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
53
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
54
+ return String(Math.round(n));
55
+ }
56
+
57
+ // Small inline icons matching the app's feather-style stroke icons.
58
+ function icon(paths: string) {
59
+ return `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
60
+ }
61
+
62
+ const ICONS = {
63
+ input: icon('<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>'),
64
+ output: icon('<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>'),
65
+ cacheRead: icon('<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>'),
66
+ cacheHit: icon('<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>'),
67
+ };
68
+
69
+ export function setupSessionStatsCard(options: SessionStatsCardOptions) {
70
+ const { pillEl, cardEl, fetchStats, getFallback } = options;
71
+
72
+ let lastStats: SessionStats | null = null;
73
+ let refreshSeq = 0;
74
+
75
+ function isOpen() {
76
+ return !cardEl.classList.contains('hidden');
77
+ }
78
+
79
+ function render() {
80
+ const fallback = getFallback();
81
+ const tokens = lastStats?.tokens || null;
82
+
83
+ const input = typeof tokens?.input === 'number' ? tokens.input : (fallback.usage?.input ?? null);
84
+ const output = typeof tokens?.output === 'number' ? tokens.output : (fallback.usage?.output ?? null);
85
+ const cacheRead = typeof tokens?.cacheRead === 'number' ? tokens.cacheRead : (fallback.usage?.cacheRead ?? null);
86
+ const cost = typeof lastStats?.cost === 'number' ? lastStats.cost : (fallback.cost > 0 ? fallback.cost : null);
87
+
88
+ // contextUsage may be absent, and its tokens/percent may be null right
89
+ // after compaction — fall back to the last locally-known numbers.
90
+ const cu = lastStats?.contextUsage || null;
91
+ const ctxWindow = typeof cu?.contextWindow === 'number' && cu.contextWindow > 0
92
+ ? cu.contextWindow
93
+ : (fallback.contextWindow > 0 ? fallback.contextWindow : null);
94
+ const ctxTokens = typeof cu?.tokens === 'number'
95
+ ? cu.tokens
96
+ : (fallback.contextTokens > 0 ? fallback.contextTokens : null);
97
+ let pct = typeof cu?.percent === 'number' ? cu.percent : null;
98
+ if (pct === null && ctxTokens !== null && ctxWindow) {
99
+ pct = (ctxTokens / ctxWindow) * 100;
100
+ }
101
+
102
+ const hitDenominator = (input ?? 0) + (cacheRead ?? 0);
103
+ const hitRate = cacheRead !== null && hitDenominator > 0 ? (cacheRead / hitDenominator) * 100 : null;
104
+
105
+ const sections: string[] = [];
106
+
107
+ if (pct !== null || (ctxTokens !== null && ctxWindow !== null)) {
108
+ const detail = ctxTokens !== null && ctxWindow !== null
109
+ ? `<span class="stats-context-detail">${formatTokens(ctxTokens)} / ${formatTokens(ctxWindow)}</span>`
110
+ : '';
111
+ sections.push(`
112
+ <div class="stats-row">
113
+ <span class="stats-label">Context</span>
114
+ <span class="stats-value">${pct !== null ? `${pct.toFixed(1)}%` : '—'}${detail}</span>
115
+ </div>`);
116
+ }
117
+
118
+ if (input !== null || output !== null || cacheRead !== null) {
119
+ sections.push(`
120
+ <div class="stats-row"><span class="stats-label"><span class="stats-icon">${ICONS.input}</span>Input</span><span class="stats-value">${input !== null ? formatTokens(input) : '—'}</span></div>
121
+ <div class="stats-row"><span class="stats-label"><span class="stats-icon">${ICONS.output}</span>Output</span><span class="stats-value">${output !== null ? formatTokens(output) : '—'}</span></div>
122
+ <div class="stats-row"><span class="stats-label"><span class="stats-icon">${ICONS.cacheRead}</span>Cache read</span><span class="stats-value">${cacheRead !== null ? formatTokens(cacheRead) : '—'}</span></div>
123
+ <div class="stats-row"><span class="stats-label"><span class="stats-icon">${ICONS.cacheHit}</span>Cache hit</span><span class="stats-value">${hitRate !== null ? `${hitRate.toFixed(1)}%` : '—'}</span></div>`);
124
+ }
125
+
126
+ if (cost !== null) {
127
+ sections.push(`
128
+ <div class="stats-row">
129
+ <span class="stats-label">Cost</span>
130
+ <span class="stats-value">$${cost.toFixed(3)} (sub)</span>
131
+ </div>`);
132
+ }
133
+
134
+ cardEl.innerHTML = sections.length
135
+ ? sections.join('<div class="stats-sep"></div>')
136
+ : '<div class="stats-empty">No usage data yet</div>';
137
+ }
138
+
139
+ // Re-fetch authoritative stats; re-render the card if it is showing.
140
+ // Refreshes can overlap (turn end, snapshot load, card open, session
141
+ // switch), so each carries a sequence number and only the newest one is
142
+ // allowed to write — a slow stale response must never clobber fresh stats.
143
+ async function refresh() {
144
+ const seq = ++refreshSeq;
145
+ const stats = await fetchStats();
146
+ if (seq !== refreshSeq) return;
147
+ lastStats = stats;
148
+ if (isOpen()) render();
149
+ }
150
+
151
+ async function open() {
152
+ render(); // show cached/fallback numbers immediately
153
+ cardEl.classList.remove('hidden');
154
+ pillEl.setAttribute('aria-expanded', 'true');
155
+ await refresh();
156
+ }
157
+
158
+ function close() {
159
+ cardEl.classList.add('hidden');
160
+ pillEl.setAttribute('aria-expanded', 'false');
161
+ }
162
+
163
+ pillEl.addEventListener('click', (e) => {
164
+ e.stopPropagation();
165
+ if (isOpen()) close();
166
+ else void open();
167
+ });
168
+
169
+ document.addEventListener('click', (e) => {
170
+ if (isOpen() && !cardEl.contains(e.target as Node) && !pillEl.contains(e.target as Node)) {
171
+ close();
172
+ }
173
+ });
174
+
175
+ return { refresh, close, isOpen };
176
+ }