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