@danieltmn/tamnora 0.24.1 → 0.25.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 (50) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/harness/packages/server/public/app.js +833 -834
  3. package/harness/packages/server/public/appearance.js +4 -0
  4. package/harness/packages/server/public/boot.js +49 -0
  5. package/harness/packages/server/public/chat.js +17 -0
  6. package/harness/packages/server/public/chrome.js +855 -852
  7. package/harness/packages/server/public/commands.js +448 -403
  8. package/harness/packages/server/public/core.js +229 -216
  9. package/harness/packages/server/public/favicon.ico +0 -0
  10. package/harness/packages/server/public/icons/icon-128x128.png +0 -0
  11. package/harness/packages/server/public/icons/icon-144x144.png +0 -0
  12. package/harness/packages/server/public/icons/icon-152x152.png +0 -0
  13. package/harness/packages/server/public/icons/icon-192x192.png +0 -0
  14. package/harness/packages/server/public/icons/icon-384x384.png +0 -0
  15. package/harness/packages/server/public/icons/icon-512x512.png +0 -0
  16. package/harness/packages/server/public/icons/icon-72x72.png +0 -0
  17. package/harness/packages/server/public/icons/icon-96x96.png +0 -0
  18. package/harness/packages/server/public/icons/logo.png +0 -0
  19. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  20. package/harness/packages/server/public/icons/logo_light.png +0 -0
  21. package/harness/packages/server/public/icons/tamnora.ico +0 -0
  22. package/harness/packages/server/public/index.html +72 -101
  23. package/harness/packages/server/public/landing/boot.js +16 -0
  24. package/harness/packages/server/public/landing/index.html +123 -59
  25. package/harness/packages/server/public/landing/landing.js +11 -9
  26. package/harness/packages/server/public/logo.png +0 -0
  27. package/harness/packages/server/public/markdown.js +1 -1
  28. package/harness/packages/server/public/settings.js +1155 -1080
  29. package/harness/packages/server/public/settingsNav.js +81 -0
  30. package/harness/packages/server/public/styles.css +2818 -2515
  31. package/harness/packages/server/public/system.js +495 -495
  32. package/harness/packages/server/src/lib/appearance.js +4 -0
  33. package/harness/packages/server/src/lib/restart.js +20 -0
  34. package/harness/packages/server/src/lib/securityHeaders.js +53 -0
  35. package/harness/packages/server/src/routes/reload.js +46 -0
  36. package/harness/packages/server/src/server.js +6 -1
  37. package/harness/packages/server/test/accessibility.test.js +72 -0
  38. package/harness/packages/server/test/appearance.test.js +4 -0
  39. package/harness/packages/server/test/consoleModules.test.js +133 -57
  40. package/harness/packages/server/test/consoleTabs.test.js +228 -0
  41. package/harness/packages/server/test/docsSync.test.js +73 -0
  42. package/harness/packages/server/test/landingLogin.test.js +89 -0
  43. package/harness/packages/server/test/markdownSecurity.test.js +80 -0
  44. package/harness/packages/server/test/reload.test.js +95 -0
  45. package/harness/packages/server/test/securityHeaders.test.js +109 -0
  46. package/harness/packages/server/test/settingsNav.test.js +90 -0
  47. package/harness/packages/server/test/themeCss.js +110 -0
  48. package/harness/packages/server/test/themes.test.js +72 -0
  49. package/harness/scripts/restart.mjs +101 -0
  50. package/package.json +1 -1
@@ -1,836 +1,835 @@
1
- // Tamnora console — dependency-free client for the harness server (terminal theme).
2
-
3
- import {
4
- activePane,
5
- addImageFiles,
6
- attachInput,
7
- chatForm,
8
- chatInput,
9
- closeMessageMenus,
10
- controllers,
11
- interruptBtn,
12
- openMessageMenu,
13
- panesEl,
14
- positionMessageMenu,
15
- renderFooterActivity,
16
- scheduleScrollNav,
17
- scrollDownBtn,
18
- scrollTopBtn,
19
- sendInterrupt,
20
- sendMessage,
21
- sendPresence,
22
- stopBtn,
23
- } from './chat.js';
24
- import {
25
- activateTab,
26
- agentControlsSlot,
27
- buildAppearanceControls,
28
- closeCreateFolder,
29
- closeFolderPicker,
30
- closeModeMenu,
31
- closeSettingsModal,
32
- createFolder,
33
- cwdPill,
34
- cycleTheme,
35
- filterFolders,
36
- folderGoto,
37
- folderList,
38
- folderModal,
39
- folderNewName,
40
- folderParent,
41
- gotoFolder,
42
- hideMoreMenu,
43
- initLayout,
44
- loadFolders,
45
- loadModels,
46
- loadModes,
47
- logout,
48
- modeMenu,
49
- modePill,
50
- modeSelect,
51
- modelPill,
52
- modelSelect,
53
- moreMenu,
54
- moreTab,
55
- moveAgentControls,
56
- openCreateFolder,
57
- openFolderPicker,
58
- openSettingsModal,
59
- persistAgent,
60
- providerSelect,
61
- renderModePill,
62
- setCwd,
63
- setMode,
64
- settingsModal,
65
- statusbarEl,
66
- strictInput,
67
- syncDefaultCwd,
68
- themeToggle,
69
- toggleMode,
70
- useFolder,
71
- writeDefaultCwd,
72
- yesInput,
73
- } from './chrome.js';
74
- import {
75
- handleSlashKey,
76
- hideSlashMenu,
77
- onChatInputChanged,
78
- openPalette,
79
- palette,
80
- renderPalette,
81
- runPaletteItem,
82
- setPaletteIndex,
83
- toast,
84
- } from './commands.js';
85
- import { $, api, setCsrfToken, state, storage } from './core.js';
86
- import {
87
- PREVIEW_DEVICE_KEY,
88
- PREVIEW_URL_KEY,
89
- applyTreeFilter,
90
- detectProcs,
91
- explorerFilter,
92
- explorerMode,
93
- filePreviewState,
94
- filePreviewTools,
95
- gitEntries,
96
- gitFilterEl,
97
- gitModalEl,
98
- loadPreview,
99
- openRemoteTunnel,
100
- previewFrame,
101
- previewTools,
102
- previewUrlInput,
103
- refreshExplorerMode,
104
- renderGitList,
105
- setExplorerMode,
106
- setExplorerOpen,
107
- setExplorerStatus,
108
- setPreviewStatus,
109
- setTunnelUi,
110
- syncPreviewTunnel,
111
- } from './explorer.js';
1
+ // Tamnora console — dependency-free client for the harness server (terminal theme).
2
+
3
+ import {
4
+ activePane,
5
+ addImageFiles,
6
+ attachInput,
7
+ chatForm,
8
+ chatInput,
9
+ closeMessageMenus,
10
+ controllers,
11
+ interruptBtn,
12
+ openMessageMenu,
13
+ panesEl,
14
+ positionMessageMenu,
15
+ renderFooterActivity,
16
+ scheduleScrollNav,
17
+ scrollDownBtn,
18
+ scrollTopBtn,
19
+ sendInterrupt,
20
+ sendMessage,
21
+ sendPresence,
22
+ stopBtn,
23
+ } from './chat.js';
24
+ import {
25
+ activateTab,
26
+ agentControlsSlot,
27
+ buildAppearanceControls,
28
+ closeCreateFolder,
29
+ closeFolderPicker,
30
+ closeModeMenu,
31
+ closeSettingsModal,
32
+ createFolder,
33
+ cwdPill,
34
+ cycleTheme,
35
+ filterFolders,
36
+ folderGoto,
37
+ folderList,
38
+ folderModal,
39
+ folderNewName,
40
+ folderParent,
41
+ gotoFolder,
42
+ hideMoreMenu,
43
+ initLayout,
44
+ loadFolders,
45
+ loadModels,
46
+ loadModes,
47
+ logout,
48
+ modeMenu,
49
+ modePill,
50
+ modeSelect,
51
+ modelPill,
52
+ modelSelect,
53
+ moreMenu,
54
+ moreTab,
55
+ moveAgentControls,
56
+ openCreateFolder,
57
+ openFolderPicker,
58
+ openSettingsModal,
59
+ persistAgent,
60
+ providerSelect,
61
+ renderModePill,
62
+ setCwd,
63
+ setMode,
64
+ settingsModal,
65
+ statusbarEl,
66
+ strictInput,
67
+ syncDefaultCwd,
68
+ themeToggle,
69
+ toggleMode,
70
+ useFolder,
71
+ writeDefaultCwd,
72
+ yesInput,
73
+ } from './chrome.js';
74
+ import {
75
+ handleSlashKey,
76
+ hideSlashMenu,
77
+ onChatInputChanged,
78
+ openPalette,
79
+ palette,
80
+ renderPalette,
81
+ runPaletteItem,
82
+ setPaletteIndex,
83
+ toast,
84
+ } from './commands.js';
85
+ import { $, api, scrollBehavior, setCsrfToken, state, storage } from './core.js';
86
+ import {
87
+ PREVIEW_DEVICE_KEY,
88
+ PREVIEW_URL_KEY,
89
+ applyTreeFilter,
90
+ detectProcs,
91
+ explorerFilter,
92
+ explorerMode,
93
+ filePreviewState,
94
+ filePreviewTools,
95
+ gitEntries,
96
+ gitFilterEl,
97
+ gitModalEl,
98
+ loadPreview,
99
+ openRemoteTunnel,
100
+ previewFrame,
101
+ previewTools,
102
+ previewUrlInput,
103
+ refreshExplorerMode,
104
+ renderGitList,
105
+ setExplorerMode,
106
+ setExplorerOpen,
107
+ setExplorerStatus,
108
+ setPreviewStatus,
109
+ setTunnelUi,
110
+ syncPreviewTunnel,
111
+ } from './explorer.js';
112
112
  import { renderMarkdown } from './markdown.js';
