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,2025 @@
1
+ /**
2
+ * Main App - Ties everything together
3
+ */
4
+ import { WebSocketClient } from './websocket-client.js';
5
+ import { StateManager } from './state.js';
6
+ import { MessageRenderer } from './message-renderer.js';
7
+ import { ToolCardRenderer } from './tool-card.js';
8
+ import { DialogHandler } from './dialogs.js';
9
+ import { SessionSidebar } from './session-sidebar.js';
10
+ import { themes, applyTheme, getCurrentTheme } from './themes.js';
11
+ import { FileBrowser, getFileIcon } from './file-browser.js';
12
+ import { setupLauncherPanel } from './launcher-panel.js';
13
+ import { setupModelPicker } from './model-picker.js';
14
+ import { setupTreeView } from './tree-view.js';
15
+ import { setupVoiceInput } from './voice-input.js';
16
+ import { setupCommandPalette } from './command-palette.js';
17
+ import { setupSessionStatsCard } from './session-stats-card.js';
18
+ // Initialize components
19
+ const wsUrl = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws';
20
+ const wsClient = new WebSocketClient(wsUrl);
21
+ const state = new StateManager();
22
+ // All element lookups below query the app's static index.html shell, which is
23
+ // present before this module runs (the script is a deferred module at the end
24
+ // of <body>). A missing element means the page is structurally broken, so we
25
+ // assert non-null at the query site rather than guarding every usage.
26
+ const messageRenderer = new MessageRenderer(document.getElementById('messages'));
27
+ const toolCardRenderer = new ToolCardRenderer(document.getElementById('messages'));
28
+ const dialogHandler = new DialogHandler(document.getElementById('dialog-container'), wsClient, () => activeLiveSessionId);
29
+ // Session sidebar
30
+ const sidebar = new SessionSidebar(document.getElementById('session-list'), handleSessionSelect);
31
+ // UI elements
32
+ const messageInput = document.getElementById('message-input');
33
+ const chatForm = document.getElementById('chat-form');
34
+ const sendBtn = document.getElementById('send-btn');
35
+ const abortBtn = document.getElementById('abort-btn');
36
+ const statusIndicator = document.getElementById('status-indicator');
37
+ const statusText = document.getElementById('status-text');
38
+ // Tracks the pending timer that restores statusText after a transient
39
+ // status message (rpcCommand success/error). Any new status message must
40
+ // clear this so a stale restore cannot overwrite a later, longer-lived
41
+ // message (e.g. the red-dot error flash).
42
+ let statusRestoreTimer = null;
43
+ // Tracks the pending timer that restores the status indicator (dot) and
44
+ // text after a red-dot error flash. Kept SEPARATE from statusRestoreTimer
45
+ // so a normal setStatusMessage call cannot cancel the only callback that
46
+ // would clear the `error` class — otherwise an unrelated status update
47
+ // during the 3s flash would strand the dot red with no timer to reset it.
48
+ let statusFlashTimer = null;
49
+ // Restore the status indicator dot to its real connection/streaming state.
50
+ // Only touches the indicator class (not statusText), so callers can set the
51
+ // accompanying text themselves.
52
+ function restoreStatusIndicator() {
53
+ const open = wsClient.ws?.readyState === WebSocket.OPEN;
54
+ statusIndicator.className = `status-indicator ${open && state.isStreaming ? 'streaming' : (open ? 'connected' : 'disconnected')}`;
55
+ }
56
+ // Set a transient statusText message and schedule its restore. Cancels any
57
+ // previously scheduled restore so overlapping messages cannot race. A new
58
+ // status message also supersedes any active error flash: it cancels the
59
+ // flash's restore and returns the dot to its real state so the `error`
60
+ // class cannot linger with no timer to clear it.
61
+ function setStatusMessage(text, restoreText = null, restoreMs = 3000) {
62
+ if (statusFlashTimer !== null) {
63
+ clearTimeout(statusFlashTimer);
64
+ statusFlashTimer = null;
65
+ restoreStatusIndicator();
66
+ }
67
+ clearTimeout(statusRestoreTimer ?? undefined);
68
+ statusText.textContent = text;
69
+ if (restoreText !== null) {
70
+ statusRestoreTimer = setTimeout(() => {
71
+ statusRestoreTimer = null;
72
+ statusText.textContent = restoreText;
73
+ }, restoreMs);
74
+ }
75
+ }
76
+ // Turn the status indicator red and show an error message; after `ms`,
77
+ // restore the indicator to the real connection state and reset the text.
78
+ function flashStatusError(msg, ms = 3000) {
79
+ // Reset the class atomically so no stale connected/disconnected/streaming
80
+ // class lingers alongside `error` (matches updateConnectionStatus' style).
81
+ statusIndicator.className = 'status-indicator error';
82
+ // Cancel any pending status-text restore (e.g. rpcCommand's 'Done' ->
83
+ // 'Connected' timer from an earlier successful step in the same flow) so it
84
+ // cannot overwrite this error message while the red dot persists.
85
+ clearTimeout(statusRestoreTimer ?? undefined);
86
+ // Cancel any prior flash restore so overlapping flashes (a second
87
+ // model-save failure within 3s) cannot leak a stray restore that
88
+ // would reset the dot before this flash's own restore fires.
89
+ clearTimeout(statusFlashTimer ?? undefined);
90
+ statusText.textContent = msg;
91
+ // Schedule the dot+text restore on the dedicated statusFlashTimer so a
92
+ // later setStatusMessage (e.g. the user clicking the thinking-level cycle
93
+ // button right after a thinking-level failure) cannot cancel the only
94
+ // callback that clears the `error` class. If a new status message does
95
+ // arrive, setStatusMessage itself retires the flash via restoreStatusIndicator.
96
+ statusFlashTimer = setTimeout(() => {
97
+ statusFlashTimer = null;
98
+ restoreStatusIndicator();
99
+ const open = wsClient.ws?.readyState === WebSocket.OPEN;
100
+ // Preserve an in-progress stream: restore the streaming text too.
101
+ statusText.textContent = (open && state.isStreaming) ? 'Working...'
102
+ : (open ? 'Connected' : 'Disconnected');
103
+ }, ms);
104
+ }
105
+ const sidebarEl = document.getElementById('sidebar');
106
+ const sidebarToggle = document.getElementById('sidebar-toggle');
107
+ const sidebarOverlay = document.getElementById('sidebar-overlay');
108
+ const refreshSessionsBtn = document.getElementById('refresh-sessions-btn');
109
+ const sessionSearchInput = document.getElementById('session-search-input');
110
+ const typingIndicator = document.getElementById('typing-indicator');
111
+ const contextPillEl = document.getElementById('context-pill');
112
+ const scrollBottomBtn = document.getElementById('scroll-bottom-btn');
113
+ const scrollBottomBadge = document.getElementById('scroll-bottom-badge');
114
+ const messagesContainer = document.getElementById('messages');
115
+ const launcherPanel = setupLauncherPanel({
116
+ launcherEl: document.getElementById('launcher'),
117
+ messagesContainer,
118
+ async createSession(projectPath) {
119
+ try {
120
+ const res = await fetch('/api/live-sessions', {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ cwd: projectPath, model: '' }),
124
+ });
125
+ const data = await res.json();
126
+ if (data.session) {
127
+ upsertLiveSession(data.session);
128
+ await selectLiveSession(data.session.id);
129
+ }
130
+ }
131
+ catch (e) {
132
+ console.error('[Launcher] Failed to create Tau tab:', e);
133
+ }
134
+ },
135
+ });
136
+ // State tracking
137
+ let currentStreamingElement = null;
138
+ let currentStreamingText = '';
139
+ let sessionTotalCost = 0;
140
+ let lastInputTokens = 0;
141
+ let contextWindowSize = 0; // fetched from model info
142
+ let originalTitle = document.title;
143
+ let hasFocus = true;
144
+ let unreadCount = 0;
145
+ let isScrolledUp = false;
146
+ let hasNewWhileScrolled = false;
147
+ let lastSentMessage = null; // Track to avoid duplicate rendering from backend echo events
148
+ let lastUsage = null; // Full usage object for context visualiser
149
+ let activeLiveSessionFile = null; // The active live session file path
150
+ let viewingActiveSession = false; // Whether we're viewing a live backend Tau tab or historical read-only session
151
+ let hasReceivedInitialServerState = false;
152
+ let liveInstances = []; // Sidebar live indicators derived from backend live sessions
153
+ let liveSessions = [];
154
+ let activeLiveSessionId = localStorage.getItem('tau-active-live-session-id') || null;
155
+ let hasRestoredInitialLiveSession = false;
156
+ let pendingExtensionUIRequests = []; // background session UI requests waiting for that Tau tab to be selected
157
+ dialogHandler.onIdle = () => processQueuedExtensionUIRequest();
158
+ // File browser
159
+ const fileSidebar = document.getElementById('file-sidebar');
160
+ const fileSidebarToggle = document.getElementById('file-sidebar-toggle');
161
+ const fileSidebarClose = document.getElementById('file-sidebar-close');
162
+ const fileSidebarUp = document.getElementById('file-sidebar-up');
163
+ const fileList = document.getElementById('file-list');
164
+ const fileSidebarPath = document.getElementById('file-sidebar-path');
165
+ const fileBrowser = new FileBrowser(fileList, fileSidebarPath, messageInput, (filePath) => {
166
+ const name = filePath.split(/[/\\]/).pop() || filePath;
167
+ const ext = name.split('.').pop()?.toLowerCase() || '';
168
+ pendingFilePaths.push({ path: filePath, name, ext, sessionId: activeLiveSessionId });
169
+ renderAttachmentPreviews();
170
+ }, () => (viewingActiveSession && liveSessions.some(s => s.id === activeLiveSessionId) ? activeLiveSessionId : null));
171
+ fileSidebarToggle.addEventListener('click', () => {
172
+ const isCollapsed = fileSidebar.classList.toggle('collapsed');
173
+ if (!isCollapsed && !fileBrowser.currentPath) {
174
+ fileBrowser.load(); // Load session cwd
175
+ }
176
+ localStorage.setItem('tau-file-sidebar', isCollapsed ? 'closed' : 'open');
177
+ });
178
+ fileSidebarClose.addEventListener('click', () => {
179
+ fileSidebar.classList.add('collapsed');
180
+ localStorage.setItem('tau-file-sidebar', 'closed');
181
+ });
182
+ fileSidebarUp.addEventListener('click', () => {
183
+ const parent = fileBrowser.getParentPath();
184
+ if (parent)
185
+ fileBrowser.load(parent);
186
+ });
187
+ fetch('/api/health').then(r => r.json()).then(data => {
188
+ const names = { win32: 'Explorer', darwin: 'Finder', linux: 'file manager' };
189
+ const name = names[data.platform] || 'file manager';
190
+ document.getElementById('file-sidebar-finder').title = `Open in ${name}`;
191
+ }).catch(() => { });
192
+ document.getElementById('file-sidebar-finder').addEventListener('click', () => {
193
+ const sessionId = viewingActiveSession && activeLiveSessionId ? activeLiveSessionId : null;
194
+ if (fileBrowser.currentPath && sessionId) {
195
+ fetch('/api/open', {
196
+ method: 'POST',
197
+ headers: { 'Content-Type': 'application/json' },
198
+ body: JSON.stringify({ filePath: fileBrowser.currentPath, sessionId }),
199
+ });
200
+ }
201
+ });
202
+ // Restore file sidebar state
203
+ if (localStorage.getItem('tau-file-sidebar') === 'open') {
204
+ fileSidebar.classList.remove('collapsed');
205
+ fileBrowser.load();
206
+ }
207
+ // ═══════════════════════════════════════
208
+ // Focus tracking for tab title notifications
209
+ // ═══════════════════════════════════════
210
+ window.addEventListener('focus', () => {
211
+ hasFocus = true;
212
+ unreadCount = 0;
213
+ document.title = originalTitle;
214
+ });
215
+ window.addEventListener('blur', () => {
216
+ hasFocus = false;
217
+ });
218
+ // Reconnect WebSocket when returning to the app (iOS suspends WS connections)
219
+ document.addEventListener('visibilitychange', () => {
220
+ if (document.visibilityState === 'visible' && wsClient.ws?.readyState !== WebSocket.OPEN) {
221
+ console.log('[App] Returning to app, reconnecting...');
222
+ wsClient.forceReconnect();
223
+ }
224
+ });
225
+ // ═══════════════════════════════════════
226
+ // Scroll-to-bottom button + new message indicator
227
+ // ═══════════════════════════════════════
228
+ messagesContainer.addEventListener('scroll', () => {
229
+ const threshold = 150;
230
+ const atBottom = messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < threshold;
231
+ isScrolledUp = !atBottom;
232
+ if (atBottom) {
233
+ scrollBottomBtn.classList.add('hidden');
234
+ scrollBottomBadge.classList.add('hidden');
235
+ hasNewWhileScrolled = false;
236
+ }
237
+ else {
238
+ scrollBottomBtn.classList.remove('hidden');
239
+ }
240
+ });
241
+ scrollBottomBtn.addEventListener('click', () => {
242
+ messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
243
+ scrollBottomBtn.classList.add('hidden');
244
+ scrollBottomBadge.classList.add('hidden');
245
+ hasNewWhileScrolled = false;
246
+ });
247
+ function scrollToBottom() {
248
+ messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
249
+ }
250
+ function showNewMessageBadge() {
251
+ if (isScrolledUp) {
252
+ hasNewWhileScrolled = true;
253
+ scrollBottomBadge.classList.remove('hidden');
254
+ }
255
+ }
256
+ // ═══════════════════════════════════════
257
+ // WebSocket event handlers
258
+ // ═══════════════════════════════════════
259
+ wsClient.addEventListener('connected', () => {
260
+ updateConnectionStatus('connected');
261
+ // Fetch model context window size for token % display
262
+ setTimeout(fetchContextWindow, 1000);
263
+ });
264
+ wsClient.addEventListener('disconnected', () => {
265
+ updateConnectionStatus('disconnected');
266
+ });
267
+ wsClient.addEventListener('reconnectFailed', () => {
268
+ updateConnectionStatus('disconnected');
269
+ messageRenderer.renderError('Connection lost. Please refresh the page.');
270
+ });
271
+ wsClient.addEventListener('rpcEvent', (e) => {
272
+ const detail = e.detail || {};
273
+ const event = (detail.event || detail);
274
+ const sessionId = detail.sessionId;
275
+ if (sessionId) {
276
+ const session = liveSessions.find(s => s.id === sessionId);
277
+ if (session) {
278
+ session.lastActiveAt = new Date().toISOString();
279
+ if (event.type === 'agent_start' || event.type === 'turn_start')
280
+ session.isStreaming = true;
281
+ if (event.type === 'agent_end' || event.type === 'turn_end')
282
+ session.isStreaming = false;
283
+ if (event.type === 'agent_start' || event.type === 'turn_start' || event.type === 'agent_end' || event.type === 'turn_end') {
284
+ // Keep an open tree modal honest: disable its rows while the turn
285
+ // runs, and refetch the tree (re-enabling the rows) when it ends.
286
+ treeViewController.notifyStreamingChanged(sessionId, !!session.isStreaming);
287
+ }
288
+ if (event.type === 'session_name' && event.name)
289
+ session.sessionName = event.name;
290
+ if (event.message?.usage)
291
+ session.contextUsage = { ...(session.contextUsage || {}), usage: event.message.usage };
292
+ renderLiveTabs();
293
+ }
294
+ if (sessionId !== activeLiveSessionId || !viewingActiveSession) {
295
+ if (event.type === 'extension_ui_request')
296
+ queueExtensionUIRequest(event, sessionId);
297
+ return;
298
+ }
299
+ }
300
+ handleRPCEvent(event, sessionId);
301
+ });
302
+ wsClient.addEventListener('serverError', (e) => {
303
+ messageRenderer.renderError(e.detail.message);
304
+ });
305
+ wsClient.addEventListener('stateUpdate', (e) => {
306
+ const detail = e.detail;
307
+ const wasViewingLive = viewingActiveSession;
308
+ const launcherVisible = launcherPanel.isVisible();
309
+ hasReceivedInitialServerState = true;
310
+ setLiveSessions(detail.liveSessions || []);
311
+ if (!hasRestoredInitialLiveSession || (wasViewingLive && !launcherVisible)) {
312
+ hasRestoredInitialLiveSession = true;
313
+ restoreActiveLiveSession();
314
+ }
315
+ else {
316
+ if (activeLiveSessionId && !liveSessions.some(s => s.id === activeLiveSessionId)) {
317
+ activeLiveSessionId = null;
318
+ localStorage.removeItem('tau-active-live-session-id');
319
+ renderQueuedMessages();
320
+ renderLiveTabs();
321
+ }
322
+ updateLiveSessionInputState();
323
+ updateLiveSessionIndicators();
324
+ }
325
+ });
326
+ wsClient.addEventListener('liveSessionCreated', (e) => {
327
+ upsertLiveSession(e.detail);
328
+ });
329
+ wsClient.addEventListener('liveSessionUpdated', (e) => {
330
+ const detail = e.detail;
331
+ upsertLiveSession(detail);
332
+ if (detail?.id === activeLiveSessionId)
333
+ applyActiveSessionMetadata(detail);
334
+ });
335
+ wsClient.addEventListener('liveSessionClosed', (e) => {
336
+ handleLiveSessionClosed(e.detail.sessionId);
337
+ });
338
+ // Receive a full live-session state snapshot.
339
+ wsClient.addEventListener('liveSessionSnapshot', (e) => {
340
+ applyLiveSessionSnapshot(e.detail);
341
+ });
342
+ // ═══════════════════════════════════════
343
+ // Live-session tabs
344
+ // ═══════════════════════════════════════
345
+ const liveTabsList = document.getElementById('live-tabs-list');
346
+ const liveTabAddBtn = document.getElementById('live-tab-add');
347
+ const newLiveSessionOverlay = document.getElementById('new-live-session-overlay');
348
+ const newLiveSessionModal = document.getElementById('new-live-session-modal');
349
+ const newLiveSessionForm = document.getElementById('new-live-session-form');
350
+ const newLiveSessionCwd = document.getElementById('new-live-session-cwd');
351
+ const newLiveSessionModel = document.getElementById('new-live-session-model');
352
+ const newLiveSessionProjects = document.getElementById('new-live-session-projects');
353
+ const newLiveSessionSubmit = document.getElementById('new-live-session-submit');
354
+ function setLiveSessions(sessions) {
355
+ liveSessions = sessions || [];
356
+ liveInstances = liveSessions.map(s => ({ sessionFile: s.sessionFile, cwd: s.cwd, port: location.port }));
357
+ renderLiveTabs();
358
+ updateLiveSessionIndicators();
359
+ }
360
+ function handleLiveSessionClosed(closedId) {
361
+ if (!closedId)
362
+ return;
363
+ liveSessions = liveSessions.filter(s => s.id !== closedId);
364
+ messageQueue = messageQueue.filter(cmd => cmd.sessionId !== closedId);
365
+ pendingExtensionUIRequests = pendingExtensionUIRequests.filter(req => req.sessionId !== closedId);
366
+ if (dialogHandler.currentRequest?.sessionId === closedId) {
367
+ dialogHandler.clearCurrentDialog();
368
+ processQueuedExtensionUIRequest();
369
+ }
370
+ renderQueuedMessages();
371
+ if (activeLiveSessionId === closedId) {
372
+ const wasViewingActive = viewingActiveSession;
373
+ activeLiveSessionId = null;
374
+ localStorage.removeItem('tau-active-live-session-id');
375
+ activeLiveSessionFile = null;
376
+ currentStreamingElement = null;
377
+ currentStreamingThinking = '';
378
+ currentStreamingText = '';
379
+ state.reset();
380
+ showTypingIndicator(false);
381
+ if (wasViewingActive) {
382
+ messageRenderer.clear();
383
+ toolCardRenderer.clear();
384
+ const next = getMostRecentLiveSession();
385
+ if (next)
386
+ selectLiveSession(next.id);
387
+ else {
388
+ viewingActiveSession = false;
389
+ messageRenderer.renderWelcome();
390
+ updateLiveSessionInputState();
391
+ updateUI();
392
+ }
393
+ }
394
+ else {
395
+ updateLiveSessionInputState();
396
+ updateUI();
397
+ }
398
+ }
399
+ liveInstances = liveSessions.map(s => ({ sessionFile: s.sessionFile, cwd: s.cwd, port: location.port }));
400
+ renderLiveTabs();
401
+ updateLiveSessionIndicators();
402
+ }
403
+ function upsertLiveSession(session) {
404
+ if (!session)
405
+ return;
406
+ const idx = liveSessions.findIndex(s => s.id === session.id);
407
+ let shouldRenderTabs = false;
408
+ if (idx >= 0) {
409
+ const before = liveTabSignature(liveSessions[idx]);
410
+ liveSessions[idx] = { ...liveSessions[idx], ...session };
411
+ shouldRenderTabs = before !== liveTabSignature(liveSessions[idx]);
412
+ }
413
+ else {
414
+ liveSessions.push(session);
415
+ shouldRenderTabs = true;
416
+ }
417
+ liveInstances = liveSessions.map(s => ({ sessionFile: s.sessionFile, cwd: s.cwd, port: location.port }));
418
+ if (shouldRenderTabs)
419
+ renderLiveTabs();
420
+ updateLiveSessionIndicators();
421
+ }
422
+ function getMostRecentLiveSession() {
423
+ return [...liveSessions].sort((a, b) => new Date(b.lastActiveAt || b.createdAt || 0).getTime() - new Date(a.lastActiveAt || a.createdAt || 0).getTime())[0] || null;
424
+ }
425
+ function basename(p) {
426
+ return (p || '').split(/[/\\]/).filter(Boolean).pop() || p || 'session';
427
+ }
428
+ function compactModelLabel(session) {
429
+ const raw = session.modelLabel || session.modelSpec || session.model?.id || session.model?.name || 'default';
430
+ return String(raw).replace(/^.*\//, '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
431
+ }
432
+ function liveTabSignature(session) {
433
+ return [
434
+ session.sessionName || basename(session.cwd || ''),
435
+ compactModelLabel(session),
436
+ session.cwd || '',
437
+ session.modelSpec || '',
438
+ session.isStreaming ? 'streaming' : 'idle',
439
+ hasPendingExtensionUIRequest(session.id) ? 'ui' : '',
440
+ ].join('\u001f');
441
+ }
442
+ function renderLiveTabs() {
443
+ if (!liveTabsList)
444
+ return;
445
+ const existing = new Map();
446
+ liveTabsList.querySelectorAll('.live-tab').forEach((tab) => {
447
+ if (tab.dataset.sessionId)
448
+ existing.set(tab.dataset.sessionId, tab);
449
+ });
450
+ const seen = new Set();
451
+ liveSessions.forEach((session, index) => {
452
+ let tab = existing.get(session.id);
453
+ if (!tab) {
454
+ tab = document.createElement('button');
455
+ tab.type = 'button';
456
+ tab.className = 'live-tab';
457
+ tab.dataset.sessionId = session.id;
458
+ tab.addEventListener('click', () => selectLiveSession(session.id));
459
+ }
460
+ seen.add(session.id);
461
+ tab.classList.toggle('active', session.id === activeLiveSessionId);
462
+ tab.title = `${session.cwd || ''}${session.modelSpec ? ` • ${session.modelSpec}` : ''}`;
463
+ const signature = liveTabSignature(session);
464
+ if (tab.dataset.signature !== signature) {
465
+ tab.dataset.signature = signature;
466
+ tab.innerHTML = `
467
+ ${session.isStreaming ? '<span class="live-tab-streaming-dot"></span>' : ''}
468
+ ${hasPendingExtensionUIRequest(session.id) ? '<span class="live-tab-ui-dot" title="Waiting for response">?</span>' : ''}
469
+ <span class="live-tab-title">${escapeHtml(session.sessionName || basename(session.cwd || ''))}</span>
470
+ <span class="live-tab-model">${escapeHtml(compactModelLabel(session))}</span>
471
+ <span class="live-tab-close" title="Close Tau tab">×</span>
472
+ `;
473
+ tab.querySelector('.live-tab-close')?.addEventListener('click', (ev) => {
474
+ ev.stopPropagation();
475
+ closeLiveSession(session.id);
476
+ });
477
+ }
478
+ const currentAtIndex = liveTabsList.children[index];
479
+ if (currentAtIndex !== tab)
480
+ liveTabsList.insertBefore(tab, currentAtIndex || null);
481
+ });
482
+ for (const [id, tab] of existing) {
483
+ if (!seen.has(id))
484
+ tab.remove();
485
+ }
486
+ }
487
+ function restoreActiveLiveSession() {
488
+ const saved = activeLiveSessionId && liveSessions.find(s => s.id === activeLiveSessionId);
489
+ const next = saved || getMostRecentLiveSession();
490
+ if (next) {
491
+ selectLiveSession(next.id);
492
+ }
493
+ else {
494
+ activeLiveSessionId = null;
495
+ viewingActiveSession = false;
496
+ activeLiveSessionFile = null;
497
+ localStorage.removeItem('tau-active-live-session-id');
498
+ state.reset();
499
+ renderQueuedMessages();
500
+ renderLiveTabs();
501
+ updateLiveSessionInputState();
502
+ }
503
+ }
504
+ async function selectLiveSession(id) {
505
+ const session = liveSessions.find(s => s.id === id);
506
+ if (!session)
507
+ return;
508
+ suspendCurrentDialogForTabSwitch(id);
509
+ launcherPanel.hide();
510
+ activeLiveSessionId = id;
511
+ localStorage.setItem('tau-active-live-session-id', id);
512
+ viewingActiveSession = true;
513
+ activeLiveSessionFile = session.sessionFile || null;
514
+ renderLiveTabs();
515
+ renderQueuedMessages();
516
+ applyActiveSessionMetadata(session);
517
+ currentStreamingElement = null;
518
+ currentStreamingThinking = '';
519
+ currentStreamingText = '';
520
+ state.reset();
521
+ state.setStreaming(!!session.isStreaming);
522
+ messageRenderer.clear();
523
+ toolCardRenderer.clear();
524
+ try {
525
+ const res = await fetch(`/api/live-sessions/${encodeURIComponent(id)}/snapshot`);
526
+ const data = await res.json();
527
+ if (!res.ok || data.error) {
528
+ handleLiveSessionClosed(id);
529
+ throw new Error(data.error || 'Live session not found');
530
+ }
531
+ applyLiveSessionSnapshot({ ...data, sessionId: id });
532
+ }
533
+ catch (e) {
534
+ messageRenderer.renderError((e instanceof Error ? e.message : '') || 'Failed to load live session snapshot');
535
+ return;
536
+ }
537
+ if (!fileSidebar.classList.contains('collapsed'))
538
+ fileBrowser.load();
539
+ updateLiveSessionInputState();
540
+ processQueuedExtensionUIRequest(id);
541
+ flushQueue();
542
+ }
543
+ function applyActiveSessionMetadata(session) {
544
+ if (!session)
545
+ return;
546
+ // Server is canonical: session.model is always null or a full {provider,id}
547
+ // object, so assign directly. No modelLabel/modelSpec string fallbacks.
548
+ modelPickerController.setModelState(session.model || '', session.thinkingLevel || 'off');
549
+ }
550
+ async function closeLiveSession(id) {
551
+ const session = liveSessions.find(s => s.id === id);
552
+ if (!session)
553
+ return;
554
+ const hasQueuedMessages = messageQueue.some(cmd => cmd.sessionId === id);
555
+ if (session.isStreaming || hasQueuedMessages) {
556
+ const reason = session.isStreaming && hasQueuedMessages
557
+ ? 'This Tau tab is streaming and has queued unsent messages. Close it, terminate the Pi session, and discard the queue?'
558
+ : session.isStreaming
559
+ ? 'This Tau tab is streaming. Close it and terminate the Pi session?'
560
+ : 'This Tau tab has queued unsent messages. Close it and discard them?';
561
+ if (!confirm(reason))
562
+ return;
563
+ }
564
+ try {
565
+ await fetch(`/api/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
566
+ }
567
+ catch (e) {
568
+ messageRenderer.renderError('Failed to close Tau tab');
569
+ }
570
+ }
571
+ async function loadProjectChips() {
572
+ if (!newLiveSessionProjects)
573
+ return;
574
+ newLiveSessionProjects.innerHTML = '';
575
+ try {
576
+ const res = await fetch('/api/projects');
577
+ const data = await res.json();
578
+ for (const project of data.projects || []) {
579
+ const chip = document.createElement('button');
580
+ chip.type = 'button';
581
+ chip.className = 'project-chip';
582
+ chip.textContent = project.name;
583
+ chip.title = project.path;
584
+ chip.addEventListener('click', () => { newLiveSessionCwd.value = project.path; });
585
+ newLiveSessionProjects.appendChild(chip);
586
+ }
587
+ }
588
+ catch { }
589
+ }
590
+ function openNewLiveSessionModal() {
591
+ newLiveSessionOverlay?.classList.remove('hidden');
592
+ newLiveSessionModal?.classList.remove('hidden');
593
+ newLiveSessionSubmit.disabled = false;
594
+ if (!newLiveSessionCwd.value)
595
+ newLiveSessionCwd.value = liveSessions.find(s => s.id === activeLiveSessionId)?.cwd || '';
596
+ loadProjectChips();
597
+ requestAnimationFrame(() => newLiveSessionCwd?.focus());
598
+ }
599
+ function closeNewLiveSessionModal() {
600
+ newLiveSessionOverlay?.classList.add('hidden');
601
+ newLiveSessionModal?.classList.add('hidden');
602
+ }
603
+ liveTabAddBtn?.addEventListener('click', openNewLiveSessionModal);
604
+ document.getElementById('new-live-session-close')?.addEventListener('click', closeNewLiveSessionModal);
605
+ document.getElementById('new-live-session-cancel')?.addEventListener('click', closeNewLiveSessionModal);
606
+ newLiveSessionOverlay?.addEventListener('click', closeNewLiveSessionModal);
607
+ newLiveSessionForm?.addEventListener('submit', async (e) => {
608
+ e.preventDefault();
609
+ const cwd = newLiveSessionCwd.value.trim();
610
+ if (!cwd)
611
+ return;
612
+ newLiveSessionSubmit.disabled = true;
613
+ newLiveSessionSubmit.textContent = 'Starting…';
614
+ try {
615
+ const res = await fetch('/api/live-sessions', {
616
+ method: 'POST',
617
+ headers: { 'Content-Type': 'application/json' },
618
+ body: JSON.stringify({ cwd, model: newLiveSessionModel.value.trim() }),
619
+ });
620
+ const data = await res.json();
621
+ if (!res.ok || data.error)
622
+ throw new Error(data.error || 'Failed to create Tau tab');
623
+ upsertLiveSession(data.session);
624
+ closeNewLiveSessionModal();
625
+ newLiveSessionModel.value = '';
626
+ await selectLiveSession(data.session.id);
627
+ }
628
+ catch (err) {
629
+ messageRenderer.renderError((err instanceof Error ? err.message : '') || 'Failed to create Tau tab');
630
+ }
631
+ finally {
632
+ newLiveSessionSubmit.disabled = false;
633
+ newLiveSessionSubmit.textContent = 'Create tab';
634
+ }
635
+ });
636
+ // ═══════════════════════════════════════
637
+ // RPC event handlers
638
+ // ═══════════════════════════════════════
639
+ function handleRPCEvent(event, sessionId = null) {
640
+ switch (event.type) {
641
+ case 'agent_start':
642
+ case 'turn_start':
643
+ handleAgentStart();
644
+ break;
645
+ case 'agent_end':
646
+ case 'turn_end':
647
+ handleAgentEnd();
648
+ break;
649
+ case 'message_start':
650
+ handleMessageStart(event.message);
651
+ break;
652
+ case 'message_update':
653
+ handleMessageUpdate(event);
654
+ break;
655
+ case 'message_end':
656
+ handleMessageEnd(event.message);
657
+ break;
658
+ case 'tool_execution_start':
659
+ handleToolExecutionStart(event);
660
+ break;
661
+ case 'tool_execution_update':
662
+ handleToolExecutionUpdate(event);
663
+ break;
664
+ case 'tool_execution_end':
665
+ handleToolExecutionEnd(event);
666
+ break;
667
+ case 'auto_compaction_start':
668
+ handleCompactionStart();
669
+ break;
670
+ case 'auto_compaction_end':
671
+ handleCompactionEnd(event);
672
+ break;
673
+ case 'extension_ui_request':
674
+ handleExtensionUIRequest(event, sessionId);
675
+ break;
676
+ case 'extension_error':
677
+ messageRenderer.renderError(`Extension error: ${event.error}`);
678
+ break;
679
+ case 'session_name':
680
+ // Auto-title: update sidebar with new session name
681
+ if (event.name) {
682
+ const activeItem = document.querySelector('.session-item.active .session-title');
683
+ if (activeItem)
684
+ activeItem.textContent = event.name;
685
+ }
686
+ break;
687
+ }
688
+ }
689
+ function handleCompactionStart() {
690
+ const el = document.createElement('div');
691
+ el.className = 'system-message compaction-message';
692
+ el.id = 'compaction-indicator';
693
+ el.innerHTML = '<span class="compaction-spinner">⟳</span> Compacting context…';
694
+ messagesContainer.appendChild(el);
695
+ scrollToBottom();
696
+ }
697
+ function handleCompactionEnd(event) {
698
+ const indicator = document.getElementById('compaction-indicator');
699
+ if (indicator) {
700
+ const summary = event.summary ? ` — ${event.summary}` : '';
701
+ indicator.innerHTML = `✓ Context compacted${summary}`;
702
+ indicator.classList.add('compaction-done');
703
+ }
704
+ // Reset token tracking — the stats refresh below and the next message
705
+ // bring in fresh post-compaction numbers.
706
+ lastInputTokens = 0;
707
+ updateContextPill();
708
+ hideCompactButton();
709
+ void sessionStatsCard.refresh();
710
+ }
711
+ function handleAgentStart() {
712
+ state.setStreaming(true);
713
+ showTypingIndicator(true);
714
+ updateUI();
715
+ }
716
+ function handleAgentEnd() {
717
+ const wasStreaming = state.isStreaming;
718
+ state.setStreaming(false);
719
+ showTypingIndicator(false);
720
+ currentStreamingElement = null;
721
+ currentStreamingText = '';
722
+ updateUI();
723
+ // A turn just finished — pull authoritative post-turn stats from pi so the
724
+ // context pill and stats card stop relying on incremental usage events.
725
+ // Guard with wasStreaming so paired turn_end/agent_end events do not
726
+ // double-fetch for the same completed turn.
727
+ if (wasStreaming)
728
+ void sessionStatsCard.refresh();
729
+ // Notify via tab title if unfocused. Guard with wasStreaming so paired
730
+ // turn_end/agent_end events do not double-count the same completed turn.
731
+ if (wasStreaming && !hasFocus) {
732
+ unreadCount++;
733
+ document.title = `(${unreadCount}) ● ${originalTitle}`;
734
+ }
735
+ }
736
+ let currentStreamingThinking = '';
737
+ function handleMessageStart(message) {
738
+ if (message.role === 'assistant') {
739
+ currentStreamingText = '';
740
+ currentStreamingThinking = '';
741
+ currentStreamingElement = messageRenderer.renderAssistantMessage({ content: '' }, true);
742
+ }
743
+ else if (message.role === 'user') {
744
+ // User messages can echo back via backend events; only render if we did
745
+ // not just send this message ourselves.
746
+ if (!lastSentMessage || getMessageText(message) !== lastSentMessage) {
747
+ const content = getMessageText(message);
748
+ if (content) {
749
+ messageRenderer.renderUserMessage({ content });
750
+ }
751
+ }
752
+ lastSentMessage = null;
753
+ }
754
+ }
755
+ function getMessageText(message) {
756
+ if (typeof message.content === 'string')
757
+ return message.content;
758
+ if (Array.isArray(message.content)) {
759
+ return message.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
760
+ }
761
+ return '';
762
+ }
763
+ function getMessageThinking(message) {
764
+ if (!Array.isArray(message?.content))
765
+ return '';
766
+ return message.content
767
+ .filter(b => b.type === 'thinking')
768
+ .map(b => b.thinking || b.text || '')
769
+ .filter(Boolean)
770
+ .join('\n');
771
+ }
772
+ function handleMessageUpdate(event) {
773
+ const { assistantMessageEvent } = event;
774
+ if (!assistantMessageEvent)
775
+ return;
776
+ if (assistantMessageEvent.type === 'thinking_delta') {
777
+ if (!currentStreamingElement) {
778
+ currentStreamingElement = messageRenderer.renderAssistantMessage({ content: '' }, true);
779
+ }
780
+ currentStreamingThinking += assistantMessageEvent.delta;
781
+ if (currentStreamingElement) {
782
+ messageRenderer.updateStreamingThinking(currentStreamingElement, currentStreamingThinking);
783
+ }
784
+ }
785
+ else if (assistantMessageEvent.type === 'text_delta') {
786
+ if (!currentStreamingElement) {
787
+ currentStreamingElement = messageRenderer.renderAssistantMessage({ content: '' }, true);
788
+ }
789
+ currentStreamingText += assistantMessageEvent.delta;
790
+ if (currentStreamingElement) {
791
+ messageRenderer.updateStreamingMessage(currentStreamingElement, currentStreamingText);
792
+ }
793
+ }
794
+ }
795
+ function handleMessageEnd(message) {
796
+ if (!currentStreamingElement && message?.role === 'assistant') {
797
+ messageRenderer.renderAssistantMessage(message, false, true);
798
+ }
799
+ if (currentStreamingElement) {
800
+ // If this client attached mid-stream, local deltas may be incomplete. The
801
+ // message_end payload is authoritative, so refresh the streaming DOM from it
802
+ // before finalizing.
803
+ if (message?.role === 'assistant') {
804
+ const finalText = getMessageText(message);
805
+ const finalThinking = getMessageThinking(message);
806
+ if (finalText && finalText.length >= currentStreamingText.length) {
807
+ currentStreamingText = finalText;
808
+ messageRenderer.updateStreamingMessage(currentStreamingElement, currentStreamingText);
809
+ }
810
+ if (finalThinking && finalThinking.length >= currentStreamingThinking.length) {
811
+ currentStreamingThinking = finalThinking;
812
+ messageRenderer.updateStreamingThinking(currentStreamingElement, currentStreamingThinking);
813
+ }
814
+ }
815
+ // Pass usage info for cost display
816
+ const usage = message?.usage || null;
817
+ // Pass thinking content so finalize can render the thinking block
818
+ messageRenderer.finalizeStreamingMessage(currentStreamingElement, usage, currentStreamingThinking);
819
+ currentStreamingElement = null;
820
+ currentStreamingThinking = '';
821
+ currentStreamingText = '';
822
+ // Track session cost and tokens
823
+ if (usage?.cost?.total) {
824
+ sessionTotalCost += usage.cost.total;
825
+ }
826
+ if (usage?.input) {
827
+ lastInputTokens = usage.input + (usage.cacheRead || 0);
828
+ lastUsage = usage;
829
+ }
830
+ updateContextPill();
831
+ showNewMessageBadge();
832
+ }
833
+ }
834
+ function handleToolExecutionStart(event) {
835
+ const { toolCallId, toolName, args } = event;
836
+ if (!toolCallId)
837
+ return;
838
+ state.addToolExecution(toolCallId, {
839
+ toolName,
840
+ args,
841
+ status: 'pending',
842
+ });
843
+ const exec = state.getToolExecution(toolCallId);
844
+ if (exec)
845
+ toolCardRenderer.createToolCard(exec);
846
+ }
847
+ function handleToolExecutionUpdate(event) {
848
+ const { toolCallId, partialResult } = event;
849
+ if (!toolCallId)
850
+ return;
851
+ const output = formatToolOutput(partialResult);
852
+ state.updateToolExecution(toolCallId, {
853
+ status: 'streaming',
854
+ output,
855
+ });
856
+ const exec = state.getToolExecution(toolCallId);
857
+ if (exec)
858
+ toolCardRenderer.updateToolCard(exec);
859
+ }
860
+ function handleToolExecutionEnd(event) {
861
+ const { toolCallId, result, isError } = event;
862
+ if (!toolCallId)
863
+ return;
864
+ const output = formatToolOutput(result);
865
+ state.updateToolExecution(toolCallId, {
866
+ status: isError ? 'error' : 'complete',
867
+ output,
868
+ isError,
869
+ });
870
+ toolCardRenderer.finalizeToolCard(toolCallId, result, isError ?? false);
871
+ }
872
+ function hasPendingExtensionUIRequest(sessionId) {
873
+ return pendingExtensionUIRequests.some(req => req.sessionId === sessionId);
874
+ }
875
+ function queueExtensionUIRequest(event, sessionId) {
876
+ if (!sessionId) {
877
+ handleExtensionUIRequest(event, sessionId);
878
+ return;
879
+ }
880
+ if (!pendingExtensionUIRequests.some(req => req.sessionId === sessionId && req.event?.id === event.id)) {
881
+ pendingExtensionUIRequests.push({ sessionId, event });
882
+ }
883
+ renderLiveTabs();
884
+ }
885
+ function processQueuedExtensionUIRequest(sessionId = activeLiveSessionId) {
886
+ if (!sessionId || !viewingActiveSession || sessionId !== activeLiveSessionId || dialogHandler.currentRequest)
887
+ return;
888
+ const idx = pendingExtensionUIRequests.findIndex(req => req.sessionId === sessionId);
889
+ if (idx === -1)
890
+ return;
891
+ const [{ event }] = pendingExtensionUIRequests.splice(idx, 1);
892
+ renderLiveTabs();
893
+ handleExtensionUIRequest(event, sessionId);
894
+ }
895
+ function suspendCurrentDialogForTabSwitch(nextSessionId) {
896
+ const current = dialogHandler.currentRequest;
897
+ if (!current?.sessionId || current.sessionId === nextSessionId)
898
+ return;
899
+ const event = current.request;
900
+ if (event && !pendingExtensionUIRequests.some(req => req.sessionId === current.sessionId && req.event?.id === event.id)) {
901
+ pendingExtensionUIRequests.unshift({ sessionId: current.sessionId, event });
902
+ }
903
+ dialogHandler.clearCurrentDialog();
904
+ renderLiveTabs();
905
+ }
906
+ function handleExtensionUIRequest(event, sessionId = null) {
907
+ const request = (sessionId ? { ...event, sessionId } : event);
908
+ switch (event.method) {
909
+ case 'select':
910
+ dialogHandler.showSelect(request);
911
+ break;
912
+ case 'confirm':
913
+ dialogHandler.showConfirm(request);
914
+ break;
915
+ case 'input':
916
+ dialogHandler.showInput(request);
917
+ break;
918
+ case 'editor':
919
+ dialogHandler.showEditor(request);
920
+ break;
921
+ case 'notify':
922
+ dialogHandler.showNotification(request);
923
+ break;
924
+ default:
925
+ console.warn('[App] Unknown extension UI method:', event.method);
926
+ }
927
+ }
928
+ function formatToolOutput(result) {
929
+ if (!result)
930
+ return '';
931
+ const r = result;
932
+ if (r.content && Array.isArray(r.content)) {
933
+ return r.content
934
+ .map((block) => {
935
+ if (block.type === 'text')
936
+ return block.text;
937
+ return JSON.stringify(block);
938
+ })
939
+ .join('\n');
940
+ }
941
+ return JSON.stringify(result, null, 2);
942
+ }
943
+ // ═══════════════════════════════════════
944
+ // Input handling — textarea with auto-resize
945
+ // ═══════════════════════════════════════
946
+ chatForm.addEventListener('submit', (e) => {
947
+ e.preventDefault();
948
+ sendMessage();
949
+ });
950
+ messageInput.addEventListener('keydown', (e) => {
951
+ // Enter sends, Shift+Enter inserts newline
952
+ if (e.key === 'Enter' && !e.shiftKey) {
953
+ e.preventDefault();
954
+ sendMessage();
955
+ }
956
+ });
957
+ // Auto-resize textarea
958
+ messageInput.addEventListener('input', () => {
959
+ messageInput.style.height = 'auto';
960
+ messageInput.style.height = Math.min(messageInput.scrollHeight, 200) + 'px';
961
+ });
962
+ // ═══════════════════════════════════════
963
+ // Attachments (images + file browser paths)
964
+ // ═══════════════════════════════════════
965
+ const attachBtn = document.getElementById('attach-btn');
966
+ const imageInput = document.getElementById('image-input');
967
+ const imagePreviews = document.getElementById('image-previews');
968
+ let pendingImages = []; // { data: base64, mimeType }
969
+ let pendingFilePaths = []; // { path, name, ext } — from file browser (populated by callback above)
970
+ const MAX_IMAGE_DIM = 2048;
971
+ const VALID_MIME_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
972
+ const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico']);
973
+ function getFileChipIcon(name) {
974
+ return getFileIcon(name || 'file', false);
975
+ }
976
+ function processImageFile(file) {
977
+ return new Promise((resolve, reject) => {
978
+ const mimeType = VALID_MIME_TYPES.includes(file.type) ? file.type : 'image/png';
979
+ const reader = new FileReader();
980
+ reader.onerror = () => reject(new Error('Failed to read file'));
981
+ reader.onload = () => {
982
+ const img = new Image();
983
+ img.onload = () => {
984
+ let { width, height } = img;
985
+ if (width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM) {
986
+ const scale = MAX_IMAGE_DIM / Math.max(width, height);
987
+ width = Math.round(width * scale);
988
+ height = Math.round(height * scale);
989
+ }
990
+ const canvas = document.createElement('canvas');
991
+ canvas.width = width;
992
+ canvas.height = height;
993
+ canvas.getContext('2d')?.drawImage(img, 0, 0, width, height);
994
+ const outputMime = (mimeType === 'image/jpeg') ? 'image/jpeg' : 'image/png';
995
+ const quality = (outputMime === 'image/jpeg') ? 0.85 : undefined;
996
+ const dataUrl = canvas.toDataURL(outputMime, quality);
997
+ const base64 = dataUrl.split(',')[1];
998
+ if (!base64) {
999
+ reject(new Error('Failed to encode image'));
1000
+ return;
1001
+ }
1002
+ resolve({ data: base64, mimeType: outputMime });
1003
+ };
1004
+ img.onerror = () => reject(new Error('Failed to decode image'));
1005
+ img.src = String(reader.result || '');
1006
+ };
1007
+ reader.readAsDataURL(file);
1008
+ });
1009
+ }
1010
+ async function addAttachments(files) {
1011
+ const list = Array.from(files); // snapshot before any await
1012
+ for (const file of list) {
1013
+ if (!file.type.startsWith('image/'))
1014
+ continue;
1015
+ try {
1016
+ pendingImages.push(await processImageFile(file));
1017
+ }
1018
+ catch (e) {
1019
+ console.error('[Tau] Image processing failed:', e);
1020
+ }
1021
+ }
1022
+ renderAttachmentPreviews();
1023
+ }
1024
+ attachBtn.addEventListener('click', () => imageInput.click());
1025
+ imageInput.addEventListener('change', () => {
1026
+ addAttachments(imageInput.files ?? []);
1027
+ imageInput.value = '';
1028
+ });
1029
+ // Drag & drop on input
1030
+ messageInput.addEventListener('dragover', (e) => { e.preventDefault(); });
1031
+ messageInput.addEventListener('drop', (e) => {
1032
+ e.preventDefault();
1033
+ if (e.dataTransfer && e.dataTransfer.files.length > 0)
1034
+ addAttachments(e.dataTransfer.files);
1035
+ });
1036
+ // Paste images
1037
+ messageInput.addEventListener('paste', (e) => {
1038
+ if (!e.clipboardData)
1039
+ return;
1040
+ const files = [];
1041
+ for (const item of e.clipboardData.items) {
1042
+ if (!item.type.startsWith('image/'))
1043
+ continue;
1044
+ files.push(item.getAsFile());
1045
+ }
1046
+ if (files.length)
1047
+ addAttachments(files);
1048
+ });
1049
+ function makeRemoveBtn(onClick) {
1050
+ const btn = document.createElement('button');
1051
+ btn.className = 'image-preview-remove';
1052
+ btn.setAttribute('aria-label', 'Remove');
1053
+ btn.textContent = '✕';
1054
+ btn.addEventListener('click', onClick);
1055
+ return btn;
1056
+ }
1057
+ function renderAttachmentPreviews() {
1058
+ imagePreviews.innerHTML = '';
1059
+ const hasAny = pendingImages.length > 0 || pendingFilePaths.length > 0;
1060
+ if (!hasAny) {
1061
+ imagePreviews.classList.add('hidden');
1062
+ return;
1063
+ }
1064
+ imagePreviews.classList.remove('hidden');
1065
+ // Binary image chips
1066
+ pendingImages.forEach((img, i) => {
1067
+ const el = document.createElement('div');
1068
+ el.className = 'image-preview';
1069
+ const thumb = document.createElement('img');
1070
+ thumb.src = `data:${img.mimeType};base64,${img.data}`;
1071
+ el.appendChild(thumb);
1072
+ el.appendChild(makeRemoveBtn(() => { pendingImages.splice(i, 1); renderAttachmentPreviews(); }));
1073
+ imagePreviews.appendChild(el);
1074
+ });
1075
+ // File browser path chips
1076
+ pendingFilePaths.forEach((fp, i) => {
1077
+ const el = document.createElement('div');
1078
+ const removeBtn = makeRemoveBtn(() => {
1079
+ const withSpace = fp.path + ' ';
1080
+ messageInput.value = messageInput.value.includes(withSpace)
1081
+ ? messageInput.value.replace(withSpace, '')
1082
+ : messageInput.value.replace(fp.path, '');
1083
+ messageInput.dispatchEvent(new Event('input'));
1084
+ pendingFilePaths.splice(i, 1);
1085
+ renderAttachmentPreviews();
1086
+ });
1087
+ if (IMAGE_EXTS.has(fp.ext)) {
1088
+ el.className = 'image-preview';
1089
+ el.title = fp.path;
1090
+ const thumb = document.createElement('img');
1091
+ thumb.style.cssText = 'width:100%;height:100%;object-fit:cover';
1092
+ const previewParams = new URLSearchParams({ path: fp.path });
1093
+ if (fp.sessionId)
1094
+ previewParams.set('sessionId', fp.sessionId);
1095
+ thumb.src = `/api/file/preview?${previewParams.toString()}`;
1096
+ thumb.onerror = () => {
1097
+ el.classList.add('file-chip');
1098
+ thumb.remove();
1099
+ const icon = document.createElement('span');
1100
+ icon.className = 'file-chip-icon';
1101
+ icon.textContent = getFileChipIcon(fp.name);
1102
+ const label = document.createElement('span');
1103
+ label.className = 'file-chip-name';
1104
+ label.textContent = fp.name;
1105
+ el.insertBefore(label, removeBtn);
1106
+ el.insertBefore(icon, label);
1107
+ };
1108
+ el.appendChild(thumb);
1109
+ }
1110
+ else {
1111
+ el.className = 'image-preview file-chip';
1112
+ el.title = fp.path;
1113
+ const icon = document.createElement('span');
1114
+ icon.className = 'file-chip-icon';
1115
+ icon.textContent = getFileChipIcon(fp.ext);
1116
+ const label = document.createElement('span');
1117
+ label.className = 'file-chip-name';
1118
+ label.textContent = fp.name;
1119
+ el.appendChild(icon);
1120
+ el.appendChild(label);
1121
+ }
1122
+ el.appendChild(removeBtn);
1123
+ imagePreviews.appendChild(el);
1124
+ });
1125
+ }
1126
+ // ═══════════════════════════════════════
1127
+ // Send message (with images)
1128
+ // ═══════════════════════════════════════
1129
+ let messageQueue = [];
1130
+ function sendMessage() {
1131
+ const message = messageInput.value.trim();
1132
+ if (!message && pendingImages.length === 0)
1133
+ return;
1134
+ messageInput.value = '';
1135
+ messageInput.style.height = 'auto';
1136
+ const cmd = { type: 'prompt', message: message || '(see attached image)' };
1137
+ if (pendingImages.length > 0) {
1138
+ cmd.images = pendingImages.map(img => {
1139
+ console.log(`[Tau] Sending image: mimeType=${img.mimeType}, dataLen=${img.data?.length}`);
1140
+ return { type: 'image', data: img.data, mimeType: img.mimeType || 'image/png' };
1141
+ });
1142
+ pendingImages = [];
1143
+ }
1144
+ pendingFilePaths = [];
1145
+ renderAttachmentPreviews();
1146
+ if (!activeLiveSessionId) {
1147
+ messageRenderer.renderError('Create or select a Tau tab first.');
1148
+ updateLiveSessionInputState();
1149
+ return;
1150
+ }
1151
+ cmd.sessionId = activeLiveSessionId;
1152
+ if (state.isStreaming) {
1153
+ // Queue it for the current Tau tab only; do not let tab switches retarget it.
1154
+ messageQueue.push(cmd);
1155
+ lastSentMessage = message;
1156
+ renderQueuedMessages();
1157
+ return;
1158
+ }
1159
+ lastSentMessage = message;
1160
+ messageRenderer.renderUserMessage({ content: message, images: cmd.images });
1161
+ wsClient.send(cmd);
1162
+ }
1163
+ const queuedMessagesEl = document.getElementById('queued-messages');
1164
+ function renderQueuedMessages() {
1165
+ queuedMessagesEl.innerHTML = '';
1166
+ if (messageQueue.length === 0) {
1167
+ queuedMessagesEl.classList.add('hidden');
1168
+ return;
1169
+ }
1170
+ queuedMessagesEl.classList.remove('hidden');
1171
+ messageQueue.forEach((cmd, i) => {
1172
+ if (cmd.sessionId !== activeLiveSessionId)
1173
+ return;
1174
+ const el = document.createElement('div');
1175
+ el.className = 'queued-msg';
1176
+ el.innerHTML = `
1177
+ <span class="queued-msg-label">Queued</span>
1178
+ <span class="queued-msg-text">${escapeHtml(cmd.message || '')}</span>
1179
+ <button class="queued-msg-cancel" title="Cancel">×</button>
1180
+ `;
1181
+ el.querySelector('.queued-msg-cancel')?.addEventListener('click', () => {
1182
+ messageQueue.splice(i, 1);
1183
+ renderQueuedMessages();
1184
+ });
1185
+ queuedMessagesEl.appendChild(el);
1186
+ });
1187
+ queuedMessagesEl.classList.toggle('hidden', queuedMessagesEl.children.length === 0);
1188
+ }
1189
+ function escapeHtml(text) {
1190
+ const div = document.createElement('div');
1191
+ div.textContent = text;
1192
+ return div.innerHTML;
1193
+ }
1194
+ function flushQueue() {
1195
+ if (!activeLiveSessionId || state.isStreaming)
1196
+ return;
1197
+ const idx = messageQueue.findIndex(cmd => cmd.sessionId === activeLiveSessionId);
1198
+ if (idx >= 0) {
1199
+ const [cmd] = messageQueue.splice(idx, 1);
1200
+ lastSentMessage = cmd.message ?? null;
1201
+ messageRenderer.renderUserMessage({ content: cmd.message, images: cmd.images });
1202
+ renderQueuedMessages();
1203
+ wsClient.send(cmd);
1204
+ }
1205
+ }
1206
+ abortBtn.addEventListener('click', () => {
1207
+ if (!viewingActiveSession || !activeLiveSessionId)
1208
+ return;
1209
+ wsClient.send({ type: 'abort', sessionId: activeLiveSessionId });
1210
+ messageRenderer.renderError('Aborted by user');
1211
+ showTypingIndicator(false);
1212
+ });
1213
+ // Command Palette
1214
+ const commandPaletteController = setupCommandPalette([
1215
+ { icon: '🌳', label: 'Session tree', desc: 'Jump to any earlier point in the session tree', action: () => { void treeViewController.open(); } },
1216
+ { icon: '🗜️', label: 'Compact', desc: 'Compact context to save tokens', action: () => rpcCommand({ type: 'compact' }, 'Compacting...') },
1217
+ { icon: '📋', label: 'Export HTML', desc: 'Export session as HTML file', action: () => rpcExportHtml() },
1218
+ { icon: '📊', label: 'Session Stats', desc: 'Show session statistics', action: () => showSessionStats() },
1219
+ { icon: '⬇️', label: 'Expand All Tools', desc: 'Expand all tool cards', action: () => toolCardRenderer.expandAll() },
1220
+ { icon: '⬆️', label: 'Collapse All Tools', desc: 'Collapse all tool cards', action: () => toolCardRenderer.collapseAll() },
1221
+ ]);
1222
+ async function rpcCommand(cmd, statusMsg = '') {
1223
+ try {
1224
+ const backendLocalCommands = new Set(['get_auth', 'set_auth', 'get_available_models']);
1225
+ const needsLiveSession = !cmd.sessionId && !cmd.filePath && !backendLocalCommands.has(cmd.type);
1226
+ if (needsLiveSession && (!viewingActiveSession || !activeLiveSessionId)) {
1227
+ const error = 'Select a live Tau tab first.';
1228
+ setStatusMessage(error, wsClient.ws?.readyState === WebSocket.OPEN ? 'Connected' : 'Disconnected', 3000);
1229
+ return { type: 'response', command: cmd.type, success: false, error };
1230
+ }
1231
+ if (!cmd.sessionId && viewingActiveSession && activeLiveSessionId)
1232
+ cmd = { ...cmd, sessionId: activeLiveSessionId };
1233
+ if (statusMsg)
1234
+ setStatusMessage(statusMsg);
1235
+ const resp = await fetch('/api/rpc', {
1236
+ method: 'POST',
1237
+ headers: { 'Content-Type': 'application/json' },
1238
+ body: JSON.stringify(cmd),
1239
+ });
1240
+ const data = await resp.json();
1241
+ if (data.success) {
1242
+ setStatusMessage('Done', 'Connected', 2000);
1243
+ }
1244
+ else {
1245
+ setStatusMessage(data.error || 'Failed', 'Connected', 3000);
1246
+ }
1247
+ return data;
1248
+ }
1249
+ catch (e) {
1250
+ setStatusMessage('Error', 'Connected', 3000);
1251
+ }
1252
+ }
1253
+ async function rpcExportHtml() {
1254
+ const data = await rpcCommand({ type: 'export_html' }, 'Exporting...');
1255
+ if (data?.success && data.data?.path) {
1256
+ setStatusMessage(`Exported: ${data.data.path}`, 'Connected', 4000);
1257
+ }
1258
+ }
1259
+ async function showSessionStats() {
1260
+ const data = await rpcCommand({ type: 'get_session_stats' }, 'Loading stats...');
1261
+ if (data?.success && data.data) {
1262
+ const s = data.data;
1263
+ const lines = [
1264
+ `📊 Session Stats`,
1265
+ `Messages: ${s.totalMessages} (${s.userMessages} user, ${s.assistantMessages} assistant)`,
1266
+ `Tool calls: ${s.toolCalls}`,
1267
+ ];
1268
+ if (s.tokens) {
1269
+ lines.push(`Context: ~${(s.tokens.input / 1000).toFixed(1)}k tokens`);
1270
+ }
1271
+ messageRenderer.renderSystemMessage(lines.join('\n'));
1272
+ }
1273
+ }
1274
+ // Model Picker
1275
+ const modelPickerController = setupModelPicker({
1276
+ getActiveLiveSessionId: () => activeLiveSessionId,
1277
+ isViewingActiveSession: () => viewingActiveSession,
1278
+ rpcCommand,
1279
+ flashStatusError,
1280
+ escapeHtml,
1281
+ setContextWindowSize(value) { contextWindowSize = value; },
1282
+ updateContextPill,
1283
+ });
1284
+ // Session tree view (modal opened from the header git-branch button, or the
1285
+ // command palette). Selecting an entry moves the session leaf server-side;
1286
+ // the conversation re-renders when the server broadcasts a fresh snapshot.
1287
+ const treeViewController = setupTreeView({
1288
+ getActiveLiveSessionId: () => (viewingActiveSession && activeLiveSessionId ? activeLiveSessionId : null),
1289
+ isStreaming: () => state.isStreaming,
1290
+ setComposerText(text) {
1291
+ messageInput.value = text;
1292
+ // Trigger the textarea auto-resize handler.
1293
+ messageInput.dispatchEvent(new Event('input'));
1294
+ if (!isMobile())
1295
+ messageInput.focus();
1296
+ },
1297
+ flashStatusError,
1298
+ });
1299
+ // ═══════════════════════════════════════
1300
+ // Keyboard shortcuts
1301
+ // ═══════════════════════════════════════
1302
+ document.addEventListener('keydown', (e) => {
1303
+ // Escape — Abort streaming, or close sidebar on mobile
1304
+ if (e.key === 'Escape') {
1305
+ // Close palettes/panels first
1306
+ if (modelPickerController.closeIfOpen())
1307
+ return;
1308
+ if (treeViewController.closeIfOpen())
1309
+ return;
1310
+ if (!settingsPanel.classList.contains('hidden')) {
1311
+ closeSettings();
1312
+ return;
1313
+ }
1314
+ if (commandPaletteController.closeIfOpen())
1315
+ return;
1316
+ if (state.isStreaming && viewingActiveSession && activeLiveSessionId) {
1317
+ wsClient.send({ type: 'abort', sessionId: activeLiveSessionId });
1318
+ messageRenderer.renderError('Aborted by user');
1319
+ showTypingIndicator(false);
1320
+ }
1321
+ else if (!sidebarEl.classList.contains('collapsed') && window.innerWidth <= 768) {
1322
+ toggleSidebar();
1323
+ }
1324
+ }
1325
+ // / — Focus message input (when not already in an input)
1326
+ if (e.key === '/' && !isInInput()) {
1327
+ e.preventDefault();
1328
+ messageInput.focus();
1329
+ }
1330
+ });
1331
+ function isInInput() {
1332
+ const tag = document.activeElement?.tagName;
1333
+ return tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable;
1334
+ }
1335
+ // ═══════════════════════════════════════
1336
+ // Sidebar
1337
+ // ═══════════════════════════════════════
1338
+ function isMobile() {
1339
+ return window.innerWidth <= 768;
1340
+ }
1341
+ function updateSidebarToggleIcon() {
1342
+ sidebarToggle.textContent = '☰';
1343
+ }
1344
+ function toggleSidebar() {
1345
+ sidebarEl.classList.toggle('collapsed');
1346
+ sidebarOverlay.classList.toggle('visible', !sidebarEl.classList.contains('collapsed') && isMobile());
1347
+ updateSidebarToggleIcon();
1348
+ }
1349
+ sidebarToggle.addEventListener('click', toggleSidebar);
1350
+ sidebarOverlay.addEventListener('click', () => {
1351
+ sidebarEl.classList.add('collapsed');
1352
+ sidebarOverlay.classList.remove('visible');
1353
+ updateSidebarToggleIcon();
1354
+ });
1355
+ const newSessionBtn = document.getElementById('new-session-btn');
1356
+ newSessionBtn.addEventListener('click', openNewLiveSessionModal);
1357
+ refreshSessionsBtn.addEventListener('click', () => {
1358
+ if (isMobile()) {
1359
+ location.reload();
1360
+ return;
1361
+ }
1362
+ refreshSessionsBtn.classList.add('spinning');
1363
+ sidebar.loadSessions().then(() => {
1364
+ setTimeout(() => refreshSessionsBtn.classList.remove('spinning'), 600);
1365
+ updateLiveSessionIndicators();
1366
+ });
1367
+ });
1368
+ // Swipe from left edge to open sidebar on mobile
1369
+ (function initSwipeGesture() {
1370
+ let touchStartX = 0;
1371
+ let touchStartY = 0;
1372
+ let tracking = false;
1373
+ document.addEventListener('touchstart', (e) => {
1374
+ const touch = e.touches[0];
1375
+ // Only track swipes starting within 20px of left edge
1376
+ if (touch.clientX < 20 && isMobile() && sidebarEl.classList.contains('collapsed')) {
1377
+ touchStartX = touch.clientX;
1378
+ touchStartY = touch.clientY;
1379
+ tracking = true;
1380
+ }
1381
+ }, { passive: true });
1382
+ document.addEventListener('touchmove', (e) => {
1383
+ if (!tracking)
1384
+ return;
1385
+ const touch = e.touches[0];
1386
+ const dx = touch.clientX - touchStartX;
1387
+ const dy = Math.abs(touch.clientY - touchStartY);
1388
+ // If vertical movement dominates, cancel
1389
+ if (dy > dx) {
1390
+ tracking = false;
1391
+ }
1392
+ }, { passive: true });
1393
+ document.addEventListener('touchend', (e) => {
1394
+ if (!tracking)
1395
+ return;
1396
+ tracking = false;
1397
+ const touch = e.changedTouches[0];
1398
+ const dx = touch.clientX - touchStartX;
1399
+ if (dx > 60) {
1400
+ sidebarEl.classList.remove('collapsed');
1401
+ sidebarOverlay.classList.add('visible');
1402
+ }
1403
+ }, { passive: true });
1404
+ })();
1405
+ // Session search
1406
+ sessionSearchInput.addEventListener('input', () => {
1407
+ sidebar.setSearchQuery(sessionSearchInput.value);
1408
+ });
1409
+ async function newSession() {
1410
+ sessionTotalCost = 0;
1411
+ lastInputTokens = 0;
1412
+ lastUsage = null;
1413
+ updateContextPill();
1414
+ await switchSession(null);
1415
+ sidebar.clearActive();
1416
+ if (isMobile()) {
1417
+ sidebarEl.classList.add('collapsed');
1418
+ sidebarOverlay.classList.remove('visible');
1419
+ }
1420
+ if (!isMobile())
1421
+ messageInput.focus();
1422
+ }
1423
+ async function handleSessionSelect(session, project) {
1424
+ if (session)
1425
+ sidebar.setActive(session.filePath);
1426
+ sessionTotalCost = 0;
1427
+ lastInputTokens = 0;
1428
+ lastUsage = null;
1429
+ updateContextPill();
1430
+ if (session)
1431
+ await switchSession(session.filePath, session, project);
1432
+ // Close sidebar on mobile after selecting
1433
+ if (isMobile()) {
1434
+ sidebarEl.classList.add('collapsed');
1435
+ sidebarOverlay.classList.remove('visible');
1436
+ }
1437
+ }
1438
+ async function switchSession(sessionFile, session = null, project = null) {
1439
+ try {
1440
+ // Clear any streaming state from previous session to prevent bleed
1441
+ currentStreamingElement = null;
1442
+ currentStreamingThinking = '';
1443
+ currentStreamingText = '';
1444
+ viewingActiveSession = false;
1445
+ state.reset();
1446
+ showTypingIndicator(false);
1447
+ updateUI();
1448
+ messageRenderer.clear();
1449
+ toolCardRenderer.clear();
1450
+ // Clicking a historical session resumes it as a live backend Tau tab.
1451
+ // selectLiveSession() will load the resumed tab snapshot with the same
1452
+ // historical entries after the backend has attached to the session file.
1453
+ if (sessionFile) {
1454
+ const live = liveSessions.find(s => s.sessionFile === sessionFile);
1455
+ if (live) {
1456
+ await selectLiveSession(live.id);
1457
+ return;
1458
+ }
1459
+ // No live tab yet — ask the server to resume this session.
1460
+ messageRenderer.renderSystemMessage('Resuming session…');
1461
+ try {
1462
+ const resumeBody = { filePath: sessionFile };
1463
+ if (project?.path)
1464
+ resumeBody.cwd = project.path;
1465
+ const res = await fetch('/api/live-sessions/resume', {
1466
+ method: 'POST',
1467
+ headers: { 'Content-Type': 'application/json' },
1468
+ body: JSON.stringify(resumeBody),
1469
+ });
1470
+ const data = await res.json();
1471
+ if (!res.ok || data.error) {
1472
+ messageRenderer.clear();
1473
+ messageRenderer.renderError(data.error || 'Failed to resume session');
1474
+ viewingActiveSession = false;
1475
+ updateLiveSessionInputState();
1476
+ updateUI();
1477
+ return;
1478
+ }
1479
+ // If the server found an existing live tab (reused), just focus it.
1480
+ if (data.reused && data.session) {
1481
+ upsertLiveSession(data.session);
1482
+ await selectLiveSession(data.session.id);
1483
+ return;
1484
+ }
1485
+ upsertLiveSession(data.session);
1486
+ await selectLiveSession(data.session.id);
1487
+ }
1488
+ catch (e) {
1489
+ messageRenderer.clear();
1490
+ messageRenderer.renderError('Failed to resume session');
1491
+ viewingActiveSession = false;
1492
+ updateLiveSessionInputState();
1493
+ updateUI();
1494
+ }
1495
+ return;
1496
+ }
1497
+ messageRenderer.renderWelcome();
1498
+ }
1499
+ catch (error) {
1500
+ console.error('[App] Failed to switch session:', error);
1501
+ messageRenderer.renderError('Failed to switch session');
1502
+ }
1503
+ }
1504
+ // ═══════════════════════════════════════
1505
+ // Live-session snapshot sync
1506
+ // ═══════════════════════════════════════
1507
+ function applyLiveSessionSnapshot(data) {
1508
+ console.log('[LiveSession] Received state snapshot:', data.entries?.length, 'entries');
1509
+ if (data.sessionId && data.sessionId !== activeLiveSessionId)
1510
+ return;
1511
+ hasReceivedInitialServerState = true;
1512
+ // Track the active session
1513
+ activeLiveSessionFile = data.sessionFile || data.session?.sessionFile || null;
1514
+ viewingActiveSession = !!activeLiveSessionId;
1515
+ state.setStreaming(!!data.isStreaming);
1516
+ showTypingIndicator(!!data.isStreaming);
1517
+ updateLiveSessionInputState();
1518
+ updateUI();
1519
+ updateLiveSessionIndicators();
1520
+ // Update model display — server is canonical, assign directly.
1521
+ if (data.model !== undefined) {
1522
+ if (data.model?.contextWindow) {
1523
+ contextWindowSize = Number(data.model.contextWindow) || 0;
1524
+ }
1525
+ modelPickerController.setModelState(data.model || '', data.thinkingLevel || 'off');
1526
+ }
1527
+ else if (data.thinkingLevel) {
1528
+ modelPickerController.setThinkingLevel(data.thinkingLevel || 'off');
1529
+ }
1530
+ // Clear and render message history. Reset streaming handles after the
1531
+ // snapshot arrives because live deltas may have created a streaming element
1532
+ // while the snapshot request was in flight; that element is about to be
1533
+ // removed from the DOM.
1534
+ currentStreamingElement = null;
1535
+ currentStreamingThinking = '';
1536
+ currentStreamingText = '';
1537
+ messageRenderer.clear();
1538
+ sessionTotalCost = 0;
1539
+ lastInputTokens = 0;
1540
+ lastUsage = null;
1541
+ if (data.entries && data.entries.length > 0) {
1542
+ renderSessionHistory(data.entries);
1543
+ }
1544
+ else {
1545
+ messageRenderer.renderWelcome();
1546
+ }
1547
+ updateContextPill();
1548
+ // A snapshot means the session's entries/leaf may have moved (e.g. another
1549
+ // client ran navigate_tree) — refresh an open tree modal so it is not stale.
1550
+ treeViewController.notifyTreeChanged(data.sessionId || activeLiveSessionId);
1551
+ // A live session just loaded — fetch its authoritative stats from pi.
1552
+ void sessionStatsCard.refresh();
1553
+ }
1554
+ // Mark all live sessions in the sidebar with a green dot
1555
+ function updateLiveSessionIndicators() {
1556
+ const liveFiles = new Set(liveInstances.map(i => i.sessionFile));
1557
+ // Also include the current active live session
1558
+ if (activeLiveSessionFile)
1559
+ liveFiles.add(activeLiveSessionFile);
1560
+ document.querySelectorAll('.session-item').forEach(el => {
1561
+ el.classList.toggle('has-live-session', liveFiles.has(el.dataset.filePath));
1562
+ });
1563
+ }
1564
+ // Refresh live-session list for sidebar indicators if WS missed an update
1565
+ async function pollInstances() {
1566
+ try {
1567
+ const res = await fetch('/api/live-sessions');
1568
+ if (res.ok) {
1569
+ const data = await res.json();
1570
+ const wasActive = activeLiveSessionId;
1571
+ setLiveSessions(data.sessions || []);
1572
+ const activeSession = wasActive ? liveSessions.find(s => s.id === wasActive) : null;
1573
+ if (wasActive && !activeSession) {
1574
+ handleLiveSessionClosed(wasActive);
1575
+ }
1576
+ else if (activeSession && viewingActiveSession) {
1577
+ state.setStreaming(!!activeSession.isStreaming);
1578
+ showTypingIndicator(!!activeSession.isStreaming);
1579
+ applyActiveSessionMetadata(activeSession);
1580
+ updateLiveSessionInputState();
1581
+ updateUI();
1582
+ }
1583
+ }
1584
+ }
1585
+ catch { }
1586
+ }
1587
+ // Poll every 10 seconds
1588
+ setInterval(pollInstances, 10000);
1589
+ // Enable/disable input based on whether we're viewing a live backend Tau tab
1590
+ function updateLiveSessionInputState() {
1591
+ const inputArea = document.querySelector('.input-area');
1592
+ const hasLiveSession = viewingActiveSession && !!activeLiveSessionId;
1593
+ if (hasLiveSession) {
1594
+ messageInput.disabled = false;
1595
+ sendBtn.disabled = false;
1596
+ messageInput.placeholder = 'Message...';
1597
+ inputArea?.classList.remove('no-active-live-session');
1598
+ }
1599
+ else {
1600
+ messageInput.disabled = true;
1601
+ sendBtn.disabled = true;
1602
+ messageInput.placeholder = hasReceivedInitialServerState ? 'Create or select a Tau tab to chat' : 'Connecting...';
1603
+ inputArea?.classList.add('no-active-live-session');
1604
+ }
1605
+ document.getElementById('command-btn').disabled = !hasLiveSession;
1606
+ modelPickerController.setEnabled(hasLiveSession);
1607
+ treeViewController.setVisible(hasLiveSession);
1608
+ }
1609
+ // ═══════════════════════════════════════
1610
+ // Session history rendering
1611
+ // ═══════════════════════════════════════
1612
+ function renderSessionHistory(entries) {
1613
+ console.log(`[History] Rendering ${entries.length} entries`);
1614
+ let userCount = 0, assistantCount = 0, toolCardCount = 0, toolResultCount = 0;
1615
+ for (const entry of entries) {
1616
+ if (entry.type !== 'message')
1617
+ continue;
1618
+ const msg = entry.message;
1619
+ if (!msg)
1620
+ continue;
1621
+ if (msg.role === 'user') {
1622
+ const content = typeof msg.content === 'string'
1623
+ ? msg.content
1624
+ : (msg.content || [])
1625
+ .filter((b) => b.type === 'text')
1626
+ .map((b) => b.text)
1627
+ .join('\n');
1628
+ // Extract images from content blocks
1629
+ const images = Array.isArray(msg.content)
1630
+ ? msg.content
1631
+ .filter((b) => b.type === 'image')
1632
+ .map((b) => ({ data: b.source?.data || b.data || '', mimeType: b.source?.media_type || b.media_type || 'image/png' }))
1633
+ : [];
1634
+ if (content || images.length > 0) {
1635
+ userCount++;
1636
+ messageRenderer.renderUserMessage({ content: content || '', images: images.length > 0 ? images : undefined }, true);
1637
+ }
1638
+ }
1639
+ else if (msg.role === 'assistant') {
1640
+ const textBlocks = (msg.content || []).filter((b) => b.type === 'text');
1641
+ const thinkingBlocks = (msg.content || []).filter((b) => b.type === 'thinking');
1642
+ const toolCalls = (msg.content || []).filter((b) => b.type === 'toolCall');
1643
+ // Build content blocks for rendering
1644
+ const contentBlocks = [];
1645
+ for (const block of msg.content || []) {
1646
+ if (block.type === 'text' || block.type === 'thinking') {
1647
+ contentBlocks.push(block);
1648
+ }
1649
+ }
1650
+ const text = textBlocks.map((b) => b.text).join('\n');
1651
+ if (text || thinkingBlocks.length > 0) {
1652
+ assistantCount++;
1653
+ messageRenderer.renderAssistantMessage({
1654
+ content: contentBlocks.length > 0 ? contentBlocks : text,
1655
+ usage: msg.usage,
1656
+ }, false, true);
1657
+ // Track cost and tokens from history
1658
+ if (msg.usage?.cost?.total) {
1659
+ sessionTotalCost += msg.usage.cost.total;
1660
+ }
1661
+ if (msg.usage?.input) {
1662
+ lastInputTokens = msg.usage.input + (msg.usage.cacheRead || 0);
1663
+ lastUsage = msg.usage;
1664
+ }
1665
+ }
1666
+ // Show tool calls as compact history cards
1667
+ for (const tc of toolCalls) {
1668
+ toolCardCount++;
1669
+ const card = toolCardRenderer.createHistoryCard({
1670
+ toolCallId: tc.id,
1671
+ toolName: tc.name,
1672
+ args: tc.arguments || {},
1673
+ });
1674
+ console.log(`[History] Tool card created: ${tc.name}`, card?.offsetHeight, card?.innerHTML?.substring(0, 100));
1675
+ }
1676
+ }
1677
+ else if (msg.role === 'toolResult') {
1678
+ toolResultCount++;
1679
+ toolCardRenderer.addHistoryResult(msg.toolCallId ?? '', { content: msg.content || [] }, msg.isError ?? false);
1680
+ }
1681
+ }
1682
+ console.log(`[History] Done: ${userCount} users, ${assistantCount} assistants, ${toolCardCount} tools, ${toolResultCount} results`);
1683
+ console.log(`[History] DOM tool-card count:`, document.querySelectorAll('.tool-card').length);
1684
+ console.log(`[History] DOM thinking-block count:`, document.querySelectorAll('.thinking-block').length);
1685
+ updateContextPill();
1686
+ fetchContextWindow();
1687
+ // Jump to bottom instantly (no smooth scroll animation)
1688
+ messagesContainer.style.scrollBehavior = 'auto';
1689
+ requestAnimationFrame(() => {
1690
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
1691
+ // Restore smooth scrolling after a frame
1692
+ requestAnimationFrame(() => {
1693
+ messagesContainer.style.scrollBehavior = '';
1694
+ });
1695
+ });
1696
+ }
1697
+ // ═══════════════════════════════════════
1698
+ // UI helpers
1699
+ // ═══════════════════════════════════════
1700
+ function showTypingIndicator(show) {
1701
+ typingIndicator.classList.toggle('hidden', !show);
1702
+ }
1703
+ // The single header pill: shows context usage %, falling back to raw tokens
1704
+ // (no context window info yet) or session cost (no context data yet).
1705
+ // Clicking it opens the session stats card.
1706
+ function updateContextPill() {
1707
+ if (lastInputTokens > 0 && contextWindowSize > 0) {
1708
+ const pct = Math.round((lastInputTokens / contextWindowSize) * 100);
1709
+ contextPillEl.textContent = pct === 0 ? '<1%' : `${pct}%`;
1710
+ contextPillEl.classList.add('visible');
1711
+ contextPillEl.classList.remove('warning', 'critical');
1712
+ if (pct >= 80) {
1713
+ contextPillEl.classList.add('critical');
1714
+ }
1715
+ else if (pct >= 60) {
1716
+ contextPillEl.classList.add('warning');
1717
+ }
1718
+ contextPillEl.title = `Context: ${(lastInputTokens / 1000).toFixed(1)}k / ${(contextWindowSize / 1000).toFixed(0)}k tokens — click for session stats`;
1719
+ if (pct >= 80) {
1720
+ showCompactButton();
1721
+ }
1722
+ else {
1723
+ hideCompactButton();
1724
+ }
1725
+ }
1726
+ else if (lastInputTokens > 0) {
1727
+ // No context window info yet, just show raw tokens
1728
+ contextPillEl.textContent = `${(lastInputTokens / 1000).toFixed(1)}k`;
1729
+ contextPillEl.classList.add('visible');
1730
+ contextPillEl.classList.remove('warning', 'critical');
1731
+ contextPillEl.title = 'Context tokens — click for session stats';
1732
+ }
1733
+ else if (sessionTotalCost > 0) {
1734
+ // No context data yet — show the session cost as a fallback.
1735
+ contextPillEl.textContent = `$${sessionTotalCost.toFixed(3)} (sub)`;
1736
+ contextPillEl.classList.add('visible');
1737
+ contextPillEl.classList.remove('warning', 'critical');
1738
+ contextPillEl.title = 'Session cost — click for session stats';
1739
+ }
1740
+ else {
1741
+ contextPillEl.classList.remove('visible', 'warning', 'critical');
1742
+ }
1743
+ }
1744
+ function showCompactButton() {
1745
+ if (document.getElementById('compact-btn'))
1746
+ return;
1747
+ const btn = document.createElement('button');
1748
+ btn.id = 'compact-btn';
1749
+ btn.className = 'compact-btn';
1750
+ btn.textContent = 'Compact';
1751
+ btn.title = 'Context is over 80% — compact to save tokens';
1752
+ btn.addEventListener('click', () => {
1753
+ rpcCommand({ type: 'compact' }, 'Compacting...');
1754
+ hideCompactButton();
1755
+ });
1756
+ // Insert next to the context pill in the header
1757
+ const pillParent = contextPillEl.parentElement;
1758
+ if (pillParent)
1759
+ pillParent.insertBefore(btn, contextPillEl.nextSibling);
1760
+ }
1761
+ function hideCompactButton() {
1762
+ const btn = document.getElementById('compact-btn');
1763
+ if (btn)
1764
+ btn.remove();
1765
+ }
1766
+ async function fetchContextWindow() {
1767
+ // Delegate to fetchModelInfo which also updates the model button
1768
+ await modelPickerController.fetchModelInfo();
1769
+ }
1770
+ let tailscaleUrl = '';
1771
+ function updateConnectionStatus(status) {
1772
+ statusIndicator.className = `status-indicator ${status}`;
1773
+ if (status === 'connected') {
1774
+ statusText.textContent = tailscaleUrl ? 'Connected • TS' : 'Connected';
1775
+ statusText.title = tailscaleUrl || '';
1776
+ // Fetch tailscale info on first connect
1777
+ if (!tailscaleUrl) {
1778
+ fetch('/api/health').then(r => r.json()).then(data => {
1779
+ if (data.tailscaleUrl) {
1780
+ tailscaleUrl = data.tailscaleUrl;
1781
+ statusText.textContent = 'Connected • TS';
1782
+ statusText.title = tailscaleUrl;
1783
+ }
1784
+ }).catch(() => { });
1785
+ }
1786
+ }
1787
+ else if (status === 'disconnected') {
1788
+ statusText.textContent = 'Disconnected';
1789
+ }
1790
+ }
1791
+ function updateUI() {
1792
+ const hasLiveSession = !!activeLiveSessionId && viewingActiveSession;
1793
+ const isStreaming = state.isStreaming && hasLiveSession;
1794
+ // Don't clobber an active red-dot error flash: it owns both the indicator
1795
+ // class and statusText for its full 3 s. The flash's restore callback
1796
+ // re-derives the current connection/streaming state, so skipping here is
1797
+ // safe. Other UI updates below (input enabling, abort button, etc.) still
1798
+ // run normally.
1799
+ if (statusFlashTimer === null) {
1800
+ if (isStreaming) {
1801
+ statusIndicator.classList.add('streaming');
1802
+ statusIndicator.classList.remove('connected');
1803
+ statusText.textContent = 'Working...';
1804
+ }
1805
+ else {
1806
+ statusIndicator.classList.remove('streaming');
1807
+ statusIndicator.classList.add('connected');
1808
+ statusText.textContent = 'Connected';
1809
+ }
1810
+ }
1811
+ messageInput.disabled = !hasLiveSession;
1812
+ sendBtn.disabled = !hasLiveSession;
1813
+ if (isStreaming) {
1814
+ abortBtn.classList.remove('hidden');
1815
+ sendBtn.classList.add('hidden');
1816
+ }
1817
+ else {
1818
+ abortBtn.classList.add('hidden');
1819
+ sendBtn.classList.remove('hidden');
1820
+ if (hasLiveSession)
1821
+ flushQueue();
1822
+ }
1823
+ }
1824
+ // ═══════════════════════════════════════
1825
+ // WebSocket session switch handler
1826
+ // ═══════════════════════════════════════
1827
+ wsClient.addEventListener('sessionSwitch', () => {
1828
+ console.log('[App] Session switched');
1829
+ });
1830
+ // ═══════════════════════════════════════
1831
+ // Theme / Settings
1832
+ // ═══════════════════════════════════════
1833
+ const settingsBtn = document.getElementById('settings-btn');
1834
+ const settingsPanel = document.getElementById('settings-panel');
1835
+ const settingsOverlay = document.getElementById('settings-overlay');
1836
+ const settingsClose = document.getElementById('settings-close');
1837
+ const themeGrid = document.getElementById('theme-grid');
1838
+ const toggleAutoCompact = document.getElementById('toggle-auto-compact');
1839
+ const btnThinkingLevel = document.getElementById('btn-thinking-level');
1840
+ const toggleShowThinking = document.getElementById('toggle-show-thinking');
1841
+ function buildThemeGrid() {
1842
+ themeGrid.innerHTML = '';
1843
+ const current = getCurrentTheme();
1844
+ for (const [id, theme] of Object.entries(themes)) {
1845
+ const btn = document.createElement('button');
1846
+ btn.className = `theme-swatch${current === id ? ' active' : ''}`;
1847
+ const dots = (theme.colors || []).map(c => `<span class="swatch-dot" style="background:${c}"></span>`).join('');
1848
+ btn.innerHTML = `<span class="swatch-colors">${dots}</span>`;
1849
+ btn.addEventListener('click', () => {
1850
+ applyTheme(id);
1851
+ themeGrid.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active'));
1852
+ btn.classList.add('active');
1853
+ });
1854
+ themeGrid.appendChild(btn);
1855
+ }
1856
+ }
1857
+ async function openSettings() {
1858
+ buildThemeGrid();
1859
+ settingsPanel.classList.remove('hidden');
1860
+ settingsOverlay.classList.remove('hidden');
1861
+ // Fetch current state for toggles
1862
+ try {
1863
+ const resp = await fetch('/api/rpc', {
1864
+ method: 'POST',
1865
+ headers: { 'Content-Type': 'application/json' },
1866
+ body: JSON.stringify({ type: 'get_state', sessionId: activeLiveSessionId }),
1867
+ });
1868
+ const data = await resp.json();
1869
+ if (data.success && data.data) {
1870
+ const s = data.data;
1871
+ // Auto-compaction toggle
1872
+ toggleAutoCompact.className = `settings-toggle${s.autoCompactionEnabled ? ' on' : ''}`;
1873
+ // Thinking level
1874
+ btnThinkingLevel.textContent = s.thinkingLevel || 'off';
1875
+ modelPickerController.setThinkingLevel(s.thinkingLevel || 'off');
1876
+ // Session name is managed by Pi session history; no editable field in Tau settings.
1877
+ }
1878
+ }
1879
+ catch (e) {
1880
+ // Silent
1881
+ }
1882
+ // Fetch auth state
1883
+ try {
1884
+ const authData = await rpcCommand({ type: 'get_auth' });
1885
+ if (authData?.success && authData.data?.configured) {
1886
+ authSection.style.display = '';
1887
+ toggleAuth.className = `settings-toggle${authData.data.enabled ? ' on' : ''}`;
1888
+ }
1889
+ else {
1890
+ authSection.style.display = 'none';
1891
+ }
1892
+ }
1893
+ catch {
1894
+ authSection.style.display = 'none';
1895
+ }
1896
+ }
1897
+ function closeSettings() {
1898
+ settingsPanel.classList.add('hidden');
1899
+ settingsOverlay.classList.add('hidden');
1900
+ }
1901
+ settingsBtn.addEventListener('click', openSettings);
1902
+ settingsClose.addEventListener('click', closeSettings);
1903
+ settingsOverlay.addEventListener('click', closeSettings);
1904
+ // Auto-compaction toggle
1905
+ toggleAutoCompact.addEventListener('click', async () => {
1906
+ const isOn = toggleAutoCompact.classList.contains('on');
1907
+ toggleAutoCompact.className = `settings-toggle${isOn ? '' : ' on'}`;
1908
+ await rpcCommand({ type: 'set_auto_compaction', enabled: !isOn });
1909
+ });
1910
+ // Thinking level cycle (settings panel button)
1911
+ btnThinkingLevel.addEventListener('click', async () => {
1912
+ const data = await rpcCommand({ type: 'cycle_thinking_level' });
1913
+ if (data?.success && data.data?.level) {
1914
+ btnThinkingLevel.textContent = data.data.level;
1915
+ modelPickerController.setThinkingLevel(data.data.level);
1916
+ }
1917
+ });
1918
+ // Show thinking toggle (local pref)
1919
+ const showThinking = localStorage.getItem('tau-show-thinking') !== 'false';
1920
+ toggleShowThinking.className = `settings-toggle${showThinking ? ' on' : ''}`;
1921
+ if (!showThinking)
1922
+ document.body.classList.add('hide-thinking');
1923
+ toggleShowThinking.addEventListener('click', () => {
1924
+ const isOn = toggleShowThinking.classList.contains('on');
1925
+ toggleShowThinking.className = `settings-toggle${isOn ? '' : ' on'}`;
1926
+ document.body.classList.toggle('hide-thinking', isOn);
1927
+ localStorage.setItem('tau-show-thinking', String(!isOn));
1928
+ });
1929
+ // Auth toggle
1930
+ const toggleAuth = document.getElementById('toggle-auth');
1931
+ const authSection = document.getElementById('settings-auth-section');
1932
+ toggleAuth.addEventListener('click', async () => {
1933
+ const isOn = toggleAuth.classList.contains('on');
1934
+ const data = await rpcCommand({ type: 'set_auth', enabled: !isOn });
1935
+ if (data?.success) {
1936
+ toggleAuth.className = `settings-toggle${!isOn ? ' on' : ''}`;
1937
+ }
1938
+ });
1939
+ // Restore saved theme
1940
+ const savedTheme = getCurrentTheme();
1941
+ applyTheme(savedTheme);
1942
+ // ═══════════════════════════════════════
1943
+ // Session stats card (opened from the context pill)
1944
+ // ═══════════════════════════════════════
1945
+ // Fetch authoritative session stats from pi's get_session_stats RPC. Uses a
1946
+ // plain fetch (not rpcCommand) so it never flashes status-bar messages.
1947
+ // Resolves null when there is no live session or the fetch fails; a stale
1948
+ // response for a session we already switched away from is discarded, and any
1949
+ // authoritative numbers are synced into the pill's local state.
1950
+ async function fetchSessionStats() {
1951
+ if (!viewingActiveSession || !activeLiveSessionId)
1952
+ return null;
1953
+ const requestSessionId = activeLiveSessionId;
1954
+ try {
1955
+ const resp = await fetch('/api/rpc', {
1956
+ method: 'POST',
1957
+ headers: { 'Content-Type': 'application/json' },
1958
+ body: JSON.stringify({ type: 'get_session_stats', sessionId: requestSessionId }),
1959
+ });
1960
+ const data = await resp.json();
1961
+ if (!data?.success || !data.data)
1962
+ return null;
1963
+ if (activeLiveSessionId !== requestSessionId || !viewingActiveSession)
1964
+ return null;
1965
+ const stats = data.data;
1966
+ // Sync authoritative numbers into the locally-tracked pill state so the
1967
+ // pill never drifts from what pi reports. contextUsage (and its fields)
1968
+ // may be null right after compaction — keep the last known values then.
1969
+ if (typeof stats.cost === 'number')
1970
+ sessionTotalCost = stats.cost;
1971
+ const cu = stats.contextUsage;
1972
+ if (cu && typeof cu.contextWindow === 'number' && cu.contextWindow > 0) {
1973
+ contextWindowSize = cu.contextWindow;
1974
+ }
1975
+ if (cu && typeof cu.tokens === 'number') {
1976
+ lastInputTokens = cu.tokens;
1977
+ }
1978
+ updateContextPill();
1979
+ return stats;
1980
+ }
1981
+ catch {
1982
+ return null;
1983
+ }
1984
+ }
1985
+ const sessionStatsCard = setupSessionStatsCard({
1986
+ pillEl: contextPillEl,
1987
+ cardEl: document.getElementById('session-stats-card'),
1988
+ fetchStats: fetchSessionStats,
1989
+ getFallback: () => ({
1990
+ usage: lastUsage,
1991
+ cost: sessionTotalCost,
1992
+ contextTokens: lastInputTokens,
1993
+ contextWindow: contextWindowSize,
1994
+ }),
1995
+ });
1996
+ // Voice Input
1997
+ setupVoiceInput(document.getElementById('mic-btn'), messageInput);
1998
+ // ═══════════════════════════════════════
1999
+ // Initialize
2000
+ // ═══════════════════════════════════════
2001
+ // On mobile, start with the sidebar collapsed. The context pill stays in the
2002
+ // header on all screen sizes.
2003
+ if (isMobile()) {
2004
+ sidebarEl.classList.add('collapsed');
2005
+ }
2006
+ wsClient.connect();
2007
+ messageRenderer.renderWelcome();
2008
+ updateLiveSessionInputState();
2009
+ sidebar.loadSessions().then(() => {
2010
+ updateLiveSessionIndicators();
2011
+ });
2012
+ launcherPanel.init();
2013
+ // Register service worker for PWA
2014
+ if ('serviceWorker' in navigator) {
2015
+ navigator.serviceWorker.register('/sw.js').catch(() => { });
2016
+ }
2017
+ // Dismiss mobile splash screen
2018
+ const splash = document.getElementById('mobile-splash');
2019
+ if (splash) {
2020
+ requestAnimationFrame(() => {
2021
+ splash.classList.add('hidden');
2022
+ setTimeout(() => splash.remove(), 300);
2023
+ });
2024
+ }
2025
+ console.log('🚀 Tau initialized');