clawcontainer 1.0.0

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 (150) hide show
  1. package/CONTRIBUTING.md +76 -0
  2. package/DOCS.md +370 -0
  3. package/LICENSE +21 -0
  4. package/README.md +147 -0
  5. package/black_logo.png +0 -0
  6. package/dist/assets/abap-DLDM7-KI.js +1 -0
  7. package/dist/assets/apex-DNDY2TF8.js +1 -0
  8. package/dist/assets/azcli-Y6nb8tq_.js +1 -0
  9. package/dist/assets/bat-BwHxbl9M.js +1 -0
  10. package/dist/assets/bicep-CFznDFnq.js +2 -0
  11. package/dist/assets/cameligo-Bf6VGUru.js +1 -0
  12. package/dist/assets/clojure-Dnu-v4kV.js +1 -0
  13. package/dist/assets/codicon-ngg6Pgfi.ttf +0 -0
  14. package/dist/assets/coffee-Bd8akH9Z.js +1 -0
  15. package/dist/assets/cpp-BbWJElDN.js +1 -0
  16. package/dist/assets/csharp-Co3qMtFm.js +1 -0
  17. package/dist/assets/csp-D-4FJmMZ.js +1 -0
  18. package/dist/assets/css-DdJfP1eB.js +3 -0
  19. package/dist/assets/css.worker-GxEd3MMM.js +93 -0
  20. package/dist/assets/cssMode-DM_ONlf-.js +1 -0
  21. package/dist/assets/cypher-cTPe9QuQ.js +1 -0
  22. package/dist/assets/dart-BOtBlQCF.js +1 -0
  23. package/dist/assets/dockerfile-BG73LgW2.js +1 -0
  24. package/dist/assets/ecl-BEgZUVRK.js +1 -0
  25. package/dist/assets/elixir-BkW5O-1t.js +1 -0
  26. package/dist/assets/flow9-BeJ5waoc.js +1 -0
  27. package/dist/assets/freemarker2-VbwzOQPq.js +3 -0
  28. package/dist/assets/fsharp-PahG7c26.js +1 -0
  29. package/dist/assets/go-acbASCJo.js +1 -0
  30. package/dist/assets/graphql-BxJiqAUM.js +1 -0
  31. package/dist/assets/handlebars-DLvQ802u.js +1 -0
  32. package/dist/assets/hcl-DtV1sZF8.js +1 -0
  33. package/dist/assets/html-DuEPBzmS.js +1 -0
  34. package/dist/assets/html.worker-lU17Tx2m.js +470 -0
  35. package/dist/assets/htmlMode-BfeYTJaB.js +1 -0
  36. package/dist/assets/index-BnBKg8GZ.js +1291 -0
  37. package/dist/assets/index-Dq3FlPWe.css +32 -0
  38. package/dist/assets/ini-Kd9XrMLS.js +1 -0
  39. package/dist/assets/java-CXBNlu9o.js +1 -0
  40. package/dist/assets/javascript-DQO1Leza.js +1 -0
  41. package/dist/assets/json.worker-CUJs-dtA.js +58 -0
  42. package/dist/assets/jsonMode--qsURhHr.js +7 -0
  43. package/dist/assets/julia-cl7-CwDS.js +1 -0
  44. package/dist/assets/kotlin-s7OhZKlX.js +1 -0
  45. package/dist/assets/less-9HpZscsL.js +2 -0
  46. package/dist/assets/lexon-OrD6JF1K.js +1 -0
  47. package/dist/assets/liquid-PL6MZtM8.js +1 -0
  48. package/dist/assets/lspLanguageFeatures-Cy5rDFeq.js +4 -0
  49. package/dist/assets/lua-Cyyb5UIc.js +1 -0
  50. package/dist/assets/m3-B8OfTtLu.js +1 -0
  51. package/dist/assets/markdown-BFxVWTOG.js +1 -0
  52. package/dist/assets/mdx-Cb3Jy14X.js +1 -0
  53. package/dist/assets/mips-CiqrrVzr.js +1 -0
  54. package/dist/assets/msdax-DmeGPVcC.js +1 -0
  55. package/dist/assets/mysql-C_tMU-Nz.js +1 -0
  56. package/dist/assets/objective-c-BDtDVThU.js +1 -0
  57. package/dist/assets/pascal-vHIfCaH5.js +1 -0
  58. package/dist/assets/pascaligo-DtZ0uQbO.js +1 -0
  59. package/dist/assets/perl-Ub6l9XKa.js +1 -0
  60. package/dist/assets/pgsql-BlNEE0v7.js +1 -0
  61. package/dist/assets/php-BBUBE1dy.js +1 -0
  62. package/dist/assets/pla-DSh2-awV.js +1 -0
  63. package/dist/assets/postiats-CocnycG-.js +1 -0
  64. package/dist/assets/powerquery-tScXyioY.js +1 -0
  65. package/dist/assets/powershell-COWaemsV.js +1 -0
  66. package/dist/assets/protobuf-Brw8urJB.js +2 -0
  67. package/dist/assets/pug-8SOpv6rk.js +1 -0
  68. package/dist/assets/python-Usm4OUwq.js +1 -0
  69. package/dist/assets/qsharp-Bw9ernYp.js +1 -0
  70. package/dist/assets/r-j7ic8hl3.js +1 -0
  71. package/dist/assets/razor-BIOole7a.js +1 -0
  72. package/dist/assets/redis-Bu5POkcn.js +1 -0
  73. package/dist/assets/redshift-Bs9aos_-.js +1 -0
  74. package/dist/assets/restructuredtext-CqXO7rUv.js +1 -0
  75. package/dist/assets/ruby-zBfavPgS.js +1 -0
  76. package/dist/assets/rust-BzKRNQWT.js +1 -0
  77. package/dist/assets/sb-BBc9UKZt.js +1 -0
  78. package/dist/assets/scala-D9hQfWCl.js +1 -0
  79. package/dist/assets/scheme-BPhDTwHR.js +1 -0
  80. package/dist/assets/scss-CBJaRo0y.js +3 -0
  81. package/dist/assets/shell-DiJ1NA_G.js +1 -0
  82. package/dist/assets/solidity-Db0IVjzk.js +1 -0
  83. package/dist/assets/sophia-CnS9iZB_.js +1 -0
  84. package/dist/assets/sparql-CJmd_6j2.js +1 -0
  85. package/dist/assets/sql-ClhHkBeG.js +1 -0
  86. package/dist/assets/st-CHwy0fLd.js +1 -0
  87. package/dist/assets/swift-Bqt4WxQ4.js +3 -0
  88. package/dist/assets/systemverilog-Bs9z6M-B.js +1 -0
  89. package/dist/assets/tcl-Dm6ycUr_.js +1 -0
  90. package/dist/assets/ts.worker-Dy9lDQQT.js +67731 -0
  91. package/dist/assets/tsMode-CDjF3DWK.js +11 -0
  92. package/dist/assets/twig-Csy3S7wG.js +1 -0
  93. package/dist/assets/typescript-CJR4sLnG.js +1 -0
  94. package/dist/assets/typespec-Btyra-wh.js +1 -0
  95. package/dist/assets/vb-Db0cS2oM.js +1 -0
  96. package/dist/assets/wgsl-DumH7NcR.js +298 -0
  97. package/dist/assets/xml-CJZS3uh7.js +1 -0
  98. package/dist/assets/yaml-DB88cW5z.js +1 -0
  99. package/dist/audit.d.ts +48 -0
  100. package/dist/container.d.ts +100 -0
  101. package/dist/event-emitter.d.ts +7 -0
  102. package/dist/favicon.png +0 -0
  103. package/dist/git-service.d.ts +31 -0
  104. package/dist/index.html +188 -0
  105. package/dist/logo-sm.png +0 -0
  106. package/dist/logo.png +0 -0
  107. package/dist/main.d.ts +1 -0
  108. package/dist/monaco-editor.d.ts +11 -0
  109. package/dist/monacoeditorwork/css.worker.bundle.js +54264 -0
  110. package/dist/monacoeditorwork/editor.worker.bundle.js +14317 -0
  111. package/dist/monacoeditorwork/html.worker.bundle.js +30449 -0
  112. package/dist/monacoeditorwork/json.worker.bundle.js +22085 -0
  113. package/dist/monacoeditorwork/ts.worker.bundle.js +225552 -0
  114. package/dist/net-intercept.d.ts +2 -0
  115. package/dist/network-hook.d.ts +1 -0
  116. package/dist/plugin.d.ts +20 -0
  117. package/dist/policy.d.ts +58 -0
  118. package/dist/sdk.d.ts +61 -0
  119. package/dist/tab-manager.d.ts +11 -0
  120. package/dist/templates.d.ts +46 -0
  121. package/dist/terminal.d.ts +19 -0
  122. package/dist/types.d.ts +109 -0
  123. package/dist/ui.d.ts +81 -0
  124. package/dist/workspace.d.ts +16 -0
  125. package/index.html +159 -0
  126. package/logo.png +0 -0
  127. package/package.json +31 -0
  128. package/public/favicon.png +0 -0
  129. package/public/logo-sm.png +0 -0
  130. package/public/logo.png +0 -0
  131. package/src/audit.ts +196 -0
  132. package/src/container.ts +723 -0
  133. package/src/event-emitter.ts +28 -0
  134. package/src/git-service.ts +202 -0
  135. package/src/main.ts +9 -0
  136. package/src/monaco-editor.ts +111 -0
  137. package/src/net-intercept.ts +74 -0
  138. package/src/network-hook.ts +248 -0
  139. package/src/plugin.ts +63 -0
  140. package/src/policy.ts +403 -0
  141. package/src/sdk.ts +355 -0
  142. package/src/style.css +432 -0
  143. package/src/tab-manager.ts +30 -0
  144. package/src/templates.ts +271 -0
  145. package/src/terminal.ts +78 -0
  146. package/src/types.ts +113 -0
  147. package/src/ui.ts +1266 -0
  148. package/src/workspace.ts +107 -0
  149. package/tsconfig.json +20 -0
  150. package/vite.config.ts +52 -0
