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,96 @@
1
+ /**
2
+ * State Manager - Manages chat state
3
+ */
4
+
5
+ export class StateManager {
6
+ messages: Record<string, unknown>[];
7
+ toolExecutions: Map<string, Record<string, unknown>>;
8
+ isStreaming: boolean;
9
+ currentStreamingMessage: Record<string, unknown> | null;
10
+ listeners: Set<() => void>;
11
+
12
+ constructor() {
13
+ this.messages = [];
14
+ this.toolExecutions = new Map(); // toolCallId -> tool execution data
15
+ this.isStreaming = false;
16
+ this.currentStreamingMessage = null;
17
+ this.listeners = new Set();
18
+ }
19
+
20
+ addListener(callback: () => void) {
21
+ this.listeners.add(callback);
22
+ }
23
+
24
+ removeListener(callback: () => void) {
25
+ this.listeners.delete(callback);
26
+ }
27
+
28
+ notifyListeners() {
29
+ this.listeners.forEach(callback => callback());
30
+ }
31
+
32
+ addMessage(message: Record<string, unknown>) {
33
+ this.messages.push(message);
34
+ this.notifyListeners();
35
+ }
36
+
37
+ updateLastMessage(updates: Record<string, unknown>) {
38
+ if (this.messages.length > 0) {
39
+ const lastMessage = this.messages[this.messages.length - 1];
40
+ Object.assign(lastMessage, updates);
41
+ this.notifyListeners();
42
+ }
43
+ }
44
+
45
+ setStreamingMessage(message: Record<string, unknown>) {
46
+ this.currentStreamingMessage = message;
47
+ this.notifyListeners();
48
+ }
49
+
50
+ clearStreamingMessage() {
51
+ this.currentStreamingMessage = null;
52
+ this.notifyListeners();
53
+ }
54
+
55
+ setStreaming(isStreaming: boolean) {
56
+ this.isStreaming = isStreaming;
57
+ this.notifyListeners();
58
+ }
59
+
60
+ addToolExecution(toolCallId: string, data: Record<string, unknown>) {
61
+ this.toolExecutions.set(toolCallId, {
62
+ toolCallId,
63
+ toolName: data.toolName,
64
+ args: data.args,
65
+ status: 'pending',
66
+ output: '',
67
+ isError: false,
68
+ ...data
69
+ });
70
+ this.notifyListeners();
71
+ }
72
+
73
+ updateToolExecution(toolCallId: string, updates: Record<string, unknown>) {
74
+ const tool = this.toolExecutions.get(toolCallId);
75
+ if (tool) {
76
+ Object.assign(tool, updates);
77
+ this.notifyListeners();
78
+ }
79
+ }
80
+
81
+ getToolExecution(toolCallId: string) {
82
+ return this.toolExecutions.get(toolCallId);
83
+ }
84
+
85
+ getAllToolExecutions() {
86
+ return Array.from(this.toolExecutions.values());
87
+ }
88
+
89
+ reset() {
90
+ this.messages = [];
91
+ this.toolExecutions.clear();
92
+ this.isStreaming = false;
93
+ this.currentStreamingMessage = null;
94
+ this.notifyListeners();
95
+ }
96
+ }
@@ -0,0 +1,79 @@
1
+ // Tau Service Worker — minimal, just enables PWA install
2
+ // No aggressive caching since Tau connects to a live local server
3
+ /// <reference lib="webworker" />
4
+
5
+ const CACHE_NAME = 'tau-v4';
6
+ const serviceWorker = self as unknown as ServiceWorkerGlobalScope;
7
+
8
+ // Cache only the app shell on install
9
+ serviceWorker.addEventListener('install', (event: ExtendableEvent) => {
10
+ event.waitUntil(
11
+ caches.open(CACHE_NAME).then((cache) => {
12
+ return cache.addAll([
13
+ '/',
14
+ '/style.css',
15
+ '/app.js',
16
+ '/app-main.js',
17
+ '/state.js',
18
+ '/themes.js',
19
+ '/markdown.js',
20
+ '/message-renderer.js',
21
+ '/tool-card.js',
22
+ '/tree-view.js',
23
+ '/dialogs.js',
24
+ '/session-sidebar.js',
25
+ '/session-stats-card.js',
26
+ '/websocket-client.js',
27
+ '/manifest.json',
28
+ // KaTeX shell assets; fonts are picked up on demand by the
29
+ // network-first runtime cache so a missing font can't fail install.
30
+ '/vendor/katex/katex.min.js',
31
+ '/vendor/katex/katex.min.css',
32
+ ]);
33
+ })
34
+ );
35
+ serviceWorker.skipWaiting();
36
+ });
37
+
38
+ // Clean old caches
39
+ serviceWorker.addEventListener('activate', (event: ExtendableEvent) => {
40
+ event.waitUntil(
41
+ caches.keys().then((names) => {
42
+ return Promise.all(
43
+ names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))
44
+ );
45
+ })
46
+ );
47
+ serviceWorker.clients.claim();
48
+ });
49
+
50
+ // Network-first strategy — always try live server, fall back to cache
51
+ serviceWorker.addEventListener('fetch', (event: FetchEvent) => {
52
+ const url = new URL(event.request.url);
53
+
54
+ // Don't cache API/WebSocket requests
55
+ if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws')) {
56
+ return;
57
+ }
58
+
59
+ event.respondWith(
60
+ fetch(event.request)
61
+ .then((response) => {
62
+ // Update cache with fresh response
63
+ if (response.ok && event.request.method === 'GET') {
64
+ const clone = response.clone();
65
+ caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
66
+ }
67
+ return response;
68
+ })
69
+ .catch(() => {
70
+ // Offline — serve from cache
71
+ return caches.match(event.request).then((cached) => {
72
+ return cached || new Response('Tau is offline — start your pi session to connect.', {
73
+ status: 503,
74
+ headers: { 'Content-Type': 'text/plain' },
75
+ });
76
+ });
77
+ })
78
+ );
79
+ });
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Theme system — four themes: two light, two dark
3
+ */
4
+
5
+ export const themes = {
6
+ night: {
7
+ name: 'Dusk',
8
+ dark: true,
9
+ colors: ['#212121', '#a0a0a0', '#777777', '#666666'],
10
+ vars: {},
11
+ },
12
+ dawn: {
13
+ name: 'Dawn',
14
+ dark: true,
15
+ colors: ['#1a1d26', '#7a8ab0', '#6a5a80', '#5a7a9a'],
16
+ vars: {},
17
+ },
18
+ midnight: {
19
+ name: 'Midnight',
20
+ dark: true,
21
+ colors: ['#000000', '#5a7a9a', '#4a5565', '#4a5a72'],
22
+ vars: {},
23
+ },
24
+ clean: {
25
+ name: 'Clean',
26
+ dark: false,
27
+ colors: ['#ffffff', '#0580c4', '#007aff', '#5ac8fa'],
28
+ vars: {},
29
+ },
30
+ terracotta: {
31
+ name: 'Terracotta',
32
+ dark: false,
33
+ colors: ['#f4f1ec', '#b06a48', '#5c2860', '#3a6a9b'],
34
+ vars: {},
35
+ },
36
+ sage: {
37
+ name: 'Sage',
38
+ dark: false,
39
+ colors: ['#f0f2ec', '#6a7d5a', '#4a3860', '#3a6a7a'],
40
+ vars: {},
41
+ },
42
+ } as const;
43
+
44
+ export type ThemeId = keyof typeof themes;
45
+
46
+ export function applyTheme(themeId: string) {
47
+ const root = document.documentElement;
48
+ // Validate
49
+ if (!(themeId in themes)) themeId = 'night';
50
+ root.setAttribute('data-theme', themeId);
51
+ localStorage.setItem('tau-theme', themeId);
52
+ }
53
+
54
+ export function getCurrentTheme(): ThemeId {
55
+ const saved = localStorage.getItem('tau-theme');
56
+ // Migrate old values
57
+ if (saved === 'dark') return 'night';
58
+ if (saved === 'light') return 'terracotta';
59
+ if (saved && saved in themes) return saved as ThemeId;
60
+ // Auto-detect from OS
61
+ if (window.matchMedia?.('(prefers-color-scheme: light)').matches) return 'terracotta';
62
+ return 'night';
63
+ }
64
+
65
+ // Listen for OS theme changes if no explicit preference saved
66
+ if (!localStorage.getItem('tau-theme')) {
67
+ window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', (e) => {
68
+ if (!localStorage.getItem('tau-theme')) {
69
+ const root = document.documentElement;
70
+ root.setAttribute('data-theme', e.matches ? 'terracotta' : 'night');
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Tool Card - Renders and updates tool execution cards (collapsible)
3
+ */
4
+
5
+ export type ToolArgs = Record<string, unknown>;
6
+
7
+ export type ToolExecution = {
8
+ toolCallId?: string;
9
+ toolName?: string;
10
+ args?: ToolArgs;
11
+ status?: string;
12
+ output?: string;
13
+ isError?: boolean;
14
+ };
15
+
16
+ type ToolResultBlock = {
17
+ type?: string;
18
+ text?: string;
19
+ [key: string]: unknown;
20
+ };
21
+
22
+ export type ToolResult = {
23
+ content?: ToolResultBlock[];
24
+ [key: string]: unknown;
25
+ };
26
+
27
+ export class ToolCardRenderer {
28
+ container: HTMLElement;
29
+ toolCards: Map<string, HTMLElement>;
30
+
31
+ constructor(container: HTMLElement) {
32
+ this.container = container;
33
+ this.toolCards = new Map(); // toolCallId -> element
34
+ }
35
+
36
+ createToolCard(toolExecution: ToolExecution) {
37
+ const { toolCallId, toolName, args, status } = toolExecution;
38
+
39
+ const card = document.createElement('div');
40
+ card.className = 'tool-card';
41
+ card.dataset.toolCallId = String(toolCallId || '');
42
+
43
+ const argsPreview = this.getArgsPreview(String(toolName || ''), args);
44
+ const argsJson = this.formatJson(args);
45
+ const isExpanded = (status === 'streaming' || status === 'pending');
46
+
47
+ const isEdit = (toolName === 'edit' || toolName === 'Edit') && args && (args.oldText || args.old_text) && (args.newText || args.new_text);
48
+
49
+ card.innerHTML = `
50
+ <div class="tool-card-header" onclick="this.parentElement.querySelector('.tool-card-body').classList.toggle('expanded'); this.querySelector('.tool-card-chevron').classList.toggle('expanded')">
51
+ <div class="tool-header-left">
52
+ <span class="tool-card-chevron${isExpanded ? ' expanded' : ''}"><svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M2 1l4 3-4 3z"/></svg></span>
53
+ <span class="tool-name">${this.escapeHtml(toolName || '')}</span>
54
+ ${argsPreview ? `<span class="tool-args-preview">${this.escapeHtml(argsPreview)}</span>` : ''}
55
+ </div>
56
+ <div class="tool-header-right">
57
+ <button class="tool-action-btn copy-output-btn" title="Copy output" onclick="event.stopPropagation(); var t=this.closest('.tool-card').querySelector('.tool-output'); if(!t||!t.textContent.trim())return; var s=t.textContent,b=this; (navigator.clipboard?navigator.clipboard.writeText(s):new Promise(function(r){var a=document.createElement('textarea');a.value=s;a.style.cssText='position:fixed;left:-9999px';document.body.appendChild(a);a.select();document.execCommand('copy');document.body.removeChild(a);r()})).then(function(){b.classList.add('copied');setTimeout(function(){b.classList.remove('copied')},1500)})"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button>
58
+ <div class="tool-status ${status}">${status}</div>
59
+ </div>
60
+ </div>
61
+ <div class="tool-card-body${isExpanded ? ' expanded' : ''}">
62
+ ${!isEdit && argsJson ? `<div class="tool-args">${this.escapeHtml(argsJson)}</div>` : ''}
63
+ <div class="tool-output-wrapper">
64
+ <div class="tool-output"></div>
65
+ </div>
66
+ </div>
67
+ `;
68
+
69
+ // Insert diff view for Edit tools
70
+ if (isEdit) {
71
+ const diffEl = this.renderDiff(String(args.oldText || args.old_text || ''), String(args.newText || args.new_text || ''));
72
+ const body = card.querySelector('.tool-card-body');
73
+ if (body) body.insertBefore(diffEl, body.firstChild);
74
+ }
75
+
76
+ this.container.appendChild(card);
77
+ this.toolCards.set(String(toolCallId || ''), card);
78
+ this.scrollToBottom();
79
+
80
+ return card;
81
+ }
82
+
83
+ updateToolCard(toolExecution: ToolExecution) {
84
+ let card = this.toolCards.get(String(toolExecution.toolCallId || ''));
85
+
86
+ if (!card) {
87
+ card = this.createToolCard(toolExecution);
88
+ }
89
+
90
+ // Update status
91
+ const statusElement = card.querySelector('.tool-status');
92
+ if (statusElement) {
93
+ statusElement.className = `tool-status ${toolExecution.status}`;
94
+ statusElement.textContent = toolExecution.status ?? null;
95
+ }
96
+
97
+ // Auto-expand when streaming
98
+ if (toolExecution.status === 'streaming') {
99
+ const body = card.querySelector('.tool-card-body');
100
+ const chevron = card.querySelector('.tool-card-chevron');
101
+ if (body) body.classList.add('expanded');
102
+ if (chevron) chevron.classList.add('expanded');
103
+ }
104
+
105
+ // Update output
106
+ const outputElement = card.querySelector('.tool-output');
107
+ if (outputElement && toolExecution.output) {
108
+ outputElement.textContent = toolExecution.output;
109
+ this.scrollToBottom();
110
+ }
111
+ }
112
+
113
+ finalizeToolCard(toolCallId: string, result: ToolResult, isError: boolean) {
114
+ const card = this.toolCards.get(toolCallId);
115
+ if (!card) return;
116
+
117
+ // Update status
118
+ const statusElement = card.querySelector('.tool-status');
119
+ if (statusElement) {
120
+ const status = isError ? 'error' : 'complete';
121
+ statusElement.className = `tool-status ${status}`;
122
+ statusElement.textContent = status;
123
+ }
124
+
125
+ // Update output with final result
126
+ const outputElement = card.querySelector('.tool-output');
127
+ if (outputElement && result) {
128
+ const output = this.formatResult(result);
129
+ outputElement.textContent = output;
130
+ }
131
+
132
+ // Collapse completed cards (less noise)
133
+ if (!isError) {
134
+ const body = card.querySelector('.tool-card-body');
135
+ const chevron = card.querySelector('.tool-card-chevron');
136
+ if (body) body.classList.remove('expanded');
137
+ if (chevron) chevron.classList.remove('expanded');
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Create a pre-collapsed card for session history using DOM methods (no innerHTML)
143
+ */
144
+ createHistoryCard(toolExecution: ToolExecution) {
145
+ const { toolCallId, toolName, args } = toolExecution;
146
+
147
+ const card = document.createElement('div');
148
+ card.className = 'tool-card';
149
+ card.dataset.toolCallId = String(toolCallId || '');
150
+
151
+ // Header
152
+ const header = document.createElement('div');
153
+ header.className = 'tool-card-header';
154
+
155
+ const headerLeft = document.createElement('div');
156
+ headerLeft.className = 'tool-header-left';
157
+
158
+ const chevron = document.createElement('span');
159
+ chevron.className = 'tool-card-chevron';
160
+ chevron.innerHTML = '<svg width="8" height="8" viewBox="0 0 8 8" fill="currentColor"><path d="M2 1l4 3-4 3z"/></svg>';
161
+ headerLeft.appendChild(chevron);
162
+
163
+ const name = document.createElement('span');
164
+ name.className = 'tool-name';
165
+ name.textContent = String(toolName || '');
166
+ headerLeft.appendChild(name);
167
+
168
+ const preview = this.getArgsPreview(String(toolName || ''), args);
169
+ if (preview) {
170
+ const previewEl = document.createElement('span');
171
+ previewEl.className = 'tool-args-preview';
172
+ previewEl.textContent = preview;
173
+ headerLeft.appendChild(previewEl);
174
+ }
175
+
176
+ header.appendChild(headerLeft);
177
+
178
+ // Right side: copy button + status
179
+ const headerRight = document.createElement('div');
180
+ headerRight.className = 'tool-header-right';
181
+
182
+ const copyBtn = document.createElement('button');
183
+ copyBtn.className = 'tool-action-btn copy-output-btn';
184
+ copyBtn.title = 'Copy output';
185
+ copyBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
186
+ copyBtn.addEventListener('click', (e) => {
187
+ e.stopPropagation();
188
+ const output = card.querySelector('.tool-output');
189
+ if (!output || !output.textContent.trim()) return;
190
+ const text = output.textContent;
191
+ (navigator.clipboard ? navigator.clipboard.writeText(text) : new Promise<void>((r) => {
192
+ const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px';
193
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); r();
194
+ })).then(() => {
195
+ copyBtn.classList.add('copied');
196
+ setTimeout(() => copyBtn.classList.remove('copied'), 1500);
197
+ });
198
+ });
199
+ headerRight.appendChild(copyBtn);
200
+
201
+ const status = document.createElement('div');
202
+ status.className = 'tool-status complete';
203
+ status.textContent = 'complete';
204
+ headerRight.appendChild(status);
205
+
206
+ header.appendChild(headerRight);
207
+
208
+ // Toggle expand on click
209
+ header.addEventListener('click', () => {
210
+ body.classList.toggle('expanded');
211
+ chevron.classList.toggle('expanded');
212
+ });
213
+
214
+ card.appendChild(header);
215
+
216
+ // Body (collapsed by default)
217
+ const body = document.createElement('div');
218
+ body.className = 'tool-card-body';
219
+
220
+ const isEdit = (toolName === 'edit' || toolName === 'Edit') && args && (args.oldText || args.old_text) && (args.newText || args.new_text);
221
+
222
+ if (isEdit) {
223
+ body.appendChild(this.renderDiff(String(args.oldText || args.old_text || ''), String(args.newText || args.new_text || '')));
224
+ } else {
225
+ const argsJson = this.formatJson(args);
226
+ if (argsJson) {
227
+ const argsEl = document.createElement('div');
228
+ argsEl.className = 'tool-args';
229
+ argsEl.textContent = argsJson;
230
+ body.appendChild(argsEl);
231
+ }
232
+ }
233
+
234
+ const outputEl = document.createElement('div');
235
+ outputEl.className = 'tool-output';
236
+ body.appendChild(outputEl);
237
+
238
+ card.appendChild(body);
239
+
240
+ this.container.appendChild(card);
241
+ this.toolCards.set(String(toolCallId || ''), card);
242
+
243
+ return card;
244
+ }
245
+
246
+ /**
247
+ * Add result to a history card (stays collapsed)
248
+ */
249
+ addHistoryResult(toolCallId: string, result: ToolResult, isError: boolean) {
250
+ const card = this.toolCards.get(toolCallId);
251
+ if (!card) return;
252
+
253
+ if (isError) {
254
+ const statusEl = card.querySelector('.tool-status');
255
+ if (statusEl) {
256
+ statusEl.className = 'tool-status error';
257
+ statusEl.textContent = 'error';
258
+ }
259
+ }
260
+
261
+ const outputElement = card.querySelector('.tool-output');
262
+ if (outputElement && result) {
263
+ outputElement.textContent = this.formatResult(result);
264
+ }
265
+ }
266
+
267
+ /** Compact preview for the header line */
268
+ getArgsPreview(toolName: string, args?: ToolArgs) {
269
+ if (!args || Object.keys(args).length === 0) return '';
270
+
271
+ // Show the most relevant arg inline
272
+ if (args.path) return String(args.path);
273
+ if (args.command) return String(args.command).substring(0, 80);
274
+ if (args.query) return String(args.query).substring(0, 60);
275
+ if (args.url) return String(args.url);
276
+
277
+ // Fallback: first string value
278
+ for (const val of Object.values(args)) {
279
+ if (typeof val === 'string' && val.length > 0) {
280
+ return val.substring(0, 60);
281
+ }
282
+ }
283
+ return '';
284
+ }
285
+
286
+ formatJson(obj?: ToolArgs) {
287
+ if (!obj) return '';
288
+ try {
289
+ if (Object.keys(obj).length === 0) return '';
290
+ return JSON.stringify(obj, null, 2);
291
+ } catch {
292
+ return String(obj);
293
+ }
294
+ }
295
+
296
+ /** Render a simple inline diff for Edit tool */
297
+ renderDiff(oldText: string, newText: string) {
298
+ const container = document.createElement('div');
299
+ container.className = 'tool-diff';
300
+
301
+ const oldLines = oldText.split('\n');
302
+ const newLines = newText.split('\n');
303
+
304
+ // Removed lines
305
+ for (const line of oldLines) {
306
+ const el = document.createElement('div');
307
+ el.className = 'diff-line diff-removed';
308
+ el.textContent = '- ' + line;
309
+ container.appendChild(el);
310
+ }
311
+
312
+ // Added lines
313
+ for (const line of newLines) {
314
+ const el = document.createElement('div');
315
+ el.className = 'diff-line diff-added';
316
+ el.textContent = '+ ' + line;
317
+ container.appendChild(el);
318
+ }
319
+
320
+ return container;
321
+ }
322
+
323
+ formatResult(result: ToolResult) {
324
+ if (!result) return '';
325
+
326
+ if (result.content && Array.isArray(result.content)) {
327
+ return result.content
328
+ .map((block) => {
329
+ if (block.type === 'text') return block.text;
330
+ return JSON.stringify(block);
331
+ })
332
+ .join('\n');
333
+ }
334
+
335
+ return JSON.stringify(result, null, 2);
336
+ }
337
+
338
+ escapeHtml(text: unknown) {
339
+ const div = document.createElement('div');
340
+ div.textContent = String(text ?? '');
341
+ return div.innerHTML;
342
+ }
343
+
344
+ scrollToBottom() {
345
+ if (this.container) {
346
+ const threshold = 100;
347
+ const isNear =
348
+ this.container.scrollHeight - this.container.scrollTop - this.container.clientHeight < threshold;
349
+ if (isNear) {
350
+ requestAnimationFrame(() => {
351
+ this.container.scrollTop = this.container.scrollHeight;
352
+ });
353
+ }
354
+ }
355
+ }
356
+
357
+ expandAll() {
358
+ this.toolCards.forEach(card => {
359
+ card.querySelector('.tool-card-body')?.classList.add('expanded');
360
+ card.querySelector('.tool-card-chevron')?.classList.add('expanded');
361
+ });
362
+ }
363
+
364
+ collapseAll() {
365
+ this.toolCards.forEach(card => {
366
+ card.querySelector('.tool-card-body')?.classList.remove('expanded');
367
+ card.querySelector('.tool-card-chevron')?.classList.remove('expanded');
368
+ });
369
+ }
370
+
371
+ clear() {
372
+ this.toolCards.forEach((card) => card.remove());
373
+ this.toolCards.clear();
374
+ }
375
+ }