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