113
- import { wireModelPicker } from './models.js';
114
- import { restoreTabs, startNewSession } from './sessions.js';
115
- import {
116
- activateAjustesPanel,
117
- addPerfilFieldRow,
118
- ajustesTabs,
119
- closeMcpForm,
120
- closeMemoryForm,
121
- closeProviderForm,
122
- closeScheduleForm,
123
- closeSkillForm,
124
- importMcp,
125
- importSkill,
126
- limitsCachingEl,
127
- limitsDurationEl,
128
- limitsOutputEl,
129
- limitsRoundsEl,
130
- limitsTokensEl,
131
- mcpForm,
132
- mcpListEl,
133
- mcpModal,
134
- mcpModeEl,
135
- mcpPinnedEl,
136
- memoryForm,
137
- memoryModal,
138
- openMcpForm,
139
- openMemoryForm,
140
- openProviderForm,
141
- openScheduleForm,
142
- openSkillForm,
143
- perfil,
144
- providerForm,
145
- providerModal,
146
- refreshLimits,
147
- refreshMcp,
148
- refreshMcpCatalog,
149
- refreshProviders,
150
- refreshSkillCatalog,
151
- saveMemoryIndex,
152
- scheduleForm,
153
- scheduleModal,
154
- scheduleTypeFields,
155
- searchMcp,
156
- searchSkills,
157
- setStatus,
158
- skillForm,
159
- skillModal,
160
- submitAsesor,
161
- submitChannel,
162
- submitMcp,
163
- submitMemory,
164
- submitProfile,
165
- submitProvider,
166
- submitSchedule,
167
- submitSkill,
168
- syncMcpFormFields,
169
- testAsesor,
170
- testChannel,
171
- testProvider,
172
- vozPitchEl,
173
- vozRateEl,
174
- } from './settings.js';
175
- import {
176
- brandVerEl,
177
- dismissUpdate,
178
- evalRunBtn,
179
- evalTasks,
180
- followEvents,
181
- forceUpdateCheck,
182
- reconcile,
183
- refreshRuns,
184
- refreshUpdate,
185
- renderAlertSignals,
186
- runEval,
187
- runUpdate,
188
- watchLifecycle,
189
- } from './system.js';
190
- import {
191
- handsFreeBtn,
192
- micBtn,
193
- refreshTranscribe,
194
- refreshVoz,
195
- renderVozSliders,
196
- submitTranscribe,
197
- submitVoz,
198
- syncTranscribeMode,
199
- testVoz,
200
- toggleHandsFree,
201
- toggleMic,
202
- toggleTrTest,
203
- trModeEl,
204
- trTestBtn,
205
- } from './voice.js';
206
-
207
- moreTab.addEventListener('click', (event) => {
208
- event.stopPropagation();
209
- const open = moreMenu.hidden;
210
- moreMenu.hidden = !open;
211
- moreTab.setAttribute('aria-expanded', String(open));
212
- });
213
- document.querySelectorAll('#more-menu button').forEach((item) =>
214
- item.addEventListener('click', () => activateTab(item.dataset.tab))
215
- );
216
- document.addEventListener('click', (event) => {
217
- if (!moreMenu.hidden && !event.target.closest('.menu')) hideMoreMenu();
218
- });
219
- document.addEventListener('keydown', (event) => {
220
- if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
221
- });
222
-
223
- if (settingsModal) {
224
- $('#settings-toggle')?.addEventListener('click', openSettingsModal);
225
- $('#settings-close')?.addEventListener('click', closeSettingsModal);
226
- $('#settings-done')?.addEventListener('click', closeSettingsModal);
227
- $('#settings-providers')?.addEventListener('click', () => {
228
- closeSettingsModal();
229
- activateTab('providers');
230
- });
231
- $('#agent-settings-open')?.addEventListener('click', openSettingsModal);
232
- $('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
233
- $('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
234
- $('#default-cwd-clear')?.addEventListener('click', () => {
235
- writeDefaultCwd('');
236
- syncDefaultCwd();
237
- });
238
- settingsModal.addEventListener('click', (event) => {
239
- if (event.target === settingsModal) closeSettingsModal();
240
- });
241
- settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
242
- }
243
-
244
- if (statusbarEl) {
245
- statusbarEl.addEventListener('click', (event) => {
246
- if (event.target.closest('#sb-context')) return;
247
- openSettingsModal();
248
- });
249
- statusbarEl.addEventListener('keydown', (event) => {
250
- if (event.key === 'Enter' || event.key === ' ') {
251
- event.preventDefault();
252
- openSettingsModal();
253
- }
254
- });
255
- }
256
-
257
- if (palette.modal) {
258
- palette.input.addEventListener('input', () => {
259
- const q = palette.input.value;
260
- renderPalette(q);
261
- clearTimeout(palette.timer);
262
- palette.timer = setTimeout(async () => {
263
- if (q.trim().length < 2) {
264
- palette.results = { sessions: [], messages: [] };
265
- renderPalette(q);
266
- return;
267
- }
268
- const data = await api(`/api/search?q=${encodeURIComponent(q)}&limit=15`).catch(() => null);
269
- if (data) {
270
- palette.results = { sessions: data.sessions || [], messages: data.messages || [] };
271
- renderPalette(q);
272
- }
273
- }, 180);
274
- });
275
- palette.input.addEventListener('keydown', (event) => {
276
- if (event.key === 'ArrowDown') {
277
- event.preventDefault();
278
- setPaletteIndex(palette.index + 1);
279
- } else if (event.key === 'ArrowUp') {
280
- event.preventDefault();
281
- setPaletteIndex(palette.index - 1);
282
- } else if (event.key === 'Enter') {
283
- event.preventDefault();
284
- const item = palette.items[palette.index];
285
- if (item) runPaletteItem(item);
286
- }
287
- });
288
- palette.modal.addEventListener('click', (event) => {
289
- if (event.target === palette.modal) palette.modal.close();
290
- });
291
- $('#search-toggle')?.addEventListener('click', openPalette);
292
- document.addEventListener('keydown', (event) => {
293
- if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
294
- event.preventDefault();
295
- openPalette();
296
- }
297
- });
298
- }
299
-
300
- modeSelect.addEventListener('change', () => setMode(modeSelect.value));
301
-
302
- document.addEventListener('keydown', (event) => {
303
- if ((event.ctrlKey || event.metaKey) && event.key === '.') {
304
- event.preventDefault();
305
- toggleMode();
306
- }
307
- });
308
-
309
- cwdPill?.addEventListener('click', () => openFolderPicker('session'));
310
- modePill?.addEventListener('click', (event) => {
311
- event.stopPropagation();
312
- if (!modeMenu) return;
313
- modeMenu.hidden = !modeMenu.hidden;
314
- modePill.setAttribute('aria-expanded', modeMenu.hidden ? 'false' : 'true');
315
- });
316
- modeMenu?.addEventListener('click', async (event) => {
317
- const item = event.target.closest('.ctrl-menu-item');
318
- if (!item) return;
319
- const kind = item.dataset.kind;
320
- closeModeMenu();
321
- if (kind === 'full') {
322
- await setMode('agent');
323
- if (yesInput) yesInput.checked = true;
324
- } else {
325
- if (yesInput) yesInput.checked = false;
326
- await setMode(kind === 'read' ? 'plan' : kind === 'voice' ? 'voice' : 'agent');
327
- }
328
- renderModePill();
329
- });
330
- document.addEventListener('click', (event) => {
331
- if (modeMenu && !modeMenu.hidden && !event.target.closest('.ctrl-wrap')) closeModeMenu();
332
- });
333
- document.addEventListener('keydown', (event) => {
334
- if (event.key === 'Escape') closeModeMenu();
335
- });
336
- modelPill?.addEventListener('click', () => openSettingsModal());
337
- yesInput?.addEventListener('change', renderModePill);
338
- strictInput?.addEventListener('change', () =>
339
- api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
340
- );
341
-
342
- themeToggle?.addEventListener('click', cycleTheme);
343
-
344
- buildAppearanceControls();
345
-
346
- if (logout) {
347
- logout.addEventListener('click', async (event) => {
348
- event.preventDefault();
349
- try {
350
- await api('/api/auth/logout', { method: 'POST' });
351
- } catch {
352
- // igual salimos
353
- }
354
- location.href = '/landing';
355
- });
356
- }
357
-
358
- initLayout();
359
-
360
- providerSelect.addEventListener('change', async () => {
361
- await loadModels({ save: false });
362
- await persistAgent({ provider: providerSelect.value, model: modelSelect.value || undefined });
363
- });
364
- modelSelect.addEventListener('change', () => persistAgent({ model: modelSelect.value }));
365
-
366
- export let restorePreviewMax = null;
367
-
368
- scrollTopBtn.addEventListener('click', () => activePane?.scrollTo({ top: 0, behavior: 'smooth' }));
369
- scrollDownBtn.addEventListener('click', () =>
370
- activePane?.scrollTo({ top: activePane.scrollHeight, behavior: 'smooth' })
371
- );
372
- // `scroll` no burbujea: se escucha en captura desde el contenedor de los panes.
373
- panesEl.addEventListener('scroll', scheduleScrollNav, true);
374
- window.addEventListener('resize', scheduleScrollNav);
375
- new MutationObserver(scheduleScrollNav).observe(panesEl, {
376
- childList: true,
377
- characterData: true,
378
- subtree: true,
379
- });
380
-
381
- document.addEventListener('click', (event) => {
382
- if (!openMessageMenu) return;
383
- if (event.target.closest('.msg-menu') || event.target.closest('.msg-menu-btn')) return;
384
- closeMessageMenus();
385
- });
386
- document.addEventListener('keydown', (event) => {
387
- if (event.key === 'Escape') closeMessageMenus();
388
- });
389
- window.addEventListener('resize', positionMessageMenu);
390
- document.addEventListener('scroll', positionMessageMenu, true);
391
-
392
- panesEl.addEventListener('click', (event) => {
393
- const button = event.target.closest('.code-copy');
394
- if (!button) return;
395
- const pre = button.closest('.code-block')?.querySelector('pre');
396
- if (!pre || !navigator.clipboard) return;
397
- navigator.clipboard.writeText(pre.textContent).then(() => {
398
- button.textContent = 'copiado';
399
- setTimeout(() => (button.textContent = 'copiar'), 1200);
400
- });
401
- });
402
-
403
- /** Tool -> momento en que se aprobo por ultima vez. */
404
- state.recentDecisions = new Map();
405
-
406
- $('#new-session').addEventListener('click', startNewSession);
407
- $('#new-session-top').addEventListener('click', startNewSession);
408
-
409
- if (stopBtn) {
410
- stopBtn.addEventListener('click', async () => {
411
- const id = state.sessionId;
412
- if (!id || stopBtn.disabled) return;
413
- stopBtn.disabled = true;
414
- renderFooterActivity('stopping');
415
- // Abortamos el stream local (corta el seguimiento ya) y pedimos al server que
416
- // aborte el run: sin esto el run es durable y seguiria generando.
417
- controllers.get(id)?.abort();
418
- try {
419
- await api(`/api/sessions/${id}/stop`, { method: 'POST' });
420
- } catch (error) {
421
- toast(`No se pudo detener el run: ${error.message}`);
422
- } finally {
423
- stopBtn.disabled = false;
424
- }
425
- });
426
- }
427
-
428
- if (interruptBtn) interruptBtn.addEventListener('click', sendInterrupt);
429
-
430
- chatForm.addEventListener('submit', (event) => {
431
- event.preventDefault();
432
- sendMessage();
433
- });
434
- chatInput.addEventListener('keydown', (event) => {
435
- if (handleSlashKey(event)) return;
436
- if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
437
- event.preventDefault();
438
- sendInterrupt();
439
- return;
440
- }
441
- if (event.key === 'Enter' && !event.shiftKey) {
442
- event.preventDefault();
443
- sendMessage();
444
- }
445
- });
446
- chatInput.addEventListener('input', onChatInputChanged);
447
-
448
- micBtn?.addEventListener('click', toggleMic);
449
-
450
- handsFreeBtn?.addEventListener('click', toggleHandsFree);
451
-
452
- $('#attach-image').addEventListener('click', () => attachInput.click());
453
- attachInput.addEventListener('change', () => {
454
- addImageFiles([...attachInput.files]);
455
- attachInput.value = '';
456
- });
457
- chatInput.addEventListener('paste', (event) => {
458
- const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith('image/'));
459
- if (!files.length) return;
460
- event.preventDefault();
461
- addImageFiles(files);
462
- });
463
- chatForm.addEventListener('dragover', (event) => {
464
- event.preventDefault();
465
- chatForm.classList.add('dragging');
466
- });
467
- chatForm.addEventListener('dragleave', () => chatForm.classList.remove('dragging'));
468
- chatForm.addEventListener('drop', (event) => {
469
- const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
470
- if (!files.length) return;
471
- event.preventDefault();
472
- chatForm.classList.remove('dragging');
473
- addImageFiles(files);
474
- });
475
-
476
- chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
477
-
478
- $('#folder-new').addEventListener('click', openCreateFolder);
479
- $('#folder-new-cancel').addEventListener('click', closeCreateFolder);
480
- $('#folder-new-create').addEventListener('click', createFolder);
481
- folderNewName.addEventListener('keydown', (event) => {
482
- if (event.key === 'Enter') {
483
- event.preventDefault();
484
- createFolder();
485
- } else if (event.key === 'Escape') {
486
- event.preventDefault();
487
- closeCreateFolder();
488
- }
489
- });
490
- $('#folder-roots').addEventListener('click', () => loadFolders(''));
491
- $('#folder-home').addEventListener('click', () => loadFolders(state.home || ''));
492
- $('#folder-close').addEventListener('click', closeFolderPicker);
493
- $('#folder-cancel').addEventListener('click', () => {
494
- state.pendingInput = null;
495
- closeFolderPicker();
496
- });
497
- $('#folder-up').addEventListener('click', () => loadFolders(folderParent ?? ''));
498
- $('#folder-use').addEventListener('click', useFolder);
499
- $('#folder-go').addEventListener('click', gotoFolder);
500
- folderGoto.addEventListener('input', filterFolders);
501
- folderGoto.addEventListener('keydown', (event) => {
502
- if (event.key === 'Enter') {
503
- event.preventDefault();
504
- gotoFolder();
505
- }
506
- });
507
- folderList.addEventListener('keydown', (event) => {
508
- if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
509
- const items = [...folderList.querySelectorAll('.folder-item')];
510
- if (!items.length) return;
511
- event.preventDefault();
512
- const current = items.indexOf(document.activeElement);
513
- const next = event.key === 'ArrowDown' ? Math.min(items.length - 1, current + 1) : Math.max(0, current - 1);
514
- items[current === -1 ? 0 : next].focus();
515
- });
516
- folderModal.addEventListener('click', (event) => {
517
- if (event.target === folderModal) {
518
- state.pendingInput = null;
519
- closeFolderPicker();
520
- }
521
- });
522
-
523
- $('#provider-new').addEventListener('click', () => openProviderForm());
524
- $('#pf-cancel').addEventListener('click', closeProviderForm);
525
- $('#pf-close').addEventListener('click', closeProviderForm);
526
- $('#pf-test').addEventListener('click', testProvider);
527
- providerForm.addEventListener('submit', submitProvider);
528
- providerModal.addEventListener('click', (event) => {
529
- if (event.target === providerModal) closeProviderForm();
530
- });
531
-
532
- ajustesTabs.forEach((tab) =>
533
- tab.addEventListener('click', () => activateAjustesPanel(tab.dataset.panel))
534
- );
535
-
536
- $('#limits-save')?.addEventListener('click', async () => {
537
- const body = {
538
- maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
539
- maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
540
- maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
541
- maxOutputTokens: Math.max(0, Number(limitsOutputEl?.value) || 0),
542
- promptCaching: Boolean(limitsCachingEl?.checked),
543
- };
544
- try {
545
- await api('/api/settings', { method: 'PUT', body });
546
- refreshLimits();
547
- setStatus('#limits-msg', 'guardado', 'ok');
548
- } catch (error) {
549
- setStatus('#limits-msg', error.message, 'err');
550
- }
551
- });
552
-
553
- $('#tr-save')?.addEventListener('click', submitTranscribe);
554
- trModeEl?.addEventListener('change', syncTranscribeMode);
555
-
556
- trTestBtn?.addEventListener('click', toggleTrTest);
557
-
558
- perfil.add?.addEventListener('click', () => addPerfilFieldRow());
559
- perfil.save?.addEventListener('click', submitProfile);
560
-
561
- if (mcpListEl) {
562
- $('#mcp-new').addEventListener('click', () => openMcpForm());
563
- $('#mcp-import-btn')?.addEventListener('click', importMcp);
564
- $('#mcp-import')?.addEventListener('keydown', (event) => {
565
- if (event.key === 'Enter') {
566
- event.preventDefault();
567
- importMcp();
568
- }
569
- });
570
- $('#skill-import-btn')?.addEventListener('click', importSkill);
571
- $('#skill-import')?.addEventListener('keydown', (event) => {
572
- if (event.key === 'Enter') {
573
- event.preventDefault();
574
- importSkill();
575
- }
576
- });
577
- $('#mcp-search-btn')?.addEventListener('click', searchMcp);
578
- $('#mcp-search')?.addEventListener('keydown', (event) => {
579
- if (event.key === 'Enter') {
580
- event.preventDefault();
581
- searchMcp();
582
- }
583
- });
584
- $('#skill-search-btn')?.addEventListener('click', searchSkills);
585
- $('#skill-search')?.addEventListener('keydown', (event) => {
586
- if (event.key === 'Enter') {
587
- event.preventDefault();
588
- searchSkills();
589
- }
590
- });
591
- refreshMcpCatalog();
592
- refreshSkillCatalog();
593
- $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
594
- mcpModeEl?.addEventListener('change', async () => {
595
- try {
596
- await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
597
- await refreshMcp({ connect: true });
598
- } catch (error) {
599
- toast(error.message);
600
- }
601
- });
602
- const savePinnedMcp = async () => {
603
- try {
604
- await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
605
- await refreshMcp({ connect: true });
606
- } catch (error) {
607
- toast(error.message);
608
- }
609
- };
610
- $('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
611
- mcpPinnedEl?.addEventListener('keydown', (event) => {
612
- if (event.key === 'Enter') {
613
- event.preventDefault();
614
- savePinnedMcp();
615
- }
616
- });
617
- $('#tg-save')?.addEventListener('click', submitChannel);
618
- $('#tg-test')?.addEventListener('click', testChannel);
619
- $('#voz-save')?.addEventListener('click', submitVoz);
620
- $('#voz-test')?.addEventListener('click', testVoz);
621
- vozRateEl?.addEventListener('input', renderVozSliders);
622
- vozPitchEl?.addEventListener('input', renderVozSliders);
623
- $('#asesor-save')?.addEventListener('click', submitAsesor);
624
- $('#asesor-test')?.addEventListener('click', testAsesor);
625
- $('#mcp-close').addEventListener('click', closeMcpForm);
626
- $('#mf-cancel').addEventListener('click', closeMcpForm);
627
- $('#mf-transport').addEventListener('change', syncMcpFormFields);
628
- mcpForm.addEventListener('submit', submitMcp);
629
- mcpModal.addEventListener('click', (event) => {
630
- if (event.target === mcpModal) closeMcpForm();
631
- });
632
-
633
- $('#skill-new').addEventListener('click', () => openSkillForm());
634
- $('#skill-close').addEventListener('click', closeSkillForm);
635
- $('#sf-cancel').addEventListener('click', closeSkillForm);
636
- skillForm.addEventListener('submit', submitSkill);
637
- skillModal.addEventListener('click', (event) => {
638
- if (event.target === skillModal) closeSkillForm();
639
- });
640
-
641
- $('#memory-new')?.addEventListener('click', () => openMemoryForm());
642
- $('#memory-close')?.addEventListener('click', closeMemoryForm);
643
- $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
644
- memoryForm?.addEventListener('submit', submitMemory);
645
- $('#memory-save-index')?.addEventListener('click', saveMemoryIndex);
646
- memoryModal?.addEventListener('click', (event) => {
647
- if (event.target === memoryModal) closeMemoryForm();
648
- });
649
-
650
- $('#sc-type')?.addEventListener('change', scheduleTypeFields);
651
- $('#schedule-new')?.addEventListener('click', () => openScheduleForm());
652
- $('#schedule-close')?.addEventListener('click', closeScheduleForm);
653
- $('#sc-cancel')?.addEventListener('click', closeScheduleForm);
654
- scheduleForm?.addEventListener('submit', submitSchedule);
655
- scheduleModal?.addEventListener('click', (event) => {
656
- if (event.target === scheduleModal) closeScheduleForm();
657
- });
658
- }
659
-
660
- evalTasks.value = [
661
- '{"id":"math","input":"¿Cuánto es 17 * 23? Respondé solo el número.","expect":{"equals":"391"}}',
662
- '{"id":"regex","input":"Escribí una oración que contenga un número.","expect":{"regex":"\\\\d"}}',
663
- ].join('\n');
664
-
665
- evalRunBtn.addEventListener('click', runEval);
666
-
667
- $('#runs-refresh').addEventListener('click', refreshRuns);
668
-
669
- $('#file-preview-toggle')?.addEventListener('click', async () => {
670
- const toggle = $('#file-preview-toggle');
671
- const code = $('#file-code');
672
- const stage = $('#file-stage');
673
- const frame = $('#file-frame');
674
- const render = $('#file-render');
675
- const device = $('#file-device');
676
- const maxButton = $('#file-max');
677
- const current = filePreviewState.current;
678
- if (!current) return;
679
-
680
- filePreviewState.open = !filePreviewState.open;
681
- const off = () => {
682
- toggle.textContent = 'vista previa';
683
- code.hidden = false;
684
- stage.hidden = true;
685
- render.hidden = true;
686
- device.hidden = true;
687
- maxButton.hidden = false;
688
- $('#file-modal').classList.remove('preview-maximized');
689
- };
690
- if (!filePreviewState.open) return off();
691
-
692
- if (filePreviewState.kind === 'markdown') {
693
- render.innerHTML = renderMarkdown(filePreviewState.content);
694
- code.hidden = true;
695
- stage.hidden = true;
696
- render.hidden = false;
697
- device.hidden = true;
698
- maxButton.hidden = false;
699
- toggle.textContent = 'ver código';
700
- return;
701
- }
702
-
703
- device.hidden = false;
704
- maxButton.hidden = false;
705
- if (!filePreviewState.loaded) {
706
- const data = await api('/api/files/preview', {
707
- method: 'POST',
708
- body: { root: state.cwd, path: current.relPath },
709
- }).catch((error) => ({ error: error.message }));
710
- if (data?.error) {
711
- setExplorerStatus(data.error, true);
712
- filePreviewState.open = false;
713
- return off();
714
- }
715
- frame.src = data.url;
716
- filePreviewState.loaded = true;
717
- filePreviewTools.applyDevice();
718
- }
719
- toggle.textContent = 'ver código';
720
- code.hidden = true;
721
- render.hidden = true;
722
- stage.hidden = false;
723
- });
724
-
725
- document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
726
-
727
- restorePreviewMax = () => previewTools.setMax(false);
728
-
729
- $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
730
- previewUrlInput?.addEventListener('keydown', (event) => {
731
- if (event.key === 'Enter') {
732
- event.preventDefault();
733
- loadPreview(previewUrlInput.value);
734
- }
735
- });
736
- previewFrame?.addEventListener('load', () => {
737
- if (previewFrame.src) setPreviewStatus(previewFrame.src);
738
- });
739
- $('#preview-tunnel')?.addEventListener('click', () => {
740
- const match = String(previewUrlInput.value).match(/:(\d+)/);
741
- const port = match ? Number(match[1]) : 0;
742
- if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
743
- openRemoteTunnel(port);
744
- });
745
- $('#preview-close')?.addEventListener('click', async () => {
746
- await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
747
- setTunnelUi(null);
748
- setPreviewStatus('acceso remoto cerrado.');
749
- });
750
- $('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
751
-
752
- // Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
753
- // sola (iOS la descarta en background) y antes se perdia todo el estado.
754
- (function restorePreview() {
755
- const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
756
- if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
757
- previewTools.applyDevice();
758
- const savedUrl = storage.get(PREVIEW_URL_KEY, '');
759
- if (savedUrl) previewUrlInput.value = savedUrl;
760
- syncPreviewTunnel({ load: true });
761
- })();
762
-
763
- $('#procs-detect')?.addEventListener('click', detectProcs);
764
-
765
- gitFilterEl?.addEventListener('input', () => {
766
- if (explorerMode === 'git' && gitEntries.length) renderGitList();
767
- });
768
-
769
- $('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
770
- $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
771
- $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
772
- explorerFilter.addEventListener('input', applyTreeFilter);
773
- $('#file-close').addEventListener('click', () => $('#file-modal').close());
774
- $('#file-modal').addEventListener('click', (event) => {
775
- if (event.target === $('#file-modal')) $('#file-modal').close();
776
- });
777
- $('#git-close')?.addEventListener('click', () => gitModalEl?.close());
778
- gitModalEl?.addEventListener('click', (event) => {
779
- if (event.target === gitModalEl) gitModalEl.close();
780
- });
781
-
782
- try {
783
- setExplorerOpen(storage.get('tamnora-explorer-open') === '1');
784
- } catch {
785
- setExplorerOpen(false);
786
- }
787
-
788
- $('#update-run')?.addEventListener('click', runUpdate);
789
- $('#update-later')?.addEventListener('click', dismissUpdate);
790
-
791
- if (brandVerEl) {
792
- brandVerEl.title = 'buscar actualización';
793
- brandVerEl.classList.add('ver-action');
794
- brandVerEl.addEventListener('click', forceUpdateCheck);
795
- }
796
-
797
- /* --- boot ---------------------------------------------------------------- */
798
-
113
+ import { wireModelPicker } from './models.js';
114
+ import { restoreTabs, startNewSession } from './sessions.js';
115
+ import {
116
+ addPerfilFieldRow,
117
+ closeMcpForm,
118
+ closeMemoryForm,
119
+ closeProviderForm,
120
+ closeScheduleForm,
121
+ closeSkillForm,
122
+ importMcp,
123
+ importSkill,
124
+ limitsCachingEl,
125
+ limitsDurationEl,
126
+ limitsOutputEl,
127
+ limitsRoundsEl,
128
+ limitsTokensEl,
129
+ mcpForm,
130
+ mcpListEl,
131
+ mcpModal,
132
+ mcpModeEl,
133
+ mcpPinnedEl,
134
+ memoryForm,
135
+ memoryModal,
136
+ openMcpForm,
137
+ openMemoryForm,
138
+ openProviderForm,
139
+ openScheduleForm,
140
+ openSkillForm,
141
+ perfil,
142
+ providerForm,
143
+ providerModal,
144
+ refreshLimits,
145
+ refreshMcp,
146
+ refreshMcpCatalog,
147
+ refreshProviders,
148
+ refreshSkillCatalog,
149
+ saveMemoryIndex,
150
+ scheduleForm,
151
+ scheduleModal,
152
+ scheduleTypeFields,
153
+ searchMcp,
154
+ searchSkills,
155
+ setStatus,
156
+ skillForm,
157
+ skillModal,
158
+ submitAsesor,
159
+ submitChannel,
160
+ submitMcp,
161
+ submitMemory,
162
+ submitProfile,
163
+ submitProvider,
164
+ submitSchedule,
165
+ submitSkill,
166
+ syncMcpFormFields,
167
+ testAsesor,
168
+ testChannel,
169
+ testProvider,
170
+ vozPitchEl,
171
+ vozRateEl,
172
+ wireAjustesNav,
173
+ } from './settings.js';
174
+ import {
175
+ brandVerEl,
176
+ dismissUpdate,
177
+ evalRunBtn,
178
+ evalTasks,
179
+ followEvents,
180
+ forceUpdateCheck,
181
+ reconcile,
182
+ refreshRuns,
183
+ refreshUpdate,
184
+ renderAlertSignals,
185
+ runEval,
186
+ runUpdate,
187
+ watchLifecycle,
188
+ } from './system.js';
189
+ import {
190
+ handsFreeBtn,
191
+ micBtn,
192
+ refreshTranscribe,
193
+ refreshVoz,
194
+ renderVozSliders,
195
+ submitTranscribe,
196
+ submitVoz,
197
+ syncTranscribeMode,
198
+ testVoz,
199
+ toggleHandsFree,
200
+ toggleMic,
201
+ toggleTrTest,
202
+ trModeEl,
203
+ trTestBtn,
204
+ } from './voice.js';
205
+
206
+ moreTab.addEventListener('click', (event) => {
207
+ event.stopPropagation();
208
+ const open = moreMenu.hidden;
209
+ moreMenu.hidden = !open;
210
+ moreTab.setAttribute('aria-expanded', String(open));
211
+ });
212
+ document.querySelectorAll('#more-menu button').forEach((item) =>
213
+ item.addEventListener('click', () => activateTab(item.dataset.tab))
214
+ );
215
+ document.addEventListener('click', (event) => {
216
+ if (!moreMenu.hidden && !event.target.closest('.menu')) hideMoreMenu();
217
+ });
218
+ document.addEventListener('keydown', (event) => {
219
+ if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
220
+ });
221
+
222
+ $('#back-chat')?.addEventListener('click', () => activateTab('chat'));
223
+
224
+ if (settingsModal) {
225
+ $('#settings-toggle')?.addEventListener('click', openSettingsModal);
226
+ $('#settings-close')?.addEventListener('click', closeSettingsModal);
227
+ $('#settings-done')?.addEventListener('click', closeSettingsModal);
228
+ $('#settings-providers')?.addEventListener('click', () => {
229
+ closeSettingsModal();
230
+ activateTab('providers');
231
+ });
232
+ $('#agent-settings-open')?.addEventListener('click', openSettingsModal);
233
+ $('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
234
+ $('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
235
+ $('#default-cwd-clear')?.addEventListener('click', () => {
236
+ writeDefaultCwd('');
237
+ syncDefaultCwd();
238
+ });
239
+ settingsModal.addEventListener('click', (event) => {
240
+ if (event.target === settingsModal) closeSettingsModal();
241
+ });
242
+ settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
243
+ }
244
+
245
+ if (statusbarEl) {
246
+ statusbarEl.addEventListener('click', (event) => {
247
+ if (event.target.closest('#sb-context')) return;
248
+ openSettingsModal();
249
+ });
250
+ statusbarEl.addEventListener('keydown', (event) => {
251
+ if (event.key === 'Enter' || event.key === ' ') {
252
+ event.preventDefault();
253
+ openSettingsModal();
254
+ }
255
+ });
256
+ }
257
+
258
+ if (palette.modal) {
259
+ palette.input.addEventListener('input', () => {
260
+ const q = palette.input.value;
261
+ renderPalette(q);
262
+ clearTimeout(palette.timer);
263
+ palette.timer = setTimeout(async () => {
264
+ if (q.trim().length < 2) {
265
+ palette.results = { sessions: [], messages: [] };
266
+ renderPalette(q);
267
+ return;
268
+ }
269
+ const data = await api(`/api/search?q=${encodeURIComponent(q)}&limit=15`).catch(() => null);
270
+ if (data) {
271
+ palette.results = { sessions: data.sessions || [], messages: data.messages || [] };
272
+ renderPalette(q);
273
+ }
274
+ }, 180);
275
+ });
276
+ palette.input.addEventListener('keydown', (event) => {
277
+ if (event.key === 'ArrowDown') {
278
+ event.preventDefault();
279
+ setPaletteIndex(palette.index + 1);
280
+ } else if (event.key === 'ArrowUp') {
281
+ event.preventDefault();
282
+ setPaletteIndex(palette.index - 1);
283
+ } else if (event.key === 'Enter') {
284
+ event.preventDefault();
285
+ const item = palette.items[palette.index];
286
+ if (item) runPaletteItem(item);
287
+ }
288
+ });
289
+ palette.modal.addEventListener('click', (event) => {
290
+ if (event.target === palette.modal) palette.modal.close();
291
+ });
292
+ $('#search-toggle')?.addEventListener('click', openPalette);
293
+ document.addEventListener('keydown', (event) => {
294
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
295
+ event.preventDefault();
296
+ openPalette();
297
+ }
298
+ });
299
+ }
300
+
301
+ modeSelect.addEventListener('change', () => setMode(modeSelect.value));
302
+
303
+ document.addEventListener('keydown', (event) => {
304
+ if ((event.ctrlKey || event.metaKey) && event.key === '.') {
305
+ event.preventDefault();
306
+ toggleMode();
307
+ }
308
+ });
309
+
310
+ cwdPill?.addEventListener('click', () => openFolderPicker('session'));
311
+ modePill?.addEventListener('click', (event) => {
312
+ event.stopPropagation();
313
+ if (!modeMenu) return;
314
+ modeMenu.hidden = !modeMenu.hidden;
315
+ modePill.setAttribute('aria-expanded', modeMenu.hidden ? 'false' : 'true');
316
+ });
317
+ modeMenu?.addEventListener('click', async (event) => {
318
+ const item = event.target.closest('.ctrl-menu-item');
319
+ if (!item) return;
320
+ const kind = item.dataset.kind;
321
+ closeModeMenu();
322
+ if (kind === 'full') {
323
+ await setMode('agent');
324
+ if (yesInput) yesInput.checked = true;
325
+ } else {
326
+ if (yesInput) yesInput.checked = false;
327
+ await setMode(kind === 'read' ? 'plan' : kind === 'voice' ? 'voice' : 'agent');
328
+ }
329
+ renderModePill();
330
+ });
331
+ document.addEventListener('click', (event) => {
332
+ if (modeMenu && !modeMenu.hidden && !event.target.closest('.ctrl-wrap')) closeModeMenu();
333
+ });
334
+ document.addEventListener('keydown', (event) => {
335
+ if (event.key === 'Escape') closeModeMenu();
336
+ });
337
+ modelPill?.addEventListener('click', () => openSettingsModal());
338
+ yesInput?.addEventListener('change', renderModePill);
339
+ strictInput?.addEventListener('change', () =>
340
+ api('/api/settings', { method: 'PUT', body: { strictScope: strictInput.checked } }).catch(() => null)
341
+ );
342
+
343
+ themeToggle?.addEventListener('click', cycleTheme);
344
+
345
+ buildAppearanceControls();
346
+
347
+ if (logout) {
348
+ logout.addEventListener('click', async (event) => {
349
+ event.preventDefault();
350
+ try {
351
+ await api('/api/auth/logout', { method: 'POST' });
352
+ } catch {
353
+ // igual salimos
354
+ }
355
+ location.href = '/landing';
356
+ });
357
+ }
358
+
359
+ initLayout();
360
+
361
+ providerSelect.addEventListener('change', async () => {
362
+ await loadModels({ save: false });
363
+ await persistAgent({ provider: providerSelect.value, model: modelSelect.value || undefined });
364
+ });
365
+ modelSelect.addEventListener('change', () => persistAgent({ model: modelSelect.value }));
366
+
367
+ export let restorePreviewMax = null;
368
+
369
+ scrollTopBtn.addEventListener('click', () => activePane?.scrollTo({ top: 0, behavior: scrollBehavior() }));
370
+ scrollDownBtn.addEventListener('click', () =>
371
+ activePane?.scrollTo({ top: activePane.scrollHeight, behavior: scrollBehavior() })
372
+ );
373
+ // `scroll` no burbujea: se escucha en captura desde el contenedor de los panes.
374
+ panesEl.addEventListener('scroll', scheduleScrollNav, true);
375
+ window.addEventListener('resize', scheduleScrollNav);
376
+ new MutationObserver(scheduleScrollNav).observe(panesEl, {
377
+ childList: true,
378
+ characterData: true,
379
+ subtree: true,
380
+ });
381
+
382
+ document.addEventListener('click', (event) => {
383
+ if (!openMessageMenu) return;
384
+ if (event.target.closest('.msg-menu') || event.target.closest('.msg-menu-btn')) return;
385
+ closeMessageMenus();
386
+ });
387
+ document.addEventListener('keydown', (event) => {
388
+ if (event.key === 'Escape') closeMessageMenus();
389
+ });
390
+ window.addEventListener('resize', positionMessageMenu);
391
+ document.addEventListener('scroll', positionMessageMenu, true);
392
+
393
+ panesEl.addEventListener('click', (event) => {
394
+ const button = event.target.closest('.code-copy');
395
+ if (!button) return;
396
+ const pre = button.closest('.code-block')?.querySelector('pre');
397
+ if (!pre || !navigator.clipboard) return;
398
+ navigator.clipboard.writeText(pre.textContent).then(() => {
399
+ button.textContent = 'copiado';
400
+ setTimeout(() => (button.textContent = 'copiar'), 1200);
401
+ });
402
+ });
403
+
404
+ /** Tool -> momento en que se aprobo por ultima vez. */
405
+ state.recentDecisions = new Map();
406
+
407
+ $('#new-session').addEventListener('click', startNewSession);
408
+ $('#new-session-top').addEventListener('click', startNewSession);
409
+
410
+ if (stopBtn) {
411
+ stopBtn.addEventListener('click', async () => {
412
+ const id = state.sessionId;
413
+ if (!id || stopBtn.disabled) return;
414
+ stopBtn.disabled = true;
415
+ renderFooterActivity('stopping');
416
+ // Abortamos el stream local (corta el seguimiento ya) y pedimos al server que
417
+ // aborte el run: sin esto el run es durable y seguiria generando.
418
+ controllers.get(id)?.abort();
419
+ try {
420
+ await api(`/api/sessions/${id}/stop`, { method: 'POST' });
421
+ } catch (error) {
422
+ toast(`No se pudo detener el run: ${error.message}`);
423
+ } finally {
424
+ stopBtn.disabled = false;
425
+ }
426
+ });
427
+ }
428
+
429
+ if (interruptBtn) interruptBtn.addEventListener('click', sendInterrupt);
430
+
431
+ chatForm.addEventListener('submit', (event) => {
432
+ event.preventDefault();
433
+ sendMessage();
434
+ });
435
+ chatInput.addEventListener('keydown', (event) => {
436
+ if (handleSlashKey(event)) return;
437
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
438
+ event.preventDefault();
439
+ sendInterrupt();
440
+ return;
441
+ }
442
+ if (event.key === 'Enter' && !event.shiftKey) {
443
+ event.preventDefault();
444
+ sendMessage();
445
+ }
446
+ });
447
+ chatInput.addEventListener('input', onChatInputChanged);
448
+
449
+ micBtn?.addEventListener('click', toggleMic);
450
+
451
+ handsFreeBtn?.addEventListener('click', toggleHandsFree);
452
+
453
+ $('#attach-image').addEventListener('click', () => attachInput.click());
454
+ attachInput.addEventListener('change', () => {
455
+ addImageFiles([...attachInput.files]);
456
+ attachInput.value = '';
457
+ });
458
+ chatInput.addEventListener('paste', (event) => {
459
+ const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith('image/'));
460
+ if (!files.length) return;
461
+ event.preventDefault();
462
+ addImageFiles(files);
463
+ });
464
+ chatForm.addEventListener('dragover', (event) => {
465
+ event.preventDefault();
466
+ chatForm.classList.add('dragging');
467
+ });
468
+ chatForm.addEventListener('dragleave', () => chatForm.classList.remove('dragging'));
469
+ chatForm.addEventListener('drop', (event) => {
470
+ const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
471
+ if (!files.length) return;
472
+ event.preventDefault();
473
+ chatForm.classList.remove('dragging');
474
+ addImageFiles(files);
475
+ });
476
+
477
+ chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
478
+
479
+ $('#folder-new').addEventListener('click', openCreateFolder);
480
+ $('#folder-new-cancel').addEventListener('click', closeCreateFolder);
481
+ $('#folder-new-create').addEventListener('click', createFolder);
482
+ folderNewName.addEventListener('keydown', (event) => {
483
+ if (event.key === 'Enter') {
484
+ event.preventDefault();
485
+ createFolder();
486
+ } else if (event.key === 'Escape') {
487
+ event.preventDefault();
488
+ closeCreateFolder();
489
+ }
490
+ });
491
+ $('#folder-roots').addEventListener('click', () => loadFolders(''));
492
+ $('#folder-home').addEventListener('click', () => loadFolders(state.home || ''));
493
+ $('#folder-close').addEventListener('click', closeFolderPicker);
494
+ $('#folder-cancel').addEventListener('click', () => {
495
+ state.pendingInput = null;
496
+ closeFolderPicker();
497
+ });
498
+ $('#folder-up').addEventListener('click', () => loadFolders(folderParent ?? ''));
499
+ $('#folder-use').addEventListener('click', useFolder);
500
+ $('#folder-go').addEventListener('click', gotoFolder);
501
+ folderGoto.addEventListener('input', filterFolders);
502
+ folderGoto.addEventListener('keydown', (event) => {
503
+ if (event.key === 'Enter') {
504
+ event.preventDefault();
505
+ gotoFolder();
506
+ }
507
+ });
508
+ folderList.addEventListener('keydown', (event) => {
509
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
510
+ const items = [...folderList.querySelectorAll('.folder-item')];
511
+ if (!items.length) return;
512
+ event.preventDefault();
513
+ const current = items.indexOf(document.activeElement);
514
+ const next = event.key === 'ArrowDown' ? Math.min(items.length - 1, current + 1) : Math.max(0, current - 1);
515
+ items[current === -1 ? 0 : next].focus();
516
+ });
517
+ folderModal.addEventListener('click', (event) => {
518
+ if (event.target === folderModal) {
519
+ state.pendingInput = null;
520
+ closeFolderPicker();
521
+ }
522
+ });
523
+
524
+ $('#provider-new').addEventListener('click', () => openProviderForm());
525
+ $('#pf-cancel').addEventListener('click', closeProviderForm);
526
+ $('#pf-close').addEventListener('click', closeProviderForm);
527
+ $('#pf-test').addEventListener('click', testProvider);
528
+ providerForm.addEventListener('submit', submitProvider);
529
+ providerModal.addEventListener('click', (event) => {
530
+ if (event.target === providerModal) closeProviderForm();
531
+ });
532
+
533
+ wireAjustesNav();
534
+
535
+ $('#limits-save')?.addEventListener('click', async () => {
536
+ const body = {
537
+ maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
538
+ maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
539
+ maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
540
+ maxOutputTokens: Math.max(0, Number(limitsOutputEl?.value) || 0),
541
+ promptCaching: Boolean(limitsCachingEl?.checked),
542
+ };
543
+ try {
544
+ await api('/api/settings', { method: 'PUT', body });
545
+ refreshLimits();
546
+ setStatus('#limits-msg', 'guardado', 'ok');
547
+ } catch (error) {
548
+ setStatus('#limits-msg', error.message, 'err');
549
+ }
550
+ });
551
+
552
+ $('#tr-save')?.addEventListener('click', submitTranscribe);
553
+ trModeEl?.addEventListener('change', syncTranscribeMode);
554
+
555
+ trTestBtn?.addEventListener('click', toggleTrTest);
556
+
557
+ perfil.add?.addEventListener('click', () => addPerfilFieldRow());
558
+ perfil.save?.addEventListener('click', submitProfile);
559
+
560
+ if (mcpListEl) {
561
+ $('#mcp-new').addEventListener('click', () => openMcpForm());
562
+ $('#mcp-import-btn')?.addEventListener('click', importMcp);
563
+ $('#mcp-import')?.addEventListener('keydown', (event) => {
564
+ if (event.key === 'Enter') {
565
+ event.preventDefault();
566
+ importMcp();
567
+ }
568
+ });
569
+ $('#skill-import-btn')?.addEventListener('click', importSkill);
570
+ $('#skill-import')?.addEventListener('keydown', (event) => {
571
+ if (event.key === 'Enter') {
572
+ event.preventDefault();
573
+ importSkill();
574
+ }
575
+ });
576
+ $('#mcp-search-btn')?.addEventListener('click', searchMcp);
577
+ $('#mcp-search')?.addEventListener('keydown', (event) => {
578
+ if (event.key === 'Enter') {
579
+ event.preventDefault();
580
+ searchMcp();
581
+ }
582
+ });
583
+ $('#skill-search-btn')?.addEventListener('click', searchSkills);
584
+ $('#skill-search')?.addEventListener('keydown', (event) => {
585
+ if (event.key === 'Enter') {
586
+ event.preventDefault();
587
+ searchSkills();
588
+ }
589
+ });
590
+ refreshMcpCatalog();
591
+ refreshSkillCatalog();
592
+ $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
593
+ mcpModeEl?.addEventListener('change', async () => {
594
+ try {
595
+ await api('/api/settings', { method: 'PUT', body: { mcpMode: mcpModeEl.value } });
596
+ await refreshMcp({ connect: true });
597
+ } catch (error) {
598
+ toast(error.message);
599
+ }
600
+ });
601
+ const savePinnedMcp = async () => {
602
+ try {
603
+ await api('/api/settings', { method: 'PUT', body: { mcpPinned: String(mcpPinnedEl.value || '') } });
604
+ await refreshMcp({ connect: true });
605
+ } catch (error) {
606
+ toast(error.message);
607
+ }
608
+ };
609
+ $('#mcp-pinned-save')?.addEventListener('click', savePinnedMcp);
610
+ mcpPinnedEl?.addEventListener('keydown', (event) => {
611
+ if (event.key === 'Enter') {
612
+ event.preventDefault();
613
+ savePinnedMcp();
614
+ }
615
+ });
616
+ $('#tg-save')?.addEventListener('click', submitChannel);
617
+ $('#tg-test')?.addEventListener('click', testChannel);
618
+ $('#voz-save')?.addEventListener('click', submitVoz);
619
+ $('#voz-test')?.addEventListener('click', testVoz);
620
+ vozRateEl?.addEventListener('input', renderVozSliders);
621
+ vozPitchEl?.addEventListener('input', renderVozSliders);
622
+ $('#asesor-save')?.addEventListener('click', submitAsesor);
623
+ $('#asesor-test')?.addEventListener('click', testAsesor);
624
+ $('#mcp-close').addEventListener('click', closeMcpForm);
625
+ $('#mf-cancel').addEventListener('click', closeMcpForm);
626
+ $('#mf-transport').addEventListener('change', syncMcpFormFields);
627
+ mcpForm.addEventListener('submit', submitMcp);
628
+ mcpModal.addEventListener('click', (event) => {
629
+ if (event.target === mcpModal) closeMcpForm();
630
+ });
631
+
632
+ $('#skill-new').addEventListener('click', () => openSkillForm());
633
+ $('#skill-close').addEventListener('click', closeSkillForm);
634
+ $('#sf-cancel').addEventListener('click', closeSkillForm);
635
+ skillForm.addEventListener('submit', submitSkill);
636
+ skillModal.addEventListener('click', (event) => {
637
+ if (event.target === skillModal) closeSkillForm();
638
+ });
639
+
640
+ $('#memory-new')?.addEventListener('click', () => openMemoryForm());
641
+ $('#memory-close')?.addEventListener('click', closeMemoryForm);
642
+ $('#mem-cancel')?.addEventListener('click', closeMemoryForm);
643
+ memoryForm?.addEventListener('submit', submitMemory);
644
+ $('#memory-save-index')?.addEventListener('click', saveMemoryIndex);
645
+ memoryModal?.addEventListener('click', (event) => {
646
+ if (event.target === memoryModal) closeMemoryForm();
647
+ });
648
+
649
+ $('#sc-type')?.addEventListener('change', scheduleTypeFields);
650
+ $('#schedule-new')?.addEventListener('click', () => openScheduleForm());
651
+ $('#schedule-close')?.addEventListener('click', closeScheduleForm);
652
+ $('#sc-cancel')?.addEventListener('click', closeScheduleForm);
653
+ scheduleForm?.addEventListener('submit', submitSchedule);
654
+ scheduleModal?.addEventListener('click', (event) => {
655
+ if (event.target === scheduleModal) closeScheduleForm();
656
+ });
657
+ }
658
+
659
+ evalTasks.value = [
660
+ '{"id":"math","input":"¿Cuánto es 17 * 23? Respondé solo el número.","expect":{"equals":"391"}}',
661
+ '{"id":"regex","input":"Escribí una oración que contenga un número.","expect":{"regex":"\\\\d"}}',
662
+ ].join('\n');
663
+
664
+ evalRunBtn.addEventListener('click', runEval);
665
+
666
+ $('#runs-refresh').addEventListener('click', refreshRuns);
667
+
668
+ $('#file-preview-toggle')?.addEventListener('click', async () => {
669
+ const toggle = $('#file-preview-toggle');
670
+ const code = $('#file-code');
671
+ const stage = $('#file-stage');
672
+ const frame = $('#file-frame');
673
+ const render = $('#file-render');
674
+ const device = $('#file-device');
675
+ const maxButton = $('#file-max');
676
+ const current = filePreviewState.current;
677
+ if (!current) return;
678
+
679
+ filePreviewState.open = !filePreviewState.open;
680
+ const off = () => {
681
+ toggle.textContent = 'vista previa';
682
+ code.hidden = false;
683
+ stage.hidden = true;
684
+ render.hidden = true;
685
+ device.hidden = true;
686
+ maxButton.hidden = false;
687
+ $('#file-modal').classList.remove('preview-maximized');
688
+ };
689
+ if (!filePreviewState.open) return off();
690
+
691
+ if (filePreviewState.kind === 'markdown') {
692
+ render.innerHTML = renderMarkdown(filePreviewState.content);
693
+ code.hidden = true;
694
+ stage.hidden = true;
695
+ render.hidden = false;
696
+ device.hidden = true;
697
+ maxButton.hidden = false;
698
+ toggle.textContent = 'ver código';
699
+ return;
700
+ }
701
+
702
+ device.hidden = false;
703
+ maxButton.hidden = false;
704
+ if (!filePreviewState.loaded) {
705
+ const data = await api('/api/files/preview', {
706
+ method: 'POST',
707
+ body: { root: state.cwd, path: current.relPath },
708
+ }).catch((error) => ({ error: error.message }));
709
+ if (data?.error) {
710
+ setExplorerStatus(data.error, true);
711
+ filePreviewState.open = false;
712
+ return off();
713
+ }
714
+ frame.src = data.url;
715
+ filePreviewState.loaded = true;
716
+ filePreviewTools.applyDevice();
717
+ }
718
+ toggle.textContent = 'ver código';
719
+ code.hidden = true;
720
+ render.hidden = true;
721
+ stage.hidden = false;
722
+ });
723
+
724
+ document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
725
+
726
+ restorePreviewMax = () => previewTools.setMax(false);
727
+
728
+ $('#preview-open')?.addEventListener('click', () => loadPreview(previewUrlInput.value));
729
+ previewUrlInput?.addEventListener('keydown', (event) => {
730
+ if (event.key === 'Enter') {
731
+ event.preventDefault();
732
+ loadPreview(previewUrlInput.value);
733
+ }
734
+ });
735
+ previewFrame?.addEventListener('load', () => {
736
+ if (previewFrame.src) setPreviewStatus(previewFrame.src);
737
+ });
738
+ $('#preview-tunnel')?.addEventListener('click', () => {
739
+ const match = String(previewUrlInput.value).match(/:(\d+)/);
740
+ const port = match ? Number(match[1]) : 0;
741
+ if (!port) return setPreviewStatus('Poné una URL con puerto (p. ej. http://127.0.0.1:5173).', true);
742
+ openRemoteTunnel(port);
743
+ });
744
+ $('#preview-close')?.addEventListener('click', async () => {
745
+ await api('/api/preview/tunnel', { method: 'DELETE' }).catch(() => null);
746
+ setTunnelUi(null);
747
+ setPreviewStatus('acceso remoto cerrado.');
748
+ });
749
+ $('#preview-device')?.addEventListener('change', (event) => storage.set(PREVIEW_DEVICE_KEY, event.target.value));
750
+
751
+ // Restaurar lo tipeado y el tamano elegido: en la tablet la pagina se recarga
752
+ // sola (iOS la descarta en background) y antes se perdia todo el estado.
753
+ (function restorePreview() {
754
+ const savedDevice = storage.get(PREVIEW_DEVICE_KEY, '');
755
+ if (savedDevice && $('#preview-device')) $('#preview-device').value = savedDevice;
756
+ previewTools.applyDevice();
757
+ const savedUrl = storage.get(PREVIEW_URL_KEY, '');
758
+ if (savedUrl) previewUrlInput.value = savedUrl;
759
+ syncPreviewTunnel({ load: true });
760
+ })();
761
+
762
+ $('#procs-detect')?.addEventListener('click', detectProcs);
763
+
764
+ gitFilterEl?.addEventListener('input', () => {
765
+ if (explorerMode === 'git' && gitEntries.length) renderGitList();
766
+ });
767
+
768
+ $('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
769
+ $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
770
+ $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
771
+ explorerFilter.addEventListener('input', applyTreeFilter);
772
+ $('#file-close').addEventListener('click', () => $('#file-modal').close());
773
+ $('#file-modal').addEventListener('click', (event) => {
774
+ if (event.target === $('#file-modal')) $('#file-modal').close();
775
+ });
776
+ $('#git-close')?.addEventListener('click', () => gitModalEl?.close());
777
+ gitModalEl?.addEventListener('click', (event) => {
778
+ if (event.target === gitModalEl) gitModalEl.close();
779
+ });
780
+
781
+ try {
782
+ setExplorerOpen(storage.get('tamnora-explorer-open') === '1');
783
+ } catch {
784
+ setExplorerOpen(false);
785
+ }
786
+
787
+ $('#update-run')?.addEventListener('click', runUpdate);
788
+ $('#update-later')?.addEventListener('click', dismissUpdate);
789
+
790
+ if (brandVerEl) {
791
+ brandVerEl.title = 'buscar actualización';
792
+ brandVerEl.classList.add('ver-action');
793
+ brandVerEl.addEventListener('click', forceUpdateCheck);
794
+ }
795
+
796
+ /* --- boot ---------------------------------------------------------------- */
797
+
799
798
  async function boot() {
800
- wireModelPicker();
801
- const auth = await api('/api/auth/me').catch(() => null);
802
- if (auth?.enabled && !auth.authenticated) {
803
- location.replace('/landing');
804
- return;
805
- }
806
- setCsrfToken(auth?.csrf || '');
807
- document.documentElement.dataset.auth = 'ok';
808
-
809
- const health = await api('/api/health').catch(() => null);
810
- if (health?.cwd) {
811
- document.title = `Tamnora Console · ${health.cwd}`;
812
- setCwd(health.cwd);
813
- }
814
- if (health?.home) state.home = health.home;
815
- if (health?.version) {
816
- const ver = document.querySelector('.brand .ver');
817
- if (ver) ver.textContent = `v${health.version}`;
818
- }
819
- refreshUpdate();
820
- await refreshProviders();
821
- await loadModes();
822
- await loadModels();
823
- await refreshTranscribe();
824
- // La config de voz se carga al boot (sin `await`, para no bloquear con el
825
- // fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
826
- // default `false` hasta abrir Ajustes y la narración no arranca al entrar.
827
- refreshVoz();
828
- await reconcile();
829
- await restoreTabs();
830
- renderAlertSignals();
831
- followEvents();
832
- watchLifecycle();
833
- // Latido de presencia: mantiene frescas las pestañas abiertas (TTL del server).
834
- setInterval(sendPresence, 30000);
835
- }
836
- boot();
799
+ wireModelPicker();
800
+ const auth = await api('/api/auth/me').catch(() => null);
801
+ if (auth?.enabled && !auth.authenticated) {
802
+ location.replace('/landing');
803
+ return;
804
+ }
805
+ setCsrfToken(auth?.csrf || '');
806
+ document.documentElement.dataset.auth = 'ok';
807
+
808
+ const health = await api('/api/health').catch(() => null);
809
+ if (health?.cwd) {
810
+ document.title = `Tamnora Console · ${health.cwd}`;
811
+ setCwd(health.cwd);
812
+ }
813
+ if (health?.home) state.home = health.home;
814
+ if (health?.version) {
815
+ const ver = document.querySelector('.brand .ver');
816
+ if (ver) ver.textContent = `v${health.version}`;
817
+ }
818
+ refreshUpdate();
819
+ await refreshProviders();
820
+ await loadModes();
821
+ await loadModels();
822
+ await refreshTranscribe();
823
+ // La config de voz se carga al boot (sin `await`, para no bloquear con el
824
+ // fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
825
+ // default `false` hasta abrir Ajustes y la narración no arranca al entrar.
826
+ refreshVoz();
827
+ await reconcile();
828
+ await restoreTabs();
829
+ renderAlertSignals();
830
+ followEvents();
831
+ watchLifecycle();
832
+ // Latido de presencia: mantiene frescas las pestañas abiertas (TTL del server).
833
+ setInterval(sendPresence, 30000);
834
+ }
835
+ boot();