package/src/ui.ts ADDED
@@ -0,0 +1,1266 @@
1
+ import type { ContainerManager, ContainerStatus } from './container.js';
2
+ import { createEditorInstance, openFileModel, getModelContent, closeFileModel, disposeAll, initMonacoTheme } from './monaco-editor.js';
3
+ import type { AuditLog } from './audit.js';
4
+ import { PolicyEngine } from './policy.js';
5
+ import type { TabDefinition } from './types.js';
6
+
7
+ // ─── Provider → env var key ──────────────────────────────────────────────────
8
+ function providerEnvKey(provider: string): string {
9
+ switch (provider) {
10
+ case 'openai': return 'OPENAI_API_KEY';
11
+ case 'google': return 'GOOGLE_API_KEY';
12
+ default: return 'ANTHROPIC_API_KEY';
13
+ }
14
+ }
15
+
16
+ // ─── Provider → models ───────────────────────────────────────────────────────
17
+ const PROVIDER_MODELS: Record<string, string[]> = {
18
+ anthropic: ['anthropic:claude-opus-4-6', 'anthropic:claude-sonnet-4-6', 'anthropic:claude-haiku-4-5'],
19
+ openai: ['openai:gpt-4o', 'openai:gpt-4o-mini', 'openai:o3-mini'],
20
+ google: ['google:gemini-2.0-flash', 'google:gemini-2.5-pro'],
21
+ };
22
+
23
+ const LS_PREFIX = 'clawchef_';
24
+ const PREVIEW_TAB_PATH = '__preview__';
25
+ const AUDIT_TAB_PATH = '__audit__';
26
+ const POLICY_TAB_PATH = '__policy__';
27
+ const CLOUD_BROWSER_TAB_PATH = '__cloud_browser__';
28
+
29
+ interface Tab {
30
+ filePath: string;
31
+ filename: string;
32
+ }
33
+
34
+ export class UIManager {
35
+ private container: ContainerManager;
36
+ private audit: AuditLog;
37
+ private policy: PolicyEngine;
38
+ private activePanelId: string | null = null;
39
+ private refreshTimer: ReturnType<typeof setInterval> | null = null;
40
+
41
+ // Tab state
42
+ private tabs: Tab[] = [];
43
+ private activeTabPath: string | null = null;
44
+
45
+ // Audit tab state
46
+ private auditUnsubscribe: (() => void) | null = null;
47
+
48
+ // Git sync state
49
+ private syncInProgress = false;
50
+ private autoSyncTimer: ReturnType<typeof setTimeout> | null = null;
51
+ private cloneInProgress = false;
52
+
53
+ // Custom tab tracking
54
+ private customTabPaths = new Set<string>();
55
+
56
+ constructor(container: ContainerManager, audit: AuditLog, policy: PolicyEngine) {
57
+ this.container = container;
58
+ this.audit = audit;
59
+ this.policy = policy;
60
+ }
61
+
62
+ init(): void {
63
+ initMonacoTheme();
64
+ this.bindTopbarButtons();
65
+ this.bindConfigPanel();
66
+ this.bindPolicyPanel();
67
+ this.bindFileTree();
68
+ this.bindKeyboard();
69
+ this.bindResizeHandles();
70
+ this.populateModelOptions();
71
+ this.restoreConfig();
72
+ this.bindRepoControls();
73
+ }
74
+
75
+ /** Auto-open the browser preview tab on initial load. */
76
+ openPreviewOnLoad(): void {
77
+ this.openPreviewTab();
78
+ }
79
+
80
+ setStatus(status: ContainerStatus): void {
81
+ const badge = document.getElementById('container-status')!;
82
+ badge.className = 'status-badge';
83
+ badge.classList.add(`status-${status}`);
84
+ const labels: Record<ContainerStatus, string> = {
85
+ booting: 'booting', installing: 'installing', ready: 'ready', error: 'error',
86
+ };
87
+ badge.textContent = labels[status] ?? status;
88
+
89
+ if (status === 'ready') {
90
+ this.startFileTreeRefresh();
91
+ this.startHtmlFileWatcher();
92
+ }
93
+ }
94
+
95
+ getSavedConfig() {
96
+ const provider = localStorage.getItem(`${LS_PREFIX}provider`);
97
+ const model = localStorage.getItem(`${LS_PREFIX}model`);
98
+ const envJson = localStorage.getItem(`${LS_PREFIX}envVars`);
99
+
100
+ // New format
101
+ if (provider && model && envJson) {
102
+ try {
103
+ const envVars = JSON.parse(envJson) as Record<string, string>;
104
+ if (Object.keys(envVars).length > 0) return { provider, model, envVars };
105
+ } catch { /* fall through */ }
106
+ }
107
+
108
+ // Migrate old format
109
+ const apiKey = localStorage.getItem(`${LS_PREFIX}apiKey`);
110
+ if (provider && apiKey && model) {
111
+ const envVars: Record<string, string> = { [providerEnvKey(provider)]: apiKey };
112
+ const voiceKey = localStorage.getItem(`${LS_PREFIX}openaiVoiceKey`);
113
+ if (voiceKey) envVars['OPENAI_API_KEY'] = voiceKey;
114
+ return { provider, model, envVars };
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ showConfigPanel(): void {
121
+ this.openPanel('config-panel');
122
+ document.getElementById('btn-config')!.classList.add('active');
123
+ }
124
+
125
+ // ─── Keyboard shortcuts ───────────────────────────────────────────────────
126
+
127
+ private bindKeyboard(): void {
128
+ document.addEventListener('keydown', (e) => {
129
+ if ((e.ctrlKey || e.metaKey) && e.key === 's') {
130
+ if (this.activeTabPath) {
131
+ e.preventDefault();
132
+ this.saveActiveFile();
133
+ }
134
+ }
135
+ });
136
+ }
137
+
138
+ // ─── Resize handles ───────────────────────────────────────────────────────
139
+
140
+ private bindResizeHandles(): void {
141
+ // Filetree ↔ main-content (horizontal)
142
+ this.initHResize('resize-filetree', 'filetree', 'before', 120, 600);
143
+
144
+ // Editor ↔ Terminal (vertical)
145
+ this.initVResize('resize-editor-terminal', 'editor-panel', 'terminal-panel', 80, 80);
146
+
147
+ // Main-content ↔ sidebar (horizontal)
148
+ this.initHResize('resize-sidebar', 'sidebar', 'after', 150, 600);
149
+ }
150
+
151
+ private initHResize(handleId: string, targetId: string, side: 'before' | 'after', min: number, max: number): void {
152
+ const handle = document.getElementById(handleId)!;
153
+ const target = document.getElementById(targetId)!;
154
+
155
+ let startX = 0;
156
+ let startW = 0;
157
+
158
+ const onMove = (e: MouseEvent) => {
159
+ const delta = e.clientX - startX;
160
+ const newW = Math.min(max, Math.max(min, side === 'before' ? startW + delta : startW - delta));
161
+ target.style.width = `${newW}px`;
162
+ };
163
+
164
+ const onUp = () => {
165
+ handle.classList.remove('active');
166
+ document.body.classList.remove('resizing-col');
167
+ document.removeEventListener('mousemove', onMove);
168
+ document.removeEventListener('mouseup', onUp);
169
+ };
170
+
171
+ handle.addEventListener('mousedown', (e) => {
172
+ e.preventDefault();
173
+ startX = e.clientX;
174
+ startW = target.getBoundingClientRect().width;
175
+ handle.classList.add('active');
176
+ document.body.classList.add('resizing-col');
177
+ document.addEventListener('mousemove', onMove);
178
+ document.addEventListener('mouseup', onUp);
179
+ });
180
+ }
181
+
182
+ private initVResize(handleId: string, topId: string, _bottomId: string, minTop: number, minBottom: number): void {
183
+ const handle = document.getElementById(handleId)!;
184
+ const topEl = document.getElementById(topId)!;
185
+ const parent = topEl.parentElement!;
186
+
187
+ let startY = 0;
188
+ let startH = 0;
189
+
190
+ const onMove = (e: MouseEvent) => {
191
+ const parentH = parent.getBoundingClientRect().height;
192
+ const delta = e.clientY - startY;
193
+ const maxH = parentH - minBottom - handle.offsetHeight;
194
+ const newH = Math.min(maxH, Math.max(minTop, startH + delta));
195
+ topEl.style.height = `${newH}px`;
196
+ };
197
+
198
+ const onUp = () => {
199
+ handle.classList.remove('active');
200
+ document.body.classList.remove('resizing-row');
201
+ document.removeEventListener('mousemove', onMove);
202
+ document.removeEventListener('mouseup', onUp);
203
+ };
204
+
205
+ handle.addEventListener('mousedown', (e) => {
206
+ e.preventDefault();
207
+ startY = e.clientY;
208
+ startH = topEl.getBoundingClientRect().height;
209
+ handle.classList.add('active');
210
+ document.body.classList.add('resizing-row');
211
+ document.addEventListener('mousemove', onMove);
212
+ document.addEventListener('mouseup', onUp);
213
+ });
214
+ }
215
+
216
+ // ─── File Tree ─────────────────────────────────────────────────────────────
217
+
218
+ private bindFileTree(): void {
219
+ document.getElementById('btn-refresh-tree')!.addEventListener('click', () =>
220
+ this.refreshFileTree());
221
+ }
222
+
223
+ private htmlWatcherStarted = false;
224
+
225
+ private startHtmlFileWatcher(): void {
226
+ if (this.htmlWatcherStarted) return;
227
+ this.htmlWatcherStarted = true;
228
+
229
+ this.container.onFileChange((path: string) => {
230
+ if (/\.html?$/i.test(path)) {
231
+ this.openHtmlInPreview(path);
232
+ }
233
+ });
234
+
235
+ // Also start the native fs watcher in the container
236
+ this.container.startWatching();
237
+ }
238
+
239
+ private async openHtmlInPreview(filePath: string): Promise<void> {
240
+ try {
241
+ const content = await this.container.readFile(filePath);
242
+ // Open or switch to the preview tab
243
+ await this.openPreviewTab();
244
+ const iframe = document.getElementById('preview-iframe') as HTMLIFrameElement;
245
+ const urlInput = document.getElementById('preview-url') as HTMLInputElement;
246
+ const loading = document.getElementById('preview-loading')!;
247
+ iframe.srcdoc = content;
248
+ urlInput.value = filePath;
249
+ loading.classList.add('hidden');
250
+ iframe.style.visibility = 'visible';
251
+ } catch {
252
+ // file may have been deleted or unreadable — ignore
253
+ }
254
+ }
255
+
256
+ private startFileTreeRefresh(): void {
257
+ this.refreshFileTree();
258
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
259
+ this.refreshTimer = setInterval(() => this.refreshFileTree(), 4000);
260
+ }
261
+
262
+ private async refreshFileTree(): Promise<void> {
263
+ const files = await this.container.listWorkspaceFiles();
264
+ const list = document.getElementById('filetree-list')!;
265
+ list.innerHTML = '';
266
+
267
+ for (const f of files) {
268
+ const isDir = f.endsWith('/');
269
+ const name = f.replace(/\/$/, '').split('/').pop() ?? f;
270
+ const depth = f.split('/').length - 1;
271
+ const item = document.createElement('div');
272
+ item.className = `ft-item${isDir ? ' is-dir' : ''}`;
273
+ item.dataset['depth'] = String(Math.min(depth, 3));
274
+
275
+ const icon = isDir ? svgIcon('folder') : fileIcon(name);
276
+ item.innerHTML = `
277
+ <span class="ft-icon">${icon}</span>
278
+ <span class="ft-name" title="${f}">${name}</span>
279
+ `;
280
+
281
+ if (!isDir) {
282
+ item.addEventListener('click', () => this.openFile(f, name));
283
+ }
284
+ list.appendChild(item);
285
+ }
286
+
287
+ if (files.length === 0) {
288
+ list.innerHTML = '<div style="padding:12px;color:var(--text-dim);font-size:11px;">No files yet</div>';
289
+ }
290
+ }
291
+
292
+ // ─── Tab management ─────────────────────────────────────────────────────────
293
+
294
+ private async openFile(relativePath: string, filename: string): Promise<void> {
295
+ const fullPath = `workspace/${relativePath}`;
296
+
297
+ // If tab already exists, just switch to it
298
+ if (this.tabs.some(t => t.filePath === fullPath)) {
299
+ this.switchTab(fullPath);
300
+ return;
301
+ }
302
+
303
+ // Create new tab
304
+ this.tabs.push({ filePath: fullPath, filename });
305
+
306
+ // Show editor panel if hidden
307
+ const editorPanel = document.getElementById('editor-panel')!;
308
+ if (editorPanel.classList.contains('hidden')) {
309
+ editorPanel.classList.remove('hidden');
310
+ }
311
+
312
+ // Create editor instance if this is the first file tab
313
+ const editorContainer = document.getElementById('editor-container')!;
314
+ createEditorInstance(editorContainer); // no-op if already created
315
+
316
+ // Load file content
317
+ let content = '';
318
+ try {
319
+ content = await this.container.readFile(fullPath) || '(empty)';
320
+ } catch (e) {
321
+ content = `Error: ${(e as Error).message}`;
322
+ }
323
+
324
+ openFileModel(fullPath, filename, content);
325
+ this.activeTabPath = fullPath;
326
+ this.updateContentVisibility();
327
+ this.renderTabBar();
328
+ }
329
+
330
+ private closeTab(filePath: string): void {
331
+ const idx = this.tabs.findIndex(t => t.filePath === filePath);
332
+ if (idx === -1) return;
333
+
334
+ if (filePath === AUDIT_TAB_PATH) {
335
+ if (this.auditUnsubscribe) { this.auditUnsubscribe(); this.auditUnsubscribe = null; }
336
+ document.getElementById('audit-log-list')!.innerHTML = '';
337
+ } else if (filePath !== PREVIEW_TAB_PATH && filePath !== POLICY_TAB_PATH && filePath !== CLOUD_BROWSER_TAB_PATH && !this.customTabPaths.has(filePath)) {
338
+ closeFileModel(filePath);
339
+ }
340
+ this.tabs.splice(idx, 1);
341
+
342
+ // Check if any file tabs remain (need editor instance)
343
+ const isSpecial = (p: string) => p === PREVIEW_TAB_PATH || p === AUDIT_TAB_PATH || p === POLICY_TAB_PATH || p === CLOUD_BROWSER_TAB_PATH || this.customTabPaths.has(p);
344
+ const hasFileTabs = this.tabs.some(t => !isSpecial(t.filePath));
345
+
346
+ if (this.tabs.length === 0) {
347
+ this.activeTabPath = null;
348
+ disposeAll();
349
+ document.getElementById('editor-panel')!.classList.add('hidden');
350
+ } else if (this.activeTabPath === filePath) {
351
+ const newIdx = Math.min(idx, this.tabs.length - 1);
352
+ this.switchTab(this.tabs[newIdx].filePath);
353
+ return; // switchTab already renders tab bar
354
+ }
355
+
356
+ // If no file tabs left, dispose the editor instance
357
+ if (!hasFileTabs) {
358
+ disposeAll();
359
+ }
360
+
361
+ this.renderTabBar();
362
+ }
363
+
364
+ private switchTab(filePath: string): void {
365
+ const tab = this.tabs.find(t => t.filePath === filePath);
366
+ if (!tab) return;
367
+
368
+ this.activeTabPath = filePath;
369
+ if (filePath !== PREVIEW_TAB_PATH && filePath !== AUDIT_TAB_PATH && filePath !== POLICY_TAB_PATH && filePath !== CLOUD_BROWSER_TAB_PATH && !this.customTabPaths.has(filePath)) {
370
+ openFileModel(filePath, tab.filename, ''); // model already cached, content ignored
371
+ }
372
+ if (filePath === AUDIT_TAB_PATH) {
373
+ this.renderAuditLog();
374
+ }
375
+ this.updateContentVisibility();
376
+ this.renderTabBar();
377
+ }
378
+
379
+ private renderTabBar(): void {
380
+ const bar = document.getElementById('tab-bar')!;
381
+ bar.innerHTML = '';
382
+
383
+ for (const tab of this.tabs) {
384
+ const el = document.createElement('div');
385
+ el.className = `tab${tab.filePath === this.activeTabPath ? ' active' : ''}`;
386
+
387
+ const nameSpan = document.createElement('span');
388
+ const isCustom = this.customTabPaths.has(tab.filePath);
389
+ if (isCustom) {
390
+ const id = tab.filePath.replace('__custom_', '').replace('__', '');
391
+ el.setAttribute('data-custom-tab-id', id);
392
+ }
393
+ nameSpan.innerHTML = tab.filePath === PREVIEW_TAB_PATH
394
+ ? `${svgIcon('globe')} Browser`
395
+ : tab.filePath === AUDIT_TAB_PATH
396
+ ? `${svgIcon('activity')} Audit Log`
397
+ : tab.filePath === POLICY_TAB_PATH
398
+ ? `${svgIcon('shield')} Policy`
399
+ : tab.filePath === CLOUD_BROWSER_TAB_PATH
400
+ ? `${svgIcon('cloud')} Cloud Browser`
401
+ : tab.filename;
402
+ el.appendChild(nameSpan);
403
+
404
+ const closeBtn = document.createElement('button');
405
+ closeBtn.className = 'tab-close';
406
+ closeBtn.textContent = '×';
407
+ closeBtn.addEventListener('click', (e) => {
408
+ e.stopPropagation();
409
+ this.closeTab(tab.filePath);
410
+ });
411
+ el.appendChild(closeBtn);
412
+
413
+ el.addEventListener('click', () => this.switchTab(tab.filePath));
414
+ bar.appendChild(el);
415
+ }
416
+ }
417
+
418
+ private async saveActiveFile(): Promise<void> {
419
+ if (!this.activeTabPath || this.activeTabPath === PREVIEW_TAB_PATH || this.activeTabPath === AUDIT_TAB_PATH || this.activeTabPath === POLICY_TAB_PATH || this.activeTabPath === CLOUD_BROWSER_TAB_PATH || this.customTabPaths.has(this.activeTabPath)) return;
420
+ const content = getModelContent(this.activeTabPath);
421
+ try {
422
+ await this.container.writeFile(this.activeTabPath, content);
423
+ } catch {
424
+ // silent fail for now
425
+ }
426
+ }
427
+
428
+ // ─── Preview tab ──────────────────────────────────────────────────────────
429
+
430
+ private previewBound = false;
431
+
432
+ private async openPreviewTab(): Promise<void> {
433
+ // If already open, switch to it
434
+ if (this.tabs.some(t => t.filePath === PREVIEW_TAB_PATH)) {
435
+ this.switchTab(PREVIEW_TAB_PATH);
436
+ return;
437
+ }
438
+
439
+ this.tabs.push({ filePath: PREVIEW_TAB_PATH, filename: 'Browser' });
440
+
441
+ // Show editor panel if hidden
442
+ const editorPanel = document.getElementById('editor-panel')!;
443
+ if (editorPanel.classList.contains('hidden')) {
444
+ editorPanel.classList.remove('hidden');
445
+ }
446
+
447
+ this.activeTabPath = PREVIEW_TAB_PATH;
448
+ this.updateContentVisibility();
449
+ this.renderTabBar();
450
+
451
+ const urlInput = document.getElementById('preview-url') as HTMLInputElement;
452
+ const iframe = document.getElementById('preview-iframe') as HTMLIFrameElement;
453
+ const loading = document.getElementById('preview-loading')!;
454
+
455
+ if (!this.previewBound) {
456
+ this.previewBound = true;
457
+
458
+ // Navigate on Enter
459
+ urlInput.addEventListener('keydown', (e) => {
460
+ if (e.key === 'Enter') {
461
+ let val = urlInput.value.trim();
462
+ if (val && !val.startsWith('http')) val = 'https://' + val;
463
+ if (val) {
464
+ iframe.src = val;
465
+ loading.classList.remove('hidden');
466
+ iframe.style.visibility = 'hidden';
467
+ }
468
+ }
469
+ });
470
+
471
+ // Nav buttons
472
+ document.getElementById('preview-reload')!.addEventListener('click', () => {
473
+ if (iframe.src) iframe.src = iframe.src;
474
+ });
475
+ document.getElementById('preview-back')!.addEventListener('click', () => {
476
+ try { iframe.contentWindow?.history.back(); } catch { /* cross-origin */ }
477
+ });
478
+ document.getElementById('preview-forward')!.addEventListener('click', () => {
479
+ try { iframe.contentWindow?.history.forward(); } catch { /* cross-origin */ }
480
+ });
481
+
482
+ // Sync URL bar on iframe load
483
+ iframe.addEventListener('load', () => {
484
+ loading.classList.add('hidden');
485
+ iframe.style.visibility = 'visible';
486
+ try { urlInput.value = iframe.contentWindow?.location.href ?? ''; } catch { /* cross-origin */ }
487
+ });
488
+ }
489
+
490
+ // Show welcome page immediately instead of waiting for a server
491
+ iframe.srcdoc = `<!DOCTYPE html>
492
+ <html lang="en">
493
+ <head>
494
+ <meta charset="UTF-8">
495
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
496
+ <style>
497
+ * { margin: 0; padding: 0; box-sizing: border-box; }
498
+ body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding-top: 2.5rem; }
499
+ .welcome { text-align: center; max-width: 480px; padding: 2rem; }
500
+ .welcome-logo { width: 80px; height: 80px; margin-bottom: 0.25rem; border-radius: 16px; }
501
+ h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
502
+ h1 span { color: #f78166; }
503
+ p.tagline { color: #8b949e; margin-bottom: 1.5rem; font-size: 1rem; }
504
+ ul { list-style: none; text-align: left; }
505
+ ul li { padding: 0.4rem 0; color: #e6edf3; }
506
+ ul li::before { content: '▸ '; color: #f78166; }
507
+ .note-bar { background: #161b22; border-bottom: 1px solid #30363d; padding: 0.5rem 1rem; text-align: center; font-size: 0.8rem; color: #8b949e; position: fixed; top: 0; left: 0; right: 0; z-index: 10; }
508
+ .note-bar span { color: #f78166; }
509
+ </style>
510
+ </head>
511
+ <body>
512
+ <div class="note-bar">Please wait while the Claw Agent gets installed — by default it installs <span>GitClaw</span>, a variant of <span>OpenClaw</span>, but better :)</div>
513
+ <div class="welcome">
514
+ <img class="welcome-logo" src="${window.location.origin}/logo.png" alt="ClawLess logo" />
515
+ <h1>Welcome to <span>ClawLess</span></h1>
516
+ <p class="tagline" style="margin-bottom:0.5rem;font-size:0.75rem;">MIT Licensed | Made with ❤️ by Shreyas Kapale @ <span style="color:#f78166;">Lyzr</span></p>
517
+ <p class="tagline">A ClawContainer — serverless AI agent runtime, entirely in your browser.</p>
518
+ <ul>
519
+ <li>Powered by WebAssembly</li>
520
+ <li>Secure sandboxed execution</li>
521
+ <li>Full auditability of every action</li>
522
+ <li>No remote servers required</li>
523
+ <li>MIT Licensed</li>
524
+ </ul>
525
+ <p class="tagline" style="margin-top:1.5rem;font-size:0.85rem;">Use the address bar above to navigate to any URL.</p>
526
+ </div>
527
+ </body>
528
+ </html>`;
529
+ loading.classList.add('hidden');
530
+ iframe.style.visibility = 'visible';
531
+ }
532
+
533
+ // ─── Cloud Browser tab ──────────────────────────────────────────────────
534
+
535
+ private cloudBrowserBound = false;
536
+
537
+ private async openCloudBrowserTab(): Promise<void> {
538
+ // If already open, switch to it
539
+ if (this.tabs.some(t => t.filePath === CLOUD_BROWSER_TAB_PATH)) {
540
+ this.switchTab(CLOUD_BROWSER_TAB_PATH);
541
+ return;
542
+ }
543
+
544
+ this.tabs.push({ filePath: CLOUD_BROWSER_TAB_PATH, filename: 'Cloud Browser' });
545
+
546
+ const editorPanel = document.getElementById('editor-panel')!;
547
+ if (editorPanel.classList.contains('hidden')) {
548
+ editorPanel.classList.remove('hidden');
549
+ }
550
+
551
+ this.activeTabPath = CLOUD_BROWSER_TAB_PATH;
552
+ this.updateContentVisibility();
553
+ this.renderTabBar();
554
+
555
+ const iframe = document.getElementById('cloud-browser-iframe') as HTMLIFrameElement;
556
+ const loading = document.getElementById('cloud-browser-loading')!;
557
+ const status = document.getElementById('cloud-browser-status')!;
558
+ const urlInput = document.getElementById('cloud-browser-url') as HTMLInputElement;
559
+
560
+ if (!this.cloudBrowserBound) {
561
+ this.cloudBrowserBound = true;
562
+
563
+ document.getElementById('cloud-browser-reload')!.addEventListener('click', () => {
564
+ if (iframe.src) iframe.src = iframe.src;
565
+ });
566
+
567
+ const navigateCloud = () => {
568
+ const val = urlInput.value.trim();
569
+ if (!val) return;
570
+ // The Browserbase live view is interactive — user navigates directly in the iframe.
571
+ // The URL bar here is informational; navigation happens inside the session.
572
+ status.textContent = 'Navigate inside the cloud browser directly.';
573
+ };
574
+
575
+ document.getElementById('cloud-browser-go')!.addEventListener('click', navigateCloud);
576
+ urlInput.addEventListener('keydown', (e) => {
577
+ if (e.key === 'Enter') navigateCloud();
578
+ });
579
+
580
+ // Scale iframe to fit viewport
581
+ const viewport = document.getElementById('cloud-browser-viewport')!;
582
+ const scaleIframe = () => {
583
+ const vw = viewport.clientWidth;
584
+ const vh = viewport.clientHeight;
585
+ if (vw === 0 || vh === 0) return;
586
+ const scale = Math.min(vw / 1920, vh / 1080);
587
+ iframe.style.transform = `scale(${scale})`;
588
+ iframe.style.width = '1920px';
589
+ iframe.style.height = '1080px';
590
+ };
591
+ const resizeObserver = new ResizeObserver(scaleIframe);
592
+ resizeObserver.observe(viewport);
593
+ scaleIframe();
594
+ }
595
+
596
+ // Read API credentials from localStorage env vars
597
+ const envJson = localStorage.getItem(`${LS_PREFIX}envVars`);
598
+ let apiKey = '';
599
+ let projectId = '';
600
+ if (envJson) {
601
+ try {
602
+ const envVars = JSON.parse(envJson) as Record<string, string>;
603
+ apiKey = envVars['BROWSERBASE_API_KEY'] || '';
604
+ projectId = envVars['BROWSERBASE_PROJECT_ID'] || '';
605
+ } catch { /* ignore */ }
606
+ }
607
+
608
+ if (!apiKey || !projectId) {
609
+ loading.textContent = 'Missing BROWSERBASE_API_KEY or BROWSERBASE_PROJECT_ID. Add them in Config panel.';
610
+ status.textContent = 'Not configured';
611
+ return;
612
+ }
613
+
614
+ // Create Browserbase session
615
+ loading.textContent = 'Starting cloud browser…';
616
+ loading.classList.remove('hidden');
617
+ iframe.style.visibility = 'hidden';
618
+ status.textContent = 'Connecting…';
619
+
620
+ try {
621
+ let res: Response;
622
+ try {
623
+ res = await fetch('/api/browserbase/v1/sessions', {
624
+ method: 'POST',
625
+ headers: {
626
+ 'X-BB-API-Key': apiKey,
627
+ 'Content-Type': 'application/json',
628
+ },
629
+ body: JSON.stringify({ projectId }),
630
+ });
631
+ } catch (fetchErr) {
632
+ throw new Error(
633
+ `Network error calling Browserbase API: ${(fetchErr as Error).message}. ` +
634
+ 'Check the browser console (F12 → Console) for details.'
635
+ );
636
+ }
637
+
638
+ if (!res.ok) {
639
+ let errBody = '';
640
+ try { errBody = await res.text(); } catch { /* ignore */ }
641
+ const detail = errBody ? `: ${errBody}` : '';
642
+ if (res.status === 401 || res.status === 403) {
643
+ throw new Error(`Authentication failed (${res.status})${detail}. Check your BROWSERBASE_API_KEY.`);
644
+ } else if (res.status === 404) {
645
+ throw new Error(`Not found (${res.status})${detail}. Check your BROWSERBASE_PROJECT_ID.`);
646
+ } else if (res.status === 429) {
647
+ throw new Error(`Rate limited (${res.status})${detail}. Try again in a moment.`);
648
+ }
649
+ throw new Error(`Session creation failed (${res.status})${detail}`);
650
+ }
651
+
652
+ const session = await res.json();
653
+ if (!session.id) {
654
+ throw new Error(`Unexpected response — no session ID returned: ${JSON.stringify(session)}`);
655
+ }
656
+
657
+ // Get debug/live view URL
658
+ let debugRes: Response;
659
+ try {
660
+ debugRes = await fetch(`/api/browserbase/v1/sessions/${session.id}/debug`, {
661
+ headers: { 'X-BB-API-Key': apiKey },
662
+ });
663
+ } catch (fetchErr) {
664
+ throw new Error(
665
+ `Network error fetching debug URL: ${(fetchErr as Error).message}. ` +
666
+ 'Check the browser console (F12 → Console) for details.'
667
+ );
668
+ }
669
+
670
+ if (!debugRes.ok) {
671
+ let errBody = '';
672
+ try { errBody = await debugRes.text(); } catch { /* ignore */ }
673
+ throw new Error(`Debug URL fetch failed (${debugRes.status})${errBody ? ': ' + errBody : ''}`);
674
+ }
675
+
676
+ const debug = await debugRes.json();
677
+ const liveUrl = debug.debuggerFullscreenUrl;
678
+
679
+ if (!liveUrl) {
680
+ throw new Error(`No debuggerFullscreenUrl in response: ${JSON.stringify(debug)}`);
681
+ }
682
+
683
+ iframe.src = liveUrl;
684
+ urlInput.value = liveUrl;
685
+ status.textContent = 'Connected';
686
+
687
+ iframe.addEventListener('load', () => {
688
+ loading.classList.add('hidden');
689
+ iframe.style.visibility = 'visible';
690
+ }, { once: true });
691
+ } catch (e) {
692
+ const errMsg = (e as Error).message;
693
+ loading.textContent = errMsg;
694
+ status.textContent = 'Error';
695
+ console.error('[Cloud Browser]', e);
696
+ }
697
+ }
698
+
699
+ /** Show editor-container, preview-container, audit-container, policy-container, cloud-browser-container, or custom tab based on active tab type. */
700
+ private updateContentVisibility(): void {
701
+ const editorContainer = document.getElementById('editor-container')!;
702
+ const previewContainer = document.getElementById('preview-container')!;
703
+ const auditContainer = document.getElementById('audit-container')!;
704
+ const policyContainer = document.getElementById('policy-container')!;
705
+ const cloudBrowserContainer = document.getElementById('cloud-browser-container')!;
706
+
707
+ editorContainer.classList.add('hidden');
708
+ previewContainer.classList.add('hidden');
709
+ auditContainer.classList.add('hidden');
710
+ policyContainer.classList.add('hidden');
711
+ cloudBrowserContainer.classList.add('hidden');
712
+
713
+ // Hide all custom tab content
714
+ for (const path of this.customTabPaths) {
715
+ const id = path.replace('__custom_', '').replace('__', '');
716
+ document.getElementById(`custom-tab-${id}`)?.classList.add('hidden');
717
+ }
718
+
719
+ if (this.activeTabPath === PREVIEW_TAB_PATH) {
720
+ previewContainer.classList.remove('hidden');
721
+ } else if (this.activeTabPath === AUDIT_TAB_PATH) {
722
+ auditContainer.classList.remove('hidden');
723
+ } else if (this.activeTabPath === POLICY_TAB_PATH) {
724
+ policyContainer.classList.remove('hidden');
725
+ } else if (this.activeTabPath === CLOUD_BROWSER_TAB_PATH) {
726
+ cloudBrowserContainer.classList.remove('hidden');
727
+ } else if (this.activeTabPath && this.customTabPaths.has(this.activeTabPath)) {
728
+ const id = this.activeTabPath.replace('__custom_', '').replace('__', '');
729
+ document.getElementById(`custom-tab-${id}`)?.classList.remove('hidden');
730
+ } else {
731
+ editorContainer.classList.remove('hidden');
732
+ }
733
+ }
734
+
735
+ // ─── Audit tab ───────────────────────────────────────────────────────────
736
+
737
+ private openAuditTab(): void {
738
+ // If already open, switch to it
739
+ if (this.tabs.some(t => t.filePath === AUDIT_TAB_PATH)) {
740
+ this.switchTab(AUDIT_TAB_PATH);
741
+ return;
742
+ }
743
+
744
+ this.tabs.push({ filePath: AUDIT_TAB_PATH, filename: 'Audit Log' });
745
+
746
+ // Show editor panel if hidden
747
+ const editorPanel = document.getElementById('editor-panel')!;
748
+ if (editorPanel.classList.contains('hidden')) {
749
+ editorPanel.classList.remove('hidden');
750
+ }
751
+
752
+ this.activeTabPath = AUDIT_TAB_PATH;
753
+ this.updateContentVisibility();
754
+ this.renderTabBar();
755
+ this.renderAuditLog();
756
+ this.bindAuditFilters();
757
+
758
+ // Subscribe to live entries
759
+ this.auditUnsubscribe = this.audit.onEntry((entry) => {
760
+ if (this.activeTabPath !== AUDIT_TAB_PATH) return;
761
+ if (!this.matchesAuditFilters(entry)) return;
762
+ const list = document.getElementById('audit-log-list')!;
763
+ const wasAtBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 30;
764
+ list.appendChild(this.createAuditRow(entry));
765
+ if (wasAtBottom) list.scrollTop = list.scrollHeight;
766
+ });
767
+ }
768
+
769
+ private bindAuditFilters(): void {
770
+ const ids = ['audit-search', 'audit-filter-source', 'audit-filter-level', 'audit-filter-event', 'audit-date-from', 'audit-date-to'];
771
+ for (const id of ids) {
772
+ const el = document.getElementById(id)!;
773
+ el.addEventListener('input', () => this.renderAuditLog());
774
+ el.addEventListener('change', () => this.renderAuditLog());
775
+ }
776
+
777
+ document.getElementById('btn-audit-download')!.addEventListener('click', () => {
778
+ const json = this.audit.toJSON();
779
+ const blob = new Blob([json], { type: 'application/json' });
780
+ const url = URL.createObjectURL(blob);
781
+ const a = document.createElement('a');
782
+ a.href = url; a.download = 'audit.json'; a.click();
783
+ URL.revokeObjectURL(url);
784
+ });
785
+ }
786
+
787
+ private matchesAuditFilters(e: import('./audit.js').AuditEntry): boolean {
788
+ const source = (document.getElementById('audit-filter-source') as HTMLSelectElement).value;
789
+ const level = (document.getElementById('audit-filter-level') as HTMLSelectElement).value;
790
+ const event = (document.getElementById('audit-filter-event') as HTMLSelectElement).value;
791
+ const search = (document.getElementById('audit-search') as HTMLInputElement).value.toLowerCase();
792
+ const dateFrom = (document.getElementById('audit-date-from') as HTMLInputElement).value;
793
+ const dateTo = (document.getElementById('audit-date-to') as HTMLInputElement).value;
794
+
795
+ if (source && e.source !== source) return false;
796
+ if (level && e.level !== level) return false;
797
+ if (event && e.event !== event) return false;
798
+ if (dateFrom && e.timestamp < new Date(dateFrom).toISOString()) return false;
799
+ if (dateTo && e.timestamp > new Date(dateTo).toISOString()) return false;
800
+ if (search) {
801
+ const hay = `${e.detail} ${e.event} ${e.source ?? ''} ${e.meta ? JSON.stringify(e.meta) : ''}`.toLowerCase();
802
+ if (!hay.includes(search)) return false;
803
+ }
804
+ return true;
805
+ }
806
+
807
+ private renderAuditLog(): void {
808
+ const list = document.getElementById('audit-log-list')!;
809
+ list.innerHTML = '';
810
+ const entries = this.audit.getEntries().filter(e => this.matchesAuditFilters(e));
811
+ for (const entry of entries) {
812
+ list.appendChild(this.createAuditRow(entry));
813
+ }
814
+ list.scrollTop = list.scrollHeight;
815
+ }
816
+
817
+ private createAuditRow(e: import('./audit.js').AuditEntry): HTMLElement {
818
+ const row = document.createElement('div');
819
+ row.className = 'audit-row';
820
+
821
+ const ts = e.timestamp.slice(11, 23); // HH:mm:ss.mmm
822
+ const lvl = (e.level ?? 'info');
823
+ const src = e.source ?? 'system';
824
+
825
+ row.innerHTML =
826
+ `<span class="audit-ts">${ts}</span>` +
827
+ `<span class="audit-level audit-level-${lvl}">${lvl}</span>` +
828
+ `<span class="audit-source">${src}</span>` +
829
+ `<span class="audit-event">${e.event}</span>` +
830
+ `<span class="audit-detail" title="${this.escHtml(e.detail)}">${this.escHtml(e.detail)}</span>` +
831
+ (e.meta ? `<span class="audit-meta">${this.escHtml(JSON.stringify(e.meta))}</span>` : '');
832
+
833
+ return row;
834
+ }
835
+
836
+ private escHtml(s: string): string {
837
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
838
+ }
839
+
840
+ // ─── Topbar ────────────────────────────────────────────────────────────────
841
+
842
+ private bindTopbarButtons(): void {
843
+ document.getElementById('btn-preview')!.addEventListener('click', () =>
844
+ this.openPreviewTab());
845
+
846
+ document.getElementById('btn-config')!.addEventListener('click', () =>
847
+ this.togglePanel('config-panel', 'btn-config'));
848
+
849
+ document.getElementById('btn-policy')!.addEventListener('click', () =>
850
+ this.openPolicyTab());
851
+
852
+ document.getElementById('btn-audit')!.addEventListener('click', () =>
853
+ this.openAuditTab());
854
+
855
+ document.getElementById('btn-cloud-browser')!.addEventListener('click', () =>
856
+ this.openCloudBrowserTab());
857
+ }
858
+
859
+ // ─── Repo controls (clone / sync / auto-sync) ────────────────────────────
860
+
861
+ private bindRepoControls(): void {
862
+ const urlInput = document.getElementById('repo-url-input') as HTMLInputElement;
863
+ const btnClone = document.getElementById('btn-clone')!;
864
+ const btnSync = document.getElementById('btn-sync')!;
865
+ const autoSyncCb = document.getElementById('auto-sync-checkbox') as HTMLInputElement;
866
+
867
+ btnClone.addEventListener('click', () => this.handleClone(urlInput));
868
+ btnSync.addEventListener('click', () => this.handleSync(btnSync));
869
+
870
+ autoSyncCb.addEventListener('change', () => {
871
+ if (autoSyncCb.checked && this.container.hasClonedRepo) {
872
+ this.startAutoSync();
873
+ } else {
874
+ this.stopAutoSync();
875
+ }
876
+ });
877
+ }
878
+
879
+ private async handleClone(urlInput: HTMLInputElement): Promise<void> {
880
+ if (this.cloneInProgress) return;
881
+ const url = urlInput.value.trim();
882
+ if (!url) { urlInput.focus(); return; }
883
+
884
+ // Get GITHUB_TOKEN from saved env vars
885
+ const config = this.getSavedConfig();
886
+ const token = config?.envVars?.['GITHUB_TOKEN'] ?? '';
887
+ if (!token) {
888
+ alert('Please add a GITHUB_TOKEN in API Keys & Config before cloning.');
889
+ return;
890
+ }
891
+
892
+ this.cloneInProgress = true;
893
+ const btnClone = document.getElementById('btn-clone')!;
894
+ btnClone.classList.add('syncing');
895
+
896
+ try {
897
+ await this.container.cloneRepo(url, token);
898
+ document.getElementById('btn-sync')!.removeAttribute('disabled');
899
+ this.refreshFileTree();
900
+ } catch (e) {
901
+ alert(`Clone failed: ${e instanceof Error ? e.message : String(e)}`);
902
+ } finally {
903
+ this.cloneInProgress = false;
904
+ btnClone.classList.remove('syncing');
905
+ }
906
+ }
907
+
908
+ private async handleSync(btnSync: HTMLElement): Promise<void> {
909
+ if (this.syncInProgress || !this.container.hasClonedRepo) return;
910
+ this.syncInProgress = true;
911
+ btnSync.classList.add('syncing');
912
+
913
+ try {
914
+ await this.container.syncToRepo();
915
+ } catch (e) {
916
+ alert(`Sync failed: ${e instanceof Error ? e.message : String(e)}`);
917
+ } finally {
918
+ this.syncInProgress = false;
919
+ btnSync.classList.remove('syncing');
920
+ }
921
+ }
922
+
923
+ private startAutoSync(): void {
924
+ if (!this.container.hasClonedRepo) return;
925
+ // Listen for file changes with debounce
926
+ this.container.onFileChange((path: string) => {
927
+ // Skip ignored paths
928
+ if (/node_modules\/|\.git\/|\.env$/.test(path)) return;
929
+ if (this.cloneInProgress || this.syncInProgress) return;
930
+
931
+ if (this.autoSyncTimer) clearTimeout(this.autoSyncTimer);
932
+ this.autoSyncTimer = setTimeout(() => {
933
+ this.autoSyncTimer = null;
934
+ const btnSync = document.getElementById('btn-sync')!;
935
+ this.handleSync(btnSync);
936
+ }, 3000);
937
+ });
938
+ }
939
+
940
+ private stopAutoSync(): void {
941
+ if (this.autoSyncTimer) {
942
+ clearTimeout(this.autoSyncTimer);
943
+ this.autoSyncTimer = null;
944
+ }
945
+ }
946
+
947
+ // ─── Config panel ──────────────────────────────────────────────────────────
948
+
949
+ private bindConfigPanel(): void {
950
+ const providerSel = document.getElementById('provider-select') as HTMLSelectElement;
951
+ providerSel.addEventListener('change', () => {
952
+ this.populateModelOptions();
953
+ this.syncDefaultEnvKey();
954
+ });
955
+ document.getElementById('btn-add-env')!.addEventListener('click', () =>
956
+ this.addEnvRow('', ''));
957
+ document.getElementById('btn-save-config')!.addEventListener('click', () =>
958
+ this.saveConfig());
959
+ }
960
+
961
+ private populateModelOptions(): void {
962
+ const provider = (document.getElementById('provider-select') as HTMLSelectElement).value;
963
+ const modelSel = document.getElementById('model-select') as HTMLSelectElement;
964
+ modelSel.innerHTML = '';
965
+ for (const m of PROVIDER_MODELS[provider] ?? []) {
966
+ const opt = document.createElement('option');
967
+ opt.value = m; opt.textContent = m.split(':')[1];
968
+ modelSel.appendChild(opt);
969
+ }
970
+ }
971
+
972
+ /** Ensure the first env row key matches the selected provider. */
973
+ private syncDefaultEnvKey(): void {
974
+ const provider = (document.getElementById('provider-select') as HTMLSelectElement).value;
975
+ const keyName = providerEnvKey(provider);
976
+ const rows = document.getElementById('env-rows')!;
977
+ const firstKey = rows.querySelector('.env-key') as HTMLInputElement | null;
978
+ if (firstKey) firstKey.value = keyName;
979
+ }
980
+
981
+ private addEnvRow(key: string, value: string): HTMLDivElement {
982
+ const rows = document.getElementById('env-rows')!;
983
+ const row = document.createElement('div');
984
+ row.className = 'env-row';
985
+
986
+ const keyInput = document.createElement('input');
987
+ keyInput.type = 'text';
988
+ keyInput.className = 'env-key';
989
+ keyInput.placeholder = 'KEY_NAME';
990
+ keyInput.value = key;
991
+
992
+ const valInput = document.createElement('input');
993
+ valInput.type = 'password';
994
+ valInput.className = 'env-val';
995
+ valInput.placeholder = 'value';
996
+ valInput.value = value;
997
+
998
+ const removeBtn = document.createElement('button');
999
+ removeBtn.className = 'btn-remove-env';
1000
+ removeBtn.textContent = '\u00d7';
1001
+ removeBtn.title = 'Remove';
1002
+ removeBtn.addEventListener('click', () => row.remove());
1003
+
1004
+ row.appendChild(keyInput);
1005
+ row.appendChild(valInput);
1006
+ row.appendChild(removeBtn);
1007
+ rows.appendChild(row);
1008
+ return row;
1009
+ }
1010
+
1011
+ private getEnvRows(): Record<string, string> {
1012
+ const vars: Record<string, string> = {};
1013
+ const rows = document.querySelectorAll('#env-rows .env-row');
1014
+ for (const row of rows) {
1015
+ const k = (row.querySelector('.env-key') as HTMLInputElement).value.trim();
1016
+ const v = (row.querySelector('.env-val') as HTMLInputElement).value.trim();
1017
+ if (k && v) vars[k] = v;
1018
+ }
1019
+ return vars;
1020
+ }
1021
+
1022
+ private restoreConfig(): void {
1023
+ const provider = localStorage.getItem(`${LS_PREFIX}provider`);
1024
+ const model = localStorage.getItem(`${LS_PREFIX}model`);
1025
+ const envJson = localStorage.getItem(`${LS_PREFIX}envVars`);
1026
+
1027
+ if (provider) {
1028
+ (document.getElementById('provider-select') as HTMLSelectElement).value = provider;
1029
+ this.populateModelOptions();
1030
+ }
1031
+ if (model) setTimeout(() => {
1032
+ (document.getElementById('model-select') as HTMLSelectElement).value = model;
1033
+ }, 0);
1034
+
1035
+ // Restore env var rows
1036
+ let envVars: Record<string, string> = {};
1037
+ if (envJson) {
1038
+ try { envVars = JSON.parse(envJson); } catch { /* ignore */ }
1039
+ }
1040
+
1041
+ // Migrate from old format if no envVars saved yet
1042
+ if (!envJson) {
1043
+ const oldKey = localStorage.getItem(`${LS_PREFIX}apiKey`);
1044
+ const oldVoice = localStorage.getItem(`${LS_PREFIX}openaiVoiceKey`);
1045
+ const prov = provider ?? 'anthropic';
1046
+ if (oldKey) envVars[providerEnvKey(prov)] = oldKey;
1047
+ if (oldVoice) envVars['OPENAI_API_KEY'] = oldVoice;
1048
+ }
1049
+
1050
+ if (Object.keys(envVars).length === 0) {
1051
+ // Add a default empty row for the selected provider
1052
+ const prov = provider ?? 'anthropic';
1053
+ this.addEnvRow(providerEnvKey(prov), '');
1054
+ } else {
1055
+ for (const [k, v] of Object.entries(envVars)) {
1056
+ this.addEnvRow(k, v);
1057
+ }
1058
+ }
1059
+ }
1060
+
1061
+ private async saveConfig(): Promise<void> {
1062
+ const provider = (document.getElementById('provider-select') as HTMLSelectElement).value;
1063
+ const model = (document.getElementById('model-select') as HTMLSelectElement).value;
1064
+ const envVars = this.getEnvRows();
1065
+ const msg = document.getElementById('config-message')!;
1066
+
1067
+ if (Object.keys(envVars).length === 0) {
1068
+ showMsg(msg, 'At least one environment variable is required.', 'error');
1069
+ return;
1070
+ }
1071
+
1072
+ localStorage.setItem(`${LS_PREFIX}provider`, provider);
1073
+ localStorage.setItem(`${LS_PREFIX}model`, model);
1074
+ localStorage.setItem(`${LS_PREFIX}envVars`, JSON.stringify(envVars));
1075
+
1076
+ try {
1077
+ await this.container.configureEnv({ provider, model, envVars });
1078
+ showMsg(msg, 'Saved. Restart gitclaw (/quit) to apply.', 'success');
1079
+ } catch (e) {
1080
+ showMsg(msg, `Error: ${(e as Error).message}`, 'error');
1081
+ }
1082
+ }
1083
+
1084
+ // ─── Policy tab ───────────────────────────────────────────────────────────
1085
+
1086
+ private openPolicyTab(): void {
1087
+ if (this.tabs.some(t => t.filePath === POLICY_TAB_PATH)) {
1088
+ this.switchTab(POLICY_TAB_PATH);
1089
+ return;
1090
+ }
1091
+
1092
+ this.tabs.push({ filePath: POLICY_TAB_PATH, filename: 'Policy' });
1093
+
1094
+ const editorPanel = document.getElementById('editor-panel')!;
1095
+ if (editorPanel.classList.contains('hidden')) {
1096
+ editorPanel.classList.remove('hidden');
1097
+ }
1098
+
1099
+ this.loadPolicyYaml();
1100
+ this.activeTabPath = POLICY_TAB_PATH;
1101
+ this.updateContentVisibility();
1102
+ this.renderTabBar();
1103
+ }
1104
+
1105
+ private bindPolicyPanel(): void {
1106
+ document.getElementById('btn-apply-policy')!.addEventListener('click', () =>
1107
+ this.applyPolicyYaml());
1108
+ document.getElementById('btn-reset-policy')!.addEventListener('click', () =>
1109
+ this.resetPolicy());
1110
+ }
1111
+
1112
+ private loadPolicyYaml(): void {
1113
+ (document.getElementById('policy-yaml-editor') as HTMLTextAreaElement).value =
1114
+ this.policy.toYaml();
1115
+ }
1116
+
1117
+ private applyPolicyYaml(): void {
1118
+ const yaml = (document.getElementById('policy-yaml-editor') as HTMLTextAreaElement).value;
1119
+ const msg = document.getElementById('policy-message')!;
1120
+ try {
1121
+ const parsed = PolicyEngine.fromYaml(yaml);
1122
+ this.policy.loadPolicy(parsed);
1123
+ localStorage.setItem('clawchef_policy', yaml);
1124
+ this.audit.log('policy.load', 'Policy updated from UI', undefined, { source: 'user' });
1125
+ showMsg(msg, 'Policy applied.', 'success');
1126
+ } catch (e) {
1127
+ showMsg(msg, `Invalid policy: ${(e as Error).message}`, 'error');
1128
+ }
1129
+ }
1130
+
1131
+ private resetPolicy(): void {
1132
+ const defaultPolicy = PolicyEngine.defaultPolicy();
1133
+ this.policy.loadPolicy(defaultPolicy);
1134
+ localStorage.removeItem('clawchef_policy');
1135
+ this.audit.log('policy.load', 'Policy reset to default', undefined, { source: 'user' });
1136
+ this.loadPolicyYaml();
1137
+ const msg = document.getElementById('policy-message')!;
1138
+ showMsg(msg, 'Policy reset to default.', 'success');
1139
+ }
1140
+
1141
+ // ─── Panel helpers ─────────────────────────────────────────────────────────
1142
+
1143
+ private togglePanel(panelId: string, btnId: string): void {
1144
+ const sidebar = document.getElementById('sidebar')!;
1145
+ const btn = document.getElementById(btnId)!;
1146
+
1147
+ if (this.activePanelId === panelId) {
1148
+ sidebar.classList.add('sidebar-hidden');
1149
+ btn.classList.remove('active');
1150
+ this.activePanelId = null;
1151
+ } else {
1152
+ this.openPanel(panelId);
1153
+ document.querySelectorAll('.btn-icon').forEach(b => b.classList.remove('active'));
1154
+ btn.classList.add('active');
1155
+ }
1156
+ }
1157
+
1158
+ private openPanel(panelId: string): void {
1159
+ document.getElementById('sidebar')!.classList.remove('sidebar-hidden');
1160
+ document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
1161
+ document.getElementById(panelId)!.classList.remove('hidden');
1162
+ this.activePanelId = panelId;
1163
+ }
1164
+
1165
+ // ─── Custom tab API ─────────────────────────────────────────────────────────
1166
+
1167
+ /** Add a custom tab with user-defined content. */
1168
+ addCustomTab(def: TabDefinition): void {
1169
+ const tabPath = `__custom_${def.id}__`;
1170
+
1171
+ // If tab already open, switch to it
1172
+ if (this.tabs.some(t => t.filePath === tabPath)) {
1173
+ this.switchTab(tabPath);
1174
+ return;
1175
+ }
1176
+
1177
+ // Create content container inside editor-panel
1178
+ const editorPanel = document.getElementById('editor-panel')!;
1179
+ const contentDiv = document.createElement('div');
1180
+ contentDiv.id = `custom-tab-${def.id}`;
1181
+ contentDiv.className = 'custom-tab-content hidden';
1182
+ contentDiv.style.cssText = 'position:absolute;inset:0;overflow:auto;padding:1rem;';
1183
+
1184
+ if (typeof def.render === 'string') {
1185
+ contentDiv.innerHTML = def.render;
1186
+ } else {
1187
+ def.render(contentDiv);
1188
+ }
1189
+
1190
+ editorPanel.appendChild(contentDiv);
1191
+
1192
+ // Track as custom
1193
+ this.customTabPaths.add(tabPath);
1194
+
1195
+ // Add tab
1196
+ this.tabs.push({ filePath: tabPath, filename: def.label });
1197
+
1198
+ if (editorPanel.classList.contains('hidden')) {
1199
+ editorPanel.classList.remove('hidden');
1200
+ }
1201
+
1202
+ this.activeTabPath = tabPath;
1203
+ this.updateContentVisibility();
1204
+ this.renderTabBar();
1205
+ }
1206
+
1207
+ /** Remove a custom tab by its definition id. */
1208
+ removeCustomTab(id: string): void {
1209
+ const tabPath = `__custom_${id}__`;
1210
+ this.customTabPaths.delete(tabPath);
1211
+
1212
+ // Remove content div
1213
+ const contentDiv = document.getElementById(`custom-tab-${id}`);
1214
+ contentDiv?.remove();
1215
+
1216
+ // Close the tab
1217
+ this.closeTab(tabPath);
1218
+ }
1219
+ }
1220
+
1221
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
1222
+
1223
+ function showMsg(el: HTMLElement, text: string, type: 'success'|'error'|'info'): void {
1224
+ el.textContent = text;
1225
+ el.className = `message ${type}`;
1226
+ }
1227
+
1228
+ function fileIcon(name: string): string {
1229
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
1230
+ if (ext === 'md') return svgIcon('file-text');
1231
+ if (ext === 'yaml' || ext === 'yml') return svgIcon('settings');
1232
+ if (ext === 'json') return svgIcon('braces');
1233
+ if (ext === 'ts' || ext === 'tsx') return svgIcon('file-code');
1234
+ if (ext === 'js' || ext === 'jsx') return svgIcon('file-code');
1235
+ if (ext === 'sh' || ext === 'bash') return svgIcon('terminal');
1236
+ if (ext === 'py') return svgIcon('file-code');
1237
+ if (['png','jpg','jpeg','gif','svg'].includes(ext)) return svgIcon('image');
1238
+ if (['pdf','pptx','ppt'].includes(ext)) return svgIcon('file-text');
1239
+ if (['xlsx','xls','csv'].includes(ext)) return svgIcon('table');
1240
+ if (['zip','tar','gz'].includes(ext)) return svgIcon('package');
1241
+ if (['mp4','mp3'].includes(ext)) return svgIcon('film');
1242
+ return svgIcon('file');
1243
+ }
1244
+
1245
+ /** Inline SVG icons — no external dependency needed. */
1246
+ function svgIcon(name: string): string {
1247
+ const s = `width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`;
1248
+ const icons: Record<string, string> = {
1249
+ folder: `<svg ${s}><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
1250
+ globe: `<svg ${s}><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>`,
1251
+ file: `<svg ${s}><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>`,
1252
+ 'file-text': `<svg ${s}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`,
1253
+ 'file-code': `<svg ${s}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><polyline points="10 15.5 8 13.5 10 11.5"/><polyline points="14 11.5 16 13.5 14 15.5"/></svg>`,
1254
+ settings: `<svg ${s}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
1255
+ braces: `<svg ${s}><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5a2 2 0 0 0 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>`,
1256
+ terminal: `<svg ${s}><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>`,
1257
+ image: `<svg ${s}><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
1258
+ table: `<svg ${s}><path d="M9 3H5a2 2 0 0 0-2 2v4m6-6h10a2 2 0 0 1 2 2v4M9 3v18m0 0h10a2 2 0 0 0 2-2V9M9 21H5a2 2 0 0 1-2-2V9m0 0h18"/></svg>`,
1259
+ package: `<svg ${s}><line x1="16.5" y1="9.4" x2="7.5" y2="4.21"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`,
1260
+ film: `<svg ${s}><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"/><line x1="7" y1="2" x2="7" y2="22"/><line x1="17" y1="2" x2="17" y2="22"/><line x1="2" y1="12" x2="22" y2="12"/><line x1="2" y1="7" x2="7" y2="7"/><line x1="2" y1="17" x2="7" y2="17"/><line x1="17" y1="7" x2="22" y2="7"/><line x1="17" y1="17" x2="22" y2="17"/></svg>`,
1261
+ activity: `<svg ${s}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`,
1262
+ shield: `<svg ${s}><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
1263
+ cloud: `<svg ${s}><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></svg>`,
1264
+ };
1265
+ return icons[name] ?? icons['file'];
1266
+ }