@danieltmn/tamnora 0.1.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 (123) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/SECURITY.md +31 -0
  5. package/harness/packages/cli/bin/tamnora.mjs +11 -0
  6. package/harness/packages/cli/package.json +10 -0
  7. package/harness/packages/cli/src/cli.js +90 -0
  8. package/harness/packages/cli/src/commands/autostart.js +121 -0
  9. package/harness/packages/cli/src/commands/bridge.js +274 -0
  10. package/harness/packages/cli/src/commands/doctor.js +73 -0
  11. package/harness/packages/cli/src/commands/eval.js +183 -0
  12. package/harness/packages/cli/src/commands/init.js +31 -0
  13. package/harness/packages/cli/src/commands/mcp.js +101 -0
  14. package/harness/packages/cli/src/commands/repl.js +172 -0
  15. package/harness/packages/cli/src/commands/run.js +165 -0
  16. package/harness/packages/cli/src/commands/serve.js +1 -0
  17. package/harness/packages/cli/src/commands/server.js +345 -0
  18. package/harness/packages/cli/src/commands/tui.js +110 -0
  19. package/harness/packages/cli/src/commands/update.js +76 -0
  20. package/harness/packages/cli/src/commands/user.js +135 -0
  21. package/harness/packages/cli/src/lib/core.js +2 -0
  22. package/harness/packages/cli/src/lib/daemon.js +153 -0
  23. package/harness/packages/cli/src/lib/qr.js +203 -0
  24. package/harness/packages/cli/src/lib/runtime.js +110 -0
  25. package/harness/packages/cli/src/lib/tunnel.js +113 -0
  26. package/harness/packages/cli/src/lib/util.js +24 -0
  27. package/harness/packages/cli/src/tui/app.js +302 -0
  28. package/harness/packages/cli/src/tui/keys.js +78 -0
  29. package/harness/packages/cli/src/tui/layout.js +130 -0
  30. package/harness/packages/cli/src/tui/screen.js +13 -0
  31. package/harness/packages/cli/src/ui/color.js +15 -0
  32. package/harness/packages/cli/test/release.test.js +34 -0
  33. package/harness/packages/cli/test/tui.test.js +99 -0
  34. package/harness/packages/cli/test/tunnel.test.js +20 -0
  35. package/harness/packages/core/package.json +14 -0
  36. package/harness/packages/core/src/agent/context.js +66 -0
  37. package/harness/packages/core/src/agent/modes.js +26 -0
  38. package/harness/packages/core/src/agent/runAgent.js +257 -0
  39. package/harness/packages/core/src/config/app.js +49 -0
  40. package/harness/packages/core/src/config/home.js +98 -0
  41. package/harness/packages/core/src/config/identity.js +41 -0
  42. package/harness/packages/core/src/config/index.js +69 -0
  43. package/harness/packages/core/src/config/providers.js +24 -0
  44. package/harness/packages/core/src/env.js +19 -0
  45. package/harness/packages/core/src/eval/dataset.js +47 -0
  46. package/harness/packages/core/src/eval/judge.js +39 -0
  47. package/harness/packages/core/src/eval/report.js +48 -0
  48. package/harness/packages/core/src/eval/runTask.js +71 -0
  49. package/harness/packages/core/src/eval/runner.js +150 -0
  50. package/harness/packages/core/src/eval/scorers.js +217 -0
  51. package/harness/packages/core/src/eval/task.js +59 -0
  52. package/harness/packages/core/src/index.js +72 -0
  53. package/harness/packages/core/src/mcp/common.js +82 -0
  54. package/harness/packages/core/src/mcp/http.js +149 -0
  55. package/harness/packages/core/src/mcp/index.js +167 -0
  56. package/harness/packages/core/src/mcp/stdio.js +218 -0
  57. package/harness/packages/core/src/providers/index.js +119 -0
  58. package/harness/packages/core/src/providers/ollama.js +76 -0
  59. package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
  60. package/harness/packages/core/src/providers/openrouter.js +57 -0
  61. package/harness/packages/core/src/providers/presets.js +19 -0
  62. package/harness/packages/core/src/providers/resilience.js +74 -0
  63. package/harness/packages/core/src/providers/stream.js +200 -0
  64. package/harness/packages/core/src/providers/utils.js +66 -0
  65. package/harness/packages/core/src/storage/sqlite.js +453 -0
  66. package/harness/packages/core/src/tools/diff.js +90 -0
  67. package/harness/packages/core/src/tools/executors.js +277 -0
  68. package/harness/packages/core/src/tools/glob.js +67 -0
  69. package/harness/packages/core/src/tools/index.js +28 -0
  70. package/harness/packages/core/src/tools/paths.js +29 -0
  71. package/harness/packages/core/src/tools/permissions.js +69 -0
  72. package/harness/packages/core/src/tools/registry.js +44 -0
  73. package/harness/packages/core/src/tools/schema.js +177 -0
  74. package/harness/packages/core/test/agent.test.js +237 -0
  75. package/harness/packages/core/test/config.test.js +33 -0
  76. package/harness/packages/core/test/context.test.js +44 -0
  77. package/harness/packages/core/test/diff.test.js +20 -0
  78. package/harness/packages/core/test/eval.test.js +144 -0
  79. package/harness/packages/core/test/git-tools.test.js +44 -0
  80. package/harness/packages/core/test/home.test.js +62 -0
  81. package/harness/packages/core/test/mcp.test.js +158 -0
  82. package/harness/packages/core/test/providers.test.js +80 -0
  83. package/harness/packages/core/test/resilience.test.js +61 -0
  84. package/harness/packages/core/test/storage.test.js +55 -0
  85. package/harness/packages/core/test/stream.test.js +91 -0
  86. package/harness/packages/core/test/tools.test.js +96 -0
  87. package/harness/packages/server/bin/tamnora-server.mjs +11 -0
  88. package/harness/packages/server/package.json +10 -0
  89. package/harness/packages/server/public/app.js +2188 -0
  90. package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
  91. package/harness/packages/server/public/index.html +368 -0
  92. package/harness/packages/server/public/landing/index.html +523 -0
  93. package/harness/packages/server/public/landing/landing.js +89 -0
  94. package/harness/packages/server/public/markdown.js +174 -0
  95. package/harness/packages/server/public/styles.css +923 -0
  96. package/harness/packages/server/src/auth.js +146 -0
  97. package/harness/packages/server/src/cli.js +61 -0
  98. package/harness/packages/server/src/index.js +1 -0
  99. package/harness/packages/server/src/lib/approvals.js +51 -0
  100. package/harness/packages/server/src/lib/core.js +2 -0
  101. package/harness/packages/server/src/lib/http.js +74 -0
  102. package/harness/packages/server/src/lib/modelCache.js +32 -0
  103. package/harness/packages/server/src/lib/prompt.js +48 -0
  104. package/harness/packages/server/src/lib/workspace.js +74 -0
  105. package/harness/packages/server/src/routes/auth.js +56 -0
  106. package/harness/packages/server/src/routes/chat.js +222 -0
  107. package/harness/packages/server/src/routes/eval.js +100 -0
  108. package/harness/packages/server/src/routes/files.js +117 -0
  109. package/harness/packages/server/src/routes/folders.js +80 -0
  110. package/harness/packages/server/src/routes/mcp.js +50 -0
  111. package/harness/packages/server/src/routes/models.js +26 -0
  112. package/harness/packages/server/src/routes/providers.js +135 -0
  113. package/harness/packages/server/src/routes/sessions.js +157 -0
  114. package/harness/packages/server/src/server.js +249 -0
  115. package/harness/packages/server/src/static.js +67 -0
  116. package/harness/packages/server/test/markdown.test.js +36 -0
  117. package/harness/packages/server/test/modelCache.test.js +72 -0
  118. package/harness/packages/server/test/server.test.js +809 -0
  119. package/harness/scripts/example-dataset.jsonl +4 -0
  120. package/harness/scripts/mcp-echo-server.mjs +108 -0
  121. package/harness/scripts/mock-provider.mjs +134 -0
  122. package/harness/tamnora.example.json +18 -0
  123. package/package.json +70 -0
@@ -0,0 +1,2188 @@
1
+ // Tamnora console — dependency-free client for the harness server (terminal theme).
2
+
3
+ import { renderMarkdown } from './markdown.js';
4
+
5
+ const $ = (sel) => document.querySelector(sel);
6
+ let csrfToken = '';
7
+ const state = {
8
+ sessionId: null,
9
+ streaming: false,
10
+ providers: [],
11
+ active: { provider: '', model: '' },
12
+ pendingInput: null,
13
+ permissionCards: new Map(),
14
+ attachments: [],
15
+ home: null,
16
+ cwd: '',
17
+ explorerOpen: false,
18
+ sidebarOpen: true,
19
+ panes: new Map(),
20
+ tabOrder: [],
21
+ activeTab: null,
22
+ collapsedGroups: new Set(),
23
+ };
24
+
25
+ function setCwd(value) {
26
+ if (!value) return;
27
+ state.cwd = value;
28
+ $('#rail-cwd').textContent = value;
29
+ if (state.explorerOpen) refreshExplorer();
30
+ }
31
+
32
+ /* --- DOM helpers --------------------------------------------------------- */
33
+
34
+ function el(tag, className = '', text = '') {
35
+ const node = document.createElement(tag);
36
+ if (className) node.className = className;
37
+ if (text) node.textContent = text;
38
+ return node;
39
+ }
40
+
41
+ function button(label, onClick, className = 'btn') {
42
+ const node = el('button', className, label);
43
+ node.type = 'button';
44
+ node.addEventListener('click', onClick);
45
+ return node;
46
+ }
47
+
48
+ function clear(node) {
49
+ node.replaceChildren();
50
+ }
51
+
52
+ const ICON_PATHS = {
53
+ x: ['M18 6 6 18', 'm6 6 12 12'],
54
+ 'chevron-right': ['m9 18 6-6-6-6'],
55
+ 'chevron-down': ['m6 9 6 6 6-6'],
56
+ folder: ['M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z'],
57
+ 'folder-open': ['m6 14 1.45-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.55 6a2 2 0 0 1-1.94 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2'],
58
+ file: ['M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z', 'M14 2v4a2 2 0 0 0 2 2h4'],
59
+ 'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
60
+ };
61
+
62
+ function icon(name, size = 16, className = '') {
63
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
64
+ if (className) svg.setAttribute('class', className);
65
+ for (const [key, value] of Object.entries({ viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', width: String(size), height: String(size), 'aria-hidden': 'true' })) {
66
+ svg.setAttribute(key, value);
67
+ }
68
+ for (const d of ICON_PATHS[name] ?? []) {
69
+ const node = document.createElementNS('http://www.w3.org/2000/svg', 'path');
70
+ node.setAttribute('d', d);
71
+ svg.appendChild(node);
72
+ }
73
+ return svg;
74
+ }
75
+
76
+ function truncate(value, max = 400) {
77
+ const text = String(value ?? '');
78
+ return text.length > max ? `${text.slice(0, max)}…` : text;
79
+ }
80
+
81
+ /* --- HTTP / SSE ---------------------------------------------------------- */
82
+
83
+ async function api(path, { method = 'GET', body } = {}) {
84
+ const headers = {};
85
+ if (body) headers['Content-Type'] = 'application/json';
86
+ if (method !== 'GET' && csrfToken) headers['x-csrf'] = csrfToken;
87
+ const res = await fetch(path, {
88
+ method,
89
+ headers,
90
+ body: body ? JSON.stringify(body) : undefined,
91
+ });
92
+ if (!res.ok) {
93
+ let message = res.statusText;
94
+ try {
95
+ message = (await res.json()).error ?? message;
96
+ } catch {
97
+ // keep statusText
98
+ }
99
+ throw new Error(message);
100
+ }
101
+ return res.json();
102
+ }
103
+
104
+ async function streamPost(url, body, onEvent, signal) {
105
+ const headers = { 'Content-Type': 'application/json' };
106
+ if (csrfToken) headers['x-csrf'] = csrfToken;
107
+ const res = await fetch(url, {
108
+ method: 'POST',
109
+ headers,
110
+ body: JSON.stringify(body),
111
+ signal,
112
+ });
113
+ if (!res.ok) {
114
+ let message = res.statusText;
115
+ try {
116
+ message = (await res.json()).error ?? message;
117
+ } catch {
118
+ // keep statusText
119
+ }
120
+ throw new Error(message);
121
+ }
122
+ const reader = res.body.getReader();
123
+ const decoder = new TextDecoder();
124
+ let buffer = '';
125
+ while (true) {
126
+ const { value, done } = await reader.read();
127
+ if (done) break;
128
+ buffer += decoder.decode(value, { stream: true });
129
+ let index;
130
+ while ((index = buffer.indexOf('\n\n')) !== -1) {
131
+ const frame = buffer.slice(0, index);
132
+ buffer = buffer.slice(index + 2);
133
+ let event = 'message';
134
+ let data = '';
135
+ for (const line of frame.split('\n')) {
136
+ if (line.startsWith('event:')) event = line.slice(6).trim();
137
+ else if (line.startsWith('data:')) data += line.slice(5).trim();
138
+ }
139
+ let parsed = {};
140
+ if (data) {
141
+ try {
142
+ parsed = JSON.parse(data);
143
+ } catch {
144
+ parsed = { raw: data };
145
+ }
146
+ }
147
+ onEvent(event, parsed);
148
+ }
149
+ }
150
+ }
151
+
152
+ /* --- tabs ---------------------------------------------------------------- */
153
+
154
+ const moreTab = $('#more-tab');
155
+ const moreMenu = $('#more-menu');
156
+
157
+ function hideMoreMenu() {
158
+ moreMenu.hidden = true;
159
+ moreTab.setAttribute('aria-expanded', 'false');
160
+ }
161
+
162
+ function activateTab(name) {
163
+ document.querySelectorAll('.tab[data-tab]').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
164
+ moreTab.classList.toggle('active', name === 'eval' || name === 'runs');
165
+ document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
166
+ hideMoreMenu();
167
+ if (name === 'runs') refreshRuns();
168
+ if (name === 'proveedores') refreshProviders();
169
+ }
170
+
171
+ document.querySelectorAll('.tab[data-tab]').forEach((t) =>
172
+ t.addEventListener('click', () => activateTab(t.dataset.tab))
173
+ );
174
+ moreTab.addEventListener('click', (event) => {
175
+ event.stopPropagation();
176
+ const open = moreMenu.hidden;
177
+ moreMenu.hidden = !open;
178
+ moreTab.setAttribute('aria-expanded', String(open));
179
+ });
180
+ document.querySelectorAll('#more-menu button').forEach((item) =>
181
+ item.addEventListener('click', () => activateTab(item.dataset.tab))
182
+ );
183
+ document.addEventListener('click', (event) => {
184
+ if (!moreMenu.hidden && !event.target.closest('.menu')) hideMoreMenu();
185
+ });
186
+ document.addEventListener('keydown', (event) => {
187
+ if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
188
+ });
189
+
190
+ /* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
191
+
192
+ const settingsModal = $('#settings-modal');
193
+ if (settingsModal) {
194
+ $('#settings-toggle')?.addEventListener('click', () => settingsModal.showModal());
195
+ $('#settings-close')?.addEventListener('click', () => settingsModal.close());
196
+ $('#settings-done')?.addEventListener('click', () => settingsModal.close());
197
+ $('#settings-providers')?.addEventListener('click', () => {
198
+ settingsModal.close();
199
+ activateTab('proveedores');
200
+ });
201
+ settingsModal.addEventListener('click', (event) => {
202
+ if (event.target === settingsModal) settingsModal.close();
203
+ });
204
+ }
205
+
206
+ /* --- command palette (Ctrl/Cmd+K) ----------------------------------------- */
207
+
208
+ const PALETTE_COMMANDS = [
209
+ { id: 'new', label: 'Nueva sesión en otra carpeta', hint: '/new', run: () => openFolderPicker() },
210
+ { id: 'init', label: 'Analizar el proyecto y crear AGENTS.md', hint: '/init', run: () => runSlash('init') },
211
+ { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
212
+ { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
213
+ { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => settingsModal?.showModal() },
214
+ { id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('proveedores') },
215
+ { id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
216
+ { id: 'sidebar', label: 'Mostrar u ocultar sesiones', run: () => setSidebarOpen(!state.sidebarOpen) },
217
+ { id: 'theme', label: 'Cambiar tema', run: () => applyTheme(document.documentElement.dataset.theme === 'paper' ? 'dark' : 'paper') },
218
+ ];
219
+
220
+ const palette = {
221
+ modal: $('#palette-modal'),
222
+ input: $('#palette-input'),
223
+ list: $('#palette-list'),
224
+ items: [],
225
+ index: 0,
226
+ timer: null,
227
+ results: { sessions: [], messages: [] },
228
+ };
229
+
230
+ function runSlash(name) {
231
+ chatInput.value = `/${name}`;
232
+ sendMessage();
233
+ }
234
+
235
+ function paletteItems(query) {
236
+ const q = query.trim().toLowerCase();
237
+ const items = PALETTE_COMMANDS.filter(
238
+ (c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q)
239
+ ).map((c) => ({ label: c.label, hint: c.hint || 'comando', run: c.run }));
240
+
241
+ for (const session of palette.results.sessions) {
242
+ items.push({
243
+ label: session.title || '(sin título)',
244
+ hint: session.cwd ? basename(session.cwd) : 'sesión',
245
+ run: () => openTab(session.id),
246
+ });
247
+ }
248
+ for (const message of palette.results.messages) {
249
+ items.push({
250
+ label: message.snippet || '(mensaje)',
251
+ hint: `${message.role} · ${message.title || ''}`,
252
+ run: () => {
253
+ openTab(message.sessionId);
254
+ setTimeout(() => document.getElementById(message.id)?.scrollIntoView({ block: 'center' }), 450);
255
+ },
256
+ });
257
+ }
258
+ return items;
259
+ }
260
+
261
+ function setPaletteIndex(index) {
262
+ const count = palette.items.length;
263
+ if (!count) {
264
+ palette.index = 0;
265
+ return;
266
+ }
267
+ palette.index = ((index % count) + count) % count;
268
+ [...palette.list.children].forEach((child, i) => child.classList.toggle('active', i === palette.index));
269
+ palette.list.children[palette.index]?.scrollIntoView({ block: 'nearest' });
270
+ }
271
+
272
+ function renderPalette(query) {
273
+ palette.items = paletteItems(query);
274
+ palette.list.replaceChildren();
275
+ if (!palette.items.length) {
276
+ palette.list.appendChild(el('li', 'palette-empty', 'sin resultados'));
277
+ return;
278
+ }
279
+ palette.items.forEach((item, i) => {
280
+ const li = el('li', 'palette-item');
281
+ const main = el('div', 'palette-item-main');
282
+ main.appendChild(el('span', 'palette-item-label', item.label));
283
+ if (item.hint) main.appendChild(el('span', 'palette-item-hint', item.hint));
284
+ li.appendChild(main);
285
+ li.addEventListener('mousemove', () => setPaletteIndex(i));
286
+ li.addEventListener('click', () => runPaletteItem(item));
287
+ palette.list.appendChild(li);
288
+ });
289
+ setPaletteIndex(0);
290
+ }
291
+
292
+ function runPaletteItem(item) {
293
+ palette.modal?.close();
294
+ setTimeout(() => item.run(), 0);
295
+ }
296
+
297
+ function openPalette() {
298
+ if (!palette.modal) return;
299
+ palette.input.value = '';
300
+ palette.results = { sessions: [], messages: [] };
301
+ renderPalette('');
302
+ palette.modal.showModal();
303
+ palette.input.focus();
304
+ }
305
+
306
+ if (palette.modal) {
307
+ palette.input.addEventListener('input', () => {
308
+ const q = palette.input.value;
309
+ renderPalette(q);
310
+ clearTimeout(palette.timer);
311
+ palette.timer = setTimeout(async () => {
312
+ if (q.trim().length < 2) {
313
+ palette.results = { sessions: [], messages: [] };
314
+ renderPalette(q);
315
+ return;
316
+ }
317
+ const data = await api(`/api/search?q=${encodeURIComponent(q)}&limit=15`).catch(() => null);
318
+ if (data) {
319
+ palette.results = { sessions: data.sessions || [], messages: data.messages || [] };
320
+ renderPalette(q);
321
+ }
322
+ }, 180);
323
+ });
324
+ palette.input.addEventListener('keydown', (event) => {
325
+ if (event.key === 'ArrowDown') {
326
+ event.preventDefault();
327
+ setPaletteIndex(palette.index + 1);
328
+ } else if (event.key === 'ArrowUp') {
329
+ event.preventDefault();
330
+ setPaletteIndex(palette.index - 1);
331
+ } else if (event.key === 'Enter') {
332
+ event.preventDefault();
333
+ const item = palette.items[palette.index];
334
+ if (item) runPaletteItem(item);
335
+ }
336
+ });
337
+ palette.modal.addEventListener('click', (event) => {
338
+ if (event.target === palette.modal) palette.modal.close();
339
+ });
340
+ $('#search-toggle')?.addEventListener('click', openPalette);
341
+ document.addEventListener('keydown', (event) => {
342
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
343
+ event.preventDefault();
344
+ openPalette();
345
+ }
346
+ });
347
+ }
348
+
349
+ /* --- topbar: provider + model + status ----------------------------------- */
350
+
351
+ const providerSelect = $('#provider');
352
+ const modelSelect = $('#model');
353
+ const modeSelect = $('#mode');
354
+ const yesInput = $('#yes');
355
+
356
+ async function loadModes() {
357
+ const data = await api('/api/modes').catch(() => ({ modes: [] }));
358
+ clear(modeSelect);
359
+ for (const mode of data.modes) {
360
+ const option = document.createElement('option');
361
+ option.value = mode.id;
362
+ option.textContent = mode.label;
363
+ option.title = mode.description ?? '';
364
+ modeSelect.appendChild(option);
365
+ }
366
+ const settings = await api('/api/settings').catch(() => null);
367
+ const active = settings?.settings?.mode || 'agent';
368
+ if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
369
+ updateStatus();
370
+ }
371
+
372
+ modeSelect.addEventListener('change', async () => {
373
+ await api('/api/settings', { method: 'PUT', body: { mode: modeSelect.value } }).catch(() => null);
374
+ updateStatus();
375
+ });
376
+
377
+ function updateStatus(extra = {}) {
378
+ const p = state.providers.find((x) => x.id === state.active.provider);
379
+ $('#sb-mode').textContent = modeSelect.value || 'agent';
380
+ $('#sb-provider').textContent = p ? `${p.id}` : state.active.provider || '—';
381
+ $('#sb-model').textContent = state.active.model || '—';
382
+ if (extra.rounds !== undefined) $('#sb-rounds').textContent = `${extra.rounds} rondas`;
383
+ if (extra.stopReason) $('#sb-rounds').textContent = extra.stopReason;
384
+ if (extra.running) $('#sb-rounds').textContent = 'pensando…';
385
+ }
386
+
387
+ const themeToggle = $('#theme-toggle');
388
+ function applyTheme(theme) {
389
+ document.documentElement.dataset.theme = theme;
390
+ themeToggle.title = theme === 'paper' ? 'cambiar a oscuro' : 'cambiar a papel';
391
+ try {
392
+ localStorage.setItem('tamnora-console-theme', theme);
393
+ } catch {
394
+ // storage unavailable
395
+ }
396
+ }
397
+ themeToggle.addEventListener('click', () =>
398
+ applyTheme(document.documentElement.dataset.theme === 'paper' ? 'dark' : 'paper')
399
+ );
400
+ applyTheme(document.documentElement.dataset.theme || 'paper');
401
+
402
+ const logout = $('#logout');
403
+ if (logout) {
404
+ logout.addEventListener('click', async (event) => {
405
+ event.preventDefault();
406
+ try {
407
+ await api('/api/auth/logout', { method: 'POST' });
408
+ } catch {
409
+ // igual salimos
410
+ }
411
+ location.href = '/landing';
412
+ });
413
+ }
414
+
415
+ /* --- layout: resizable + collapsible panels ------------------------------- */
416
+
417
+ const panels = {
418
+ sidebar: { el: $('#sidebar'), resizer: $('#resizer-sidebar'), min: 180, max: 460, width: 260, side: 'left' },
419
+ explorer: { el: $('#explorer'), resizer: $('#resizer-explorer'), min: 200, max: 560, width: 300, side: 'right' },
420
+ };
421
+ const sidebarBackdrop = $('#sidebar-backdrop');
422
+ const mobileQuery = window.matchMedia('(max-width: 820px)');
423
+
424
+ function isMobile() {
425
+ return mobileQuery.matches;
426
+ }
427
+
428
+ function readSidebarPref() {
429
+ try {
430
+ return localStorage.getItem('tamnora-sidebar-open') !== '0';
431
+ } catch {
432
+ return true;
433
+ }
434
+ }
435
+
436
+ function persistLayout() {
437
+ try {
438
+ localStorage.setItem(
439
+ 'tamnora-panel-widths',
440
+ JSON.stringify({ sidebar: panels.sidebar.width, explorer: panels.explorer.width })
441
+ );
442
+ } catch {
443
+ // storage unavailable
444
+ }
445
+ }
446
+
447
+ function applyPanelWidths() {
448
+ panels.sidebar.el.style.width = `${panels.sidebar.width}px`;
449
+ panels.explorer.el.style.width = `${panels.explorer.width}px`;
450
+ }
451
+
452
+ function attachResizer(panel) {
453
+ panel.resizer.addEventListener('pointerdown', (event) => {
454
+ if (panel.resizer.hidden) return;
455
+ event.preventDefault();
456
+ const startX = event.clientX;
457
+ const startWidth = panel.width;
458
+ panel.resizer.classList.add('dragging');
459
+ document.body.classList.add('resizing');
460
+ panel.resizer.setPointerCapture(event.pointerId);
461
+ const move = (moveEvent) => {
462
+ const delta = panel.side === 'left' ? moveEvent.clientX - startX : startX - moveEvent.clientX;
463
+ panel.width = Math.min(panel.max, Math.max(panel.min, startWidth + delta));
464
+ panel.el.style.width = `${panel.width}px`;
465
+ };
466
+ const up = () => {
467
+ panel.resizer.classList.remove('dragging');
468
+ document.body.classList.remove('resizing');
469
+ panel.resizer.removeEventListener('pointermove', move);
470
+ panel.resizer.removeEventListener('pointerup', up);
471
+ persistLayout();
472
+ };
473
+ panel.resizer.addEventListener('pointermove', move);
474
+ panel.resizer.addEventListener('pointerup', up);
475
+ });
476
+ }
477
+
478
+ function setSidebarOpen(open) {
479
+ state.sidebarOpen = open;
480
+ if (isMobile()) {
481
+ // Drawer: siempre en el DOM; la visibilidad la maneja la clase + backdrop.
482
+ panels.sidebar.el.hidden = false;
483
+ panels.sidebar.el.classList.toggle('open', open);
484
+ panels.sidebar.resizer.hidden = true;
485
+ if (sidebarBackdrop) sidebarBackdrop.hidden = !open;
486
+ } else {
487
+ panels.sidebar.el.classList.remove('open');
488
+ panels.sidebar.el.hidden = !open;
489
+ panels.sidebar.resizer.hidden = !open;
490
+ if (sidebarBackdrop) sidebarBackdrop.hidden = true;
491
+ }
492
+ $('#sidebar-toggle').setAttribute('aria-pressed', String(open));
493
+ if (!isMobile()) {
494
+ try {
495
+ localStorage.setItem('tamnora-sidebar-open', open ? '1' : '0');
496
+ } catch {
497
+ // storage unavailable
498
+ }
499
+ }
500
+ }
501
+
502
+ function initLayout() {
503
+ let widths = {};
504
+ try {
505
+ widths = JSON.parse(localStorage.getItem('tamnora-panel-widths') || '{}');
506
+ } catch {
507
+ widths = {};
508
+ }
509
+ if (Number(widths.sidebar)) panels.sidebar.width = Math.min(panels.sidebar.max, Math.max(panels.sidebar.min, widths.sidebar));
510
+ if (Number(widths.explorer)) panels.explorer.width = Math.min(panels.explorer.max, Math.max(panels.explorer.min, widths.explorer));
511
+ try {
512
+ state.collapsedGroups = new Set(JSON.parse(localStorage.getItem('tamnora-collapsed-groups') || '[]'));
513
+ } catch {
514
+ state.collapsedGroups = new Set();
515
+ }
516
+ // En movil el panel arranca cerrado (drawer); en desktop, la preferencia guardada.
517
+ applyPanelWidths();
518
+ attachResizer(panels.sidebar);
519
+ attachResizer(panels.explorer);
520
+ setSidebarOpen(isMobile() ? false : readSidebarPref());
521
+ $('#sidebar-toggle').addEventListener('click', () => setSidebarOpen(!state.sidebarOpen));
522
+ if (sidebarBackdrop) sidebarBackdrop.addEventListener('click', () => setSidebarOpen(false));
523
+ mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
524
+ }
525
+
526
+ initLayout();
527
+
528
+ function formatK(n) {
529
+ const value = Number(n) || 0;
530
+ if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
531
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
532
+ return String(value);
533
+ }
534
+
535
+ function renderTokens() {
536
+ const node = $('#sb-tokens');
537
+ const record = state.panes.get(state.sessionId);
538
+ const tokens = record?.tokens ?? { in: 0, out: 0 };
539
+ const lastTurn = record?.lastTurn ?? { prompt: 0, completion: 0 };
540
+ const total = tokens.in + tokens.out;
541
+ if (!total) {
542
+ node.textContent = 'tokens 0';
543
+ node.className = 'muted';
544
+ node.title = '';
545
+ return;
546
+ }
547
+ node.textContent = `tokens ${formatK(total)}`;
548
+ const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
549
+ const used = lastTurn.prompt + lastTurn.completion || total;
550
+ node.title =
551
+ `entrada ${formatK(tokens.in)} · salida ${formatK(tokens.out)}` +
552
+ (context ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
553
+ if (!context) {
554
+ node.className = 'muted';
555
+ return;
556
+ }
557
+ const pct = (used / context) * 100;
558
+ node.className = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
559
+ }
560
+
561
+ function addTokens(tokens) {
562
+ const record = state.panes.get(state.sessionId);
563
+ if (record && tokens) {
564
+ record.tokens.in += tokens.prompt_tokens ?? 0;
565
+ record.tokens.out += tokens.completion_tokens ?? 0;
566
+ record.lastTurn = {
567
+ prompt: tokens.last_prompt_tokens ?? 0,
568
+ completion: tokens.last_completion_tokens ?? 0,
569
+ };
570
+ }
571
+ renderTokens();
572
+ }
573
+
574
+ async function saveSettings(patch) {
575
+ const out = await api('/api/settings', { method: 'PUT', body: patch }).catch(() => null);
576
+ if (out?.settings) {
577
+ state.active.provider = out.settings.provider;
578
+ state.active.model = out.settings.model;
579
+ }
580
+ if (patch.provider) providerSelect.value = patch.provider;
581
+ updateStatus();
582
+ }
583
+
584
+ async function loadModels({ save = false } = {}) {
585
+ const provider = providerSelect.value;
586
+ modelSelect.disabled = true;
587
+ const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
588
+ models: [],
589
+ error: error.message,
590
+ }));
591
+ const models = data?.models ?? [];
592
+ clear(modelSelect);
593
+
594
+ if (!models.length) {
595
+ const option = document.createElement('option');
596
+ option.value = '';
597
+ option.textContent = data?.error ? `sin modelos — ${data.error}` : 'sin modelos (configurá el proveedor)';
598
+ modelSelect.appendChild(option);
599
+ updateStatus();
600
+ renderTokens();
601
+ return;
602
+ }
603
+
604
+ for (const model of models) {
605
+ const option = document.createElement('option');
606
+ option.value = model.id;
607
+ option.textContent = model.label && model.label !== model.id ? model.label : model.id;
608
+ if (model.vision) option.textContent += ' · visión';
609
+ if (model.context) option.dataset.context = String(model.context);
610
+ modelSelect.appendChild(option);
611
+ }
612
+ if (state.active.model && models.some((m) => m.id === state.active.model)) {
613
+ modelSelect.value = state.active.model;
614
+ } else {
615
+ modelSelect.value = models[0].id;
616
+ if (save) await saveSettings({ model: modelSelect.value });
617
+ }
618
+ modelSelect.disabled = false;
619
+ updateStatus();
620
+ renderTokens();
621
+ }
622
+
623
+ providerSelect.addEventListener('change', async () => {
624
+ await saveSettings({ provider: providerSelect.value });
625
+ await loadModels({ save: true });
626
+ });
627
+ modelSelect.addEventListener('change', () => saveSettings({ model: modelSelect.value }));
628
+
629
+ /* --- chat ---------------------------------------------------------------- */
630
+
631
+ const blankPane = $('#messages');
632
+ const panesEl = $('#panes');
633
+ const tabbarEl = $('#tabbar');
634
+ const chatForm = $('#chat-form');
635
+ const chatInput = $('#chat-input');
636
+ const submitBtn = chatForm.querySelector('button[type="submit"]');
637
+ const stopBtn = $('#stop-btn');
638
+ const controllers = new Map();
639
+ const sessionsEl = $('#sessions');
640
+
641
+ let activePane = blankPane;
642
+ const paneOf = (message) => message?.bubble?.closest('.messages') ?? activePane;
643
+
644
+ const scrollMessages = (pane = activePane) => {
645
+ if (pane) pane.scrollTop = pane.scrollHeight;
646
+ };
647
+
648
+ function addMessage(role, text = '', pane = activePane) {
649
+ pane.querySelector('.empty')?.remove();
650
+ const wrap = el('div', `msg ${role}`);
651
+ const bubble = el('div', 'bubble');
652
+ const textEl = el('div', 'text');
653
+ const tools = el('div', 'tools');
654
+ const activity = el('div', 'activity');
655
+ activity.hidden = true;
656
+ bubble.append(textEl, tools, activity);
657
+ wrap.appendChild(bubble);
658
+ pane.appendChild(wrap);
659
+ const message = { role, wrap, bubble, textEl, tools, activity, reasoningEl: null, id: null };
660
+ addMessageActions(message);
661
+ if (text) setText(message, text);
662
+ scrollMessages(pane);
663
+ return message;
664
+ }
665
+
666
+ function addMessageActions(message) {
667
+ const actions = el('div', 'msg-actions');
668
+ actions.appendChild(button('copiar', () => copyMessage(message), 'msg-act'));
669
+ if (message.role === 'assistant') {
670
+ actions.appendChild(button('regenerar', () => regenerate(message), 'msg-act'));
671
+ }
672
+ if (message.role === 'user') {
673
+ actions.appendChild(button('editar', () => editMessage(message), 'msg-act'));
674
+ }
675
+ actions.appendChild(button('borrar desde acá', () => deleteFrom(message), 'msg-act danger'));
676
+ message.bubble.appendChild(actions);
677
+ message.actions = actions;
678
+ }
679
+
680
+ function messageId(message) {
681
+ return message.wrap?.dataset.messageId || message.id || null;
682
+ }
683
+
684
+ function copyMessage(message) {
685
+ const text = message.role === 'assistant' ? message.textEl.innerText : message.textEl.textContent;
686
+ const write = navigator.clipboard?.writeText(text);
687
+ if (write?.catch) write.catch(() => {});
688
+ }
689
+
690
+ async function reloadPane(id) {
691
+ const record = state.panes.get(id);
692
+ if (!record) return;
693
+ const detail = await api(`/api/sessions/${id}`).catch(() => null);
694
+ if (!detail) return;
695
+ record.el.replaceChildren();
696
+ for (const stored of detail.messages) renderStored(stored, record.el);
697
+ if (!detail.messages.length) record.el.appendChild(el('div', 'empty', 'Sesión vacía.'));
698
+ }
699
+
700
+ async function regenerate(message) {
701
+ const id = message?.wrap?.closest('.messages')?.dataset.session || state.sessionId;
702
+ if (!id || state.panes.get(id)?.working) return;
703
+ const detail = await api(`/api/sessions/${id}`).catch(() => null);
704
+ if (!detail) return;
705
+ const messages = detail.messages || [];
706
+ let lastUser = null;
707
+ for (let i = messages.length - 1; i >= 0; i--) {
708
+ if (messages[i].role === 'user') {
709
+ lastUser = messages[i];
710
+ break;
711
+ }
712
+ }
713
+ if (!lastUser) return;
714
+ await api(`/api/sessions/${id}/truncate`, { method: 'POST', body: { fromId: lastUser.id } }).catch(() => {});
715
+ await reloadPane(id);
716
+ chatInput.value = userText(lastUser.content);
717
+ await sendMessage();
718
+ }
719
+
720
+ function editMessage(message) {
721
+ const id = messageId(message);
722
+ chatInput.value = message.textEl.textContent;
723
+ chatInput.focus();
724
+ state.pendingEdit = id ? { fromId: id } : null;
725
+ }
726
+
727
+ async function deleteFrom(message) {
728
+ const id = messageId(message);
729
+ if (!id || !window.confirm('¿Borrar este mensaje y todo lo posterior?')) return;
730
+ const sessionKey = state.sessionId;
731
+ await api(`/api/sessions/${sessionKey}/truncate`, { method: 'POST', body: { fromId: id } }).catch(() => {});
732
+ await reloadPane(sessionKey);
733
+ }
734
+
735
+ const ACTIVITY = {
736
+ thinking: { cls: 'loader-thinking', label: 'pensando…' },
737
+ working: { cls: 'loader-working', label: 'trabajando…' },
738
+ waiting: { cls: 'loader-thinking', label: 'esperando aprobación…' },
739
+ };
740
+
741
+ function setActivity(message, kind) {
742
+ if (!message.activity) return;
743
+ if (!kind) {
744
+ message.activity.hidden = true;
745
+ message.activity.replaceChildren();
746
+ return;
747
+ }
748
+ const def = ACTIVITY[kind];
749
+ message.activity.hidden = false;
750
+ message.activity.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
751
+ scrollMessages(paneOf(message));
752
+ }
753
+
754
+ function setText(message, text) {
755
+ const value = String(text ?? '');
756
+ if (message.role === 'assistant') message.textEl.innerHTML = renderMarkdown(value);
757
+ else message.textEl.textContent = value;
758
+ scrollMessages(paneOf(message));
759
+ }
760
+
761
+ function appendImages(message, urls) {
762
+ if (!urls.length) return;
763
+ const wrap = el('div', 'msg-images');
764
+ for (const url of urls) {
765
+ const img = el('img', 'msg-image');
766
+ img.src = url;
767
+ img.alt = 'imagen adjunta';
768
+ wrap.appendChild(img);
769
+ }
770
+ message.bubble.insertBefore(wrap, message.tools);
771
+ }
772
+
773
+ panesEl.addEventListener('click', (event) => {
774
+ const button = event.target.closest('.code-copy');
775
+ if (!button) return;
776
+ const pre = button.closest('.code-block')?.querySelector('pre');
777
+ if (!pre || !navigator.clipboard) return;
778
+ navigator.clipboard.writeText(pre.textContent).then(() => {
779
+ button.textContent = 'copiado';
780
+ setTimeout(() => (button.textContent = 'copiar'), 1200);
781
+ });
782
+ });
783
+
784
+ function ensureReasoning(message) {
785
+ if (message.reasoningEl) return message.reasoningEl;
786
+ const details = el('details', 'reasoning');
787
+ details.appendChild(el('summary', '', 'razonamiento'));
788
+ const body = el('div', 'body');
789
+ details.appendChild(body);
790
+ message.bubble.insertBefore(details, message.tools);
791
+ message.reasoningEl = body;
792
+ return body;
793
+ }
794
+
795
+ function renderToolOut(node, text) {
796
+ const content = String(text ?? '');
797
+ const lines = content.split('\n');
798
+ const adds = lines.filter((line) => line.startsWith('+ ')).length;
799
+ const dels = lines.filter((line) => line.startsWith('- ')).length;
800
+ if (adds === 0 || dels === 0) {
801
+ node.textContent = content;
802
+ return;
803
+ }
804
+ node.textContent = '';
805
+ for (const line of lines) {
806
+ const cls = line.startsWith('+ ')
807
+ ? 'diff-add'
808
+ : line.startsWith('- ')
809
+ ? 'diff-del'
810
+ : /^ {2}…/.test(line)
811
+ ? 'diff-sep'
812
+ : 'diff-eq';
813
+ node.appendChild(el('span', `diff-line ${cls}`, line));
814
+ }
815
+ }
816
+
817
+ function addToolCard(message, name, args) {
818
+ const card = el('div', 'tool-card running');
819
+ const head = el('div', 'tool-head');
820
+ head.append(
821
+ el('span', 'tool-glyph', '⏺'),
822
+ el('span', 'tool-name', name),
823
+ el('span', 'tool-args', truncate(args, 200))
824
+ );
825
+ const out = el('div', 'tool-out');
826
+ card.append(head, out);
827
+ message.tools.appendChild(card);
828
+ scrollMessages(paneOf(message));
829
+ return { card, out };
830
+ }
831
+
832
+ function addPermissionCard(message, data) {
833
+ const card = el('div', 'perm-card');
834
+ const head = el('div', 'perm-head');
835
+ head.append(el('span', 'perm-icon', '⚠'), el('span', 'perm-title', `permiso para ${data.name}`));
836
+ const args = el('div', 'perm-args', truncate(JSON.stringify(data.args ?? {}), 400));
837
+ const actions = el('div', 'perm-actions');
838
+ const verdict = el('span', 'muted', 'requiere aprobación');
839
+ const buttons = {
840
+ deny: button('denegar', () => decide(false, 'once'), 'btn'),
841
+ once: button('permitir una vez', () => decide(true, 'once'), 'btn primary'),
842
+ always: button('autorizar todo', () => decide(true, 'session'), 'btn'),
843
+ };
844
+ actions.append(verdict, el('span', 'spacer'), buttons.deny, buttons.once, buttons.always);
845
+ card.append(head, args, actions);
846
+ message.tools.appendChild(card);
847
+ scrollMessages(paneOf(message));
848
+ state.permissionCards.set(data.askId, { card, verdict, buttons });
849
+
850
+ async function decide(allow, scope) {
851
+ for (const btn of Object.values(buttons)) btn.disabled = true;
852
+ card.classList.remove('pending');
853
+ card.classList.add('collapsed', allow ? 'approved' : 'denied');
854
+ card.querySelector('.perm-icon').textContent = allow ? '✓' : '✗';
855
+ verdict.textContent =
856
+ allow && scope === 'session' ? 'autorizado para toda la sesión' : allow ? 'aprobado' : 'denegado';
857
+ if (allow && scope === 'session') yesInput.checked = true;
858
+ await api(`/api/permissions/${encodeURIComponent(data.askId)}`, {
859
+ method: 'POST',
860
+ body: { allow, scope },
861
+ }).catch(() => {});
862
+ }
863
+ card.classList.add('pending');
864
+ return card;
865
+ }
866
+
867
+ function userText(content) {
868
+ if (typeof content === 'string') return content;
869
+ if (Array.isArray(content)) {
870
+ return content
871
+ .filter((part) => part?.type === 'text')
872
+ .map((part) => part.text)
873
+ .join('\n');
874
+ }
875
+ return content == null ? '' : JSON.stringify(content);
876
+ }
877
+
878
+ function userImages(content) {
879
+ if (!Array.isArray(content)) return [];
880
+ return content
881
+ .filter((part) => part?.type === 'image_url')
882
+ .map((part) => part.image_url?.url)
883
+ .filter(Boolean);
884
+ }
885
+
886
+ function renderStored(message, pane) {
887
+ if (message.role === 'user') {
888
+ const bubble = addMessage('user', userText(message.content), pane);
889
+ if (message.id) {
890
+ bubble.id = message.id;
891
+ bubble.wrap.dataset.messageId = message.id;
892
+ }
893
+ appendImages(bubble, userImages(message.content));
894
+ } else if (message.role === 'assistant') {
895
+ const bubble = addMessage('assistant', typeof message.content === 'string' ? message.content : '', pane);
896
+ if (message.id) {
897
+ bubble.id = message.id;
898
+ bubble.wrap.dataset.messageId = message.id;
899
+ }
900
+ for (const call of message.tool_calls ?? []) {
901
+ const tool = addToolCard(
902
+ bubble,
903
+ call.function?.name ?? call.name,
904
+ call.function?.arguments ?? call.arguments ?? ''
905
+ );
906
+ tool.card.classList.remove('running');
907
+ tool.card.classList.add('done');
908
+ tool.card.querySelector('.tool-glyph').textContent = '✓';
909
+ }
910
+ }
911
+ }
912
+
913
+ function basename(p) {
914
+ const text = String(p ?? '').replace(/[\\/]+$/, '');
915
+ return text.split(/[\\/]/).pop() || text;
916
+ }
917
+
918
+ function persistCollapsedGroups() {
919
+ try {
920
+ localStorage.setItem('tamnora-collapsed-groups', JSON.stringify([...state.collapsedGroups]));
921
+ } catch {
922
+ // storage unavailable
923
+ }
924
+ }
925
+
926
+ function persistTabs() {
927
+ try {
928
+ localStorage.setItem('tamnora-open-tabs', JSON.stringify({ ids: state.tabOrder, active: state.activeTab }));
929
+ } catch {
930
+ // storage unavailable
931
+ }
932
+ }
933
+
934
+ async function refreshSessions() {
935
+ const data = await api('/api/sessions').catch(() => ({ sessions: [] }));
936
+ clear(sessionsEl);
937
+ const groups = new Map();
938
+ for (const session of data.sessions) {
939
+ const key = session.cwd || '';
940
+ if (!groups.has(key)) groups.set(key, []);
941
+ groups.get(key).push(session);
942
+ }
943
+ for (const [key, items] of groups) {
944
+ const groupLi = el('li', 'session-group');
945
+ const head = el('div', 'group-head');
946
+ const collapsed = state.collapsedGroups.has(key);
947
+ const twisty = el('span', 'tw');
948
+ twisty.appendChild(icon(collapsed ? 'chevron-right' : 'chevron-down', 14));
949
+ head.append(twisty, icon('folder', 15, 'fi'), el('span', 'gname', key ? basename(key) : 'sin carpeta'), el('span', 'gcount', String(items.length)));
950
+ head.title = key || 'sesiones sin carpeta';
951
+ const list = el('ul', 'group-sessions');
952
+ list.hidden = collapsed;
953
+ head.addEventListener('click', () => {
954
+ const nowCollapsed = !state.collapsedGroups.has(key);
955
+ if (nowCollapsed) state.collapsedGroups.add(key);
956
+ else state.collapsedGroups.delete(key);
957
+ persistCollapsedGroups();
958
+ list.hidden = nowCollapsed;
959
+ clear(twisty);
960
+ twisty.appendChild(icon(nowCollapsed ? 'chevron-right' : 'chevron-down', 14));
961
+ });
962
+ for (const session of items) {
963
+ const li = el('li', `session-item${session.id === state.sessionId ? ' active' : ''}`);
964
+ li.appendChild(el('div', '', session.title || '(sin título)'));
965
+ li.title = `${session.provider ?? ''}/${session.model ?? ''}${session.cwd ? ` · ${session.cwd}` : ''}`;
966
+ li.addEventListener('click', () => {
967
+ if (isMobile()) setSidebarOpen(false);
968
+ openTab(session.id);
969
+ });
970
+ list.appendChild(li);
971
+ }
972
+ groupLi.append(head, list);
973
+ sessionsEl.appendChild(groupLi);
974
+ }
975
+ if (!data.sessions.length) sessionsEl.appendChild(el('li', 'muted', 'sin sesiones'));
976
+ }
977
+
978
+ /* --- session tabs --------------------------------------------------------- */
979
+
980
+ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }) {
981
+ const id = session.id;
982
+ if (state.panes.has(id)) return state.panes.get(id);
983
+ const pane = document.createElement('div');
984
+ pane.className = 'messages';
985
+ pane.dataset.session = id;
986
+ panesEl.appendChild(pane);
987
+ const record = {
988
+ id,
989
+ el: pane,
990
+ title: session.title || '(sin título)',
991
+ cwd: session.cwd || '',
992
+ working: false,
993
+ tokens,
994
+ lastTurn: { prompt: 0, completion: 0 },
995
+ };
996
+ state.panes.set(id, record);
997
+ state.tabOrder.push(id);
998
+ for (const message of messages) renderStored(message, pane);
999
+ if (!messages.length) {
1000
+ pane.appendChild(el('div', 'empty', `Sesión vacía${session.cwd ? ` en ${basename(session.cwd)}` : ''}.`));
1001
+ }
1002
+ return record;
1003
+ }
1004
+
1005
+ function showPane(id) {
1006
+ const record = id ? state.panes.get(id) : null;
1007
+ for (const item of state.panes.values()) item.el.hidden = item.id !== id;
1008
+ blankPane.hidden = Boolean(record);
1009
+ activePane = record ? record.el : blankPane;
1010
+ }
1011
+
1012
+ function renderTabs() {
1013
+ clear(tabbarEl);
1014
+ tabbarEl.hidden = !state.tabOrder.length;
1015
+ for (const id of state.tabOrder) {
1016
+ const record = state.panes.get(id);
1017
+ if (!record) continue;
1018
+ const tab = el('button', `session-tab${id === state.activeTab ? ' active' : ''}`);
1019
+ tab.type = 'button';
1020
+ tab.title = `${record.title}${record.cwd ? ` · ${record.cwd}` : ''}`;
1021
+ if (record.working) tab.appendChild(el('span', 'tab-spinner'));
1022
+ tab.appendChild(el('span', 'tab-title', record.title || '(sin título)'));
1023
+ const close = el('span', 'tab-close');
1024
+ close.title = 'cerrar';
1025
+ close.appendChild(icon('x', 12));
1026
+ close.addEventListener('click', (event) => {
1027
+ event.stopPropagation();
1028
+ closeTab(id);
1029
+ });
1030
+ tab.appendChild(close);
1031
+ tab.addEventListener('click', () => openTab(id));
1032
+ tabbarEl.appendChild(tab);
1033
+ }
1034
+ }
1035
+
1036
+ async function openTab(id) {
1037
+ if (state.panes.has(id)) {
1038
+ activateSession(id);
1039
+ return;
1040
+ }
1041
+ const data = await api(`/api/sessions/${id}`).catch((error) => {
1042
+ notify(`No se pudo abrir la sesión: ${error.message}`, 'notice err');
1043
+ return null;
1044
+ });
1045
+ if (!data) return;
1046
+ registerSession(data.session, data.messages, data.tokens);
1047
+ activateSession(id);
1048
+ }
1049
+
1050
+ function activateSession(id) {
1051
+ const record = state.panes.get(id);
1052
+ if (!record) return;
1053
+ state.sessionId = id;
1054
+ state.activeTab = id;
1055
+ showPane(id);
1056
+ if (record.cwd) setCwd(record.cwd);
1057
+ if (stopBtn) stopBtn.hidden = !record.working;
1058
+ auditStatus(record);
1059
+ updateStatus();
1060
+ renderTokens();
1061
+ renderTabs();
1062
+ refreshSessions();
1063
+ persistTabs();
1064
+ chatInput.focus();
1065
+ }
1066
+
1067
+ function auditStatus(record) {
1068
+ submitBtn.disabled = record.working;
1069
+ $('#sb-rounds').textContent = record.working ? 'pensando…' : 'listo';
1070
+ $('#sb-rounds').classList.toggle('running', record.working);
1071
+ }
1072
+
1073
+ function closeTab(id) {
1074
+ const record = state.panes.get(id);
1075
+ if (!record) return;
1076
+ record.el.remove();
1077
+ state.panes.delete(id);
1078
+ state.tabOrder = state.tabOrder.filter((item) => item !== id);
1079
+ if (state.activeTab === id) {
1080
+ const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
1081
+ if (next) {
1082
+ activateSession(next);
1083
+ } else {
1084
+ state.sessionId = null;
1085
+ state.activeTab = null;
1086
+ showPane(null);
1087
+ renderTabs();
1088
+ renderTokens();
1089
+ refreshTokensDefaults();
1090
+ refreshSessions();
1091
+ }
1092
+ } else {
1093
+ renderTabs();
1094
+ }
1095
+ persistTabs();
1096
+ }
1097
+
1098
+ function refreshTokensDefaults() {
1099
+ $('#sb-rounds').textContent = 'listo';
1100
+ $('#sb-rounds').classList.remove('running');
1101
+ submitBtn.disabled = false;
1102
+ }
1103
+
1104
+ function setWorking(id, working) {
1105
+ const record = state.panes.get(id);
1106
+ if (record) record.working = working;
1107
+ renderTabs();
1108
+ if (id === state.sessionId) {
1109
+ submitBtn.disabled = working;
1110
+ if (stopBtn) stopBtn.hidden = !working;
1111
+ $('#sb-rounds').textContent = working ? 'pensando.' : 'listo';
1112
+ $('#sb-rounds').classList.toggle('running', working);
1113
+ }
1114
+ }
1115
+
1116
+ async function restoreTabs() {
1117
+ let saved = null;
1118
+ try {
1119
+ saved = JSON.parse(localStorage.getItem('tamnora-open-tabs') || 'null');
1120
+ } catch {
1121
+ saved = null;
1122
+ }
1123
+ const ids = Array.isArray(saved?.ids) ? saved.ids : [];
1124
+ for (const id of ids) {
1125
+ if (state.panes.has(id)) continue;
1126
+ const data = await api(`/api/sessions/${id}`).catch(() => null);
1127
+ if (data) registerSession(data.session, data.messages, data.tokens);
1128
+ }
1129
+ const active = saved?.active && state.panes.has(saved.active) ? saved.active : state.tabOrder[state.tabOrder.length - 1];
1130
+ if (active) activateSession(active);
1131
+ else renderTabs();
1132
+ }
1133
+
1134
+ $('#new-session').addEventListener('click', () => {
1135
+ if (isMobile()) setSidebarOpen(false);
1136
+ openFolderPicker();
1137
+ });
1138
+
1139
+ const INIT_PROMPT =
1140
+ 'Analizá este proyecto (estructura, stack, convenciones, comandos de build/test) y creá o actualizá un archivo AGENTS.md en la raíz con instrucciones claras para futuros agentes. Inspeccioná archivos reales antes de escribir.';
1141
+
1142
+ async function sendMessage() {
1143
+ const current = state.panes.get(state.sessionId);
1144
+ if (current?.working) return;
1145
+ let input = chatInput.value.trim();
1146
+ const images = state.attachments.slice();
1147
+ if (input.startsWith('/') && !images.length) {
1148
+ const name = input.slice(1).split(/\s+/)[0].toLowerCase();
1149
+ const arg = input.slice(1 + name.length).trim();
1150
+ const outcome = await runCommand(name, arg);
1151
+ if (!outcome || outcome.handled) {
1152
+ chatInput.value = '';
1153
+ return;
1154
+ }
1155
+ input = outcome.input ?? input;
1156
+ }
1157
+ if (!input && !images.length) return;
1158
+ if (!state.sessionId) {
1159
+ state.pendingInput = input;
1160
+ openFolderPicker();
1161
+ return;
1162
+ }
1163
+
1164
+ if (state.pendingEdit) {
1165
+ await api(`/api/sessions/${state.sessionId}/truncate`, {
1166
+ method: 'POST',
1167
+ body: { fromId: state.pendingEdit.fromId },
1168
+ }).catch(() => {});
1169
+ await reloadPane(state.sessionId);
1170
+ state.pendingEdit = null;
1171
+ }
1172
+
1173
+ const pane = activePane;
1174
+ const sessionKey = state.sessionId;
1175
+ const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
1176
+ appendImages(user, images);
1177
+ chatInput.value = '';
1178
+ state.attachments = [];
1179
+ renderAttachments();
1180
+ let active = addMessage('assistant', '', pane);
1181
+ const controller = new AbortController();
1182
+ controllers.set(sessionKey, controller);
1183
+ setWorking(sessionKey, true);
1184
+ let sawText = false;
1185
+ let lastTool = null;
1186
+ let startNewOnText = false;
1187
+
1188
+ const flowTarget = () => {
1189
+ if (startNewOnText) {
1190
+ active = addMessage('assistant', '', pane);
1191
+ startNewOnText = false;
1192
+ setActivity(active, 'thinking');
1193
+ }
1194
+ return active;
1195
+ };
1196
+
1197
+ try {
1198
+ await streamPost(
1199
+ '/api/chat',
1200
+ {
1201
+ input: input || '(imagen adjunta)',
1202
+ images: images.length ? images : undefined,
1203
+ sessionId: state.sessionId || undefined,
1204
+ provider: providerSelect.value || undefined,
1205
+ model: modelSelect.value || undefined,
1206
+ mode: modeSelect.value || undefined,
1207
+ yes: yesInput.checked ? true : undefined,
1208
+ contextTokens: (() => {
1209
+ const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
1210
+ return context ? Math.floor(context * 0.8) : undefined;
1211
+ })(),
1212
+ },
1213
+ (event, data) => {
1214
+ if (event === 'start') {
1215
+ if (data.cwd) setCwd(data.cwd);
1216
+ } else if (event === 'text') {
1217
+ sawText = true;
1218
+ const target = flowTarget();
1219
+ setText(target, data.full);
1220
+ setActivity(target, 'thinking');
1221
+ } else if (event === 'reasoning') {
1222
+ const target = flowTarget();
1223
+ if (!sawText) ensureReasoning(target).textContent = data.full;
1224
+ setActivity(target, 'thinking');
1225
+ } else if (event === 'tools') {
1226
+ startNewOnText = true;
1227
+ } else if (event === 'tool_call') {
1228
+ setActivity(active, 'working');
1229
+ lastTool = addToolCard(active, data.name, JSON.stringify(data.args));
1230
+ } else if (event === 'tool_result') {
1231
+ setActivity(active, 'thinking');
1232
+ if (lastTool) {
1233
+ const failed = /^Error|^Permiso denegado/i.test(String(data.result));
1234
+ renderToolOut(lastTool.out, truncate(data.result));
1235
+ lastTool.card.classList.remove('running');
1236
+ lastTool.card.classList.add(failed ? 'failed' : 'done');
1237
+ lastTool.card.querySelector('.tool-glyph').textContent = failed ? '✗' : '✓';
1238
+ }
1239
+ } else if (event === 'permission') {
1240
+ setActivity(active, 'waiting');
1241
+ addPermissionCard(active, data);
1242
+ } else if (event === 'permission_resolved') {
1243
+ setActivity(active, 'thinking');
1244
+ const entry = state.permissionCards.get(data.askId);
1245
+ if (entry && entry.verdict.textContent === 'requiere aprobación') {
1246
+ entry.card.classList.add('collapsed', data.allow ? 'approved' : 'denied');
1247
+ entry.card.classList.remove('pending');
1248
+ entry.card.querySelector('.perm-icon').textContent = data.allow ? '✓' : '✗';
1249
+ entry.verdict.textContent = data.allow ? 'aprobado' : 'denegado (sin respuesta)';
1250
+ for (const btn of Object.values(entry.buttons)) btn.disabled = true;
1251
+ }
1252
+ } else if (event === 'stall') {
1253
+ if (!sawText) setText(active, `⚠ ${data.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`);
1254
+ } else if (event === 'retry') {
1255
+ if (!sawText) setText(active, `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1256
+ } else if (event === 'trim') {
1257
+ active.tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1258
+ } else if (event === 'warning') {
1259
+ active.tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1260
+ } else if (event === 'error') {
1261
+ setActivity(active, null);
1262
+ active.tools.appendChild(el('div', 'chip err', `✗ ${data.message}`));
1263
+ } else if (event === 'done') {
1264
+ setActivity(active, null);
1265
+ if (!data.content && !sawText) setText(active, '(sin texto)');
1266
+ updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1267
+ addTokens(data.tokens);
1268
+ }
1269
+ },
1270
+ controller.signal
1271
+ );
1272
+ } catch (error) {
1273
+ setActivity(active, null);
1274
+ if (error.name === 'AbortError') setText(active, '⏹ cancelado.');
1275
+ else setText(active, `Error: ${error.message}`);
1276
+ } finally {
1277
+ controllers.delete(sessionKey);
1278
+ setActivity(active, null);
1279
+ setWorking(sessionKey, false);
1280
+ refreshSessions();
1281
+ }
1282
+ }
1283
+
1284
+ if (stopBtn) {
1285
+ stopBtn.addEventListener('click', () => {
1286
+ const controller = controllers.get(state.sessionId);
1287
+ if (controller) controller.abort();
1288
+ });
1289
+ }
1290
+
1291
+ chatForm.addEventListener('submit', (event) => {
1292
+ event.preventDefault();
1293
+ sendMessage();
1294
+ });
1295
+ chatInput.addEventListener('keydown', (event) => {
1296
+ if (event.key === 'Enter' && !event.shiftKey) {
1297
+ event.preventDefault();
1298
+ sendMessage();
1299
+ }
1300
+ });
1301
+
1302
+ /* --- image attachments ---------------------------------------------------- */
1303
+
1304
+ const attachInput = $('#image-input');
1305
+ const attachPreview = $('#attach-preview');
1306
+
1307
+ function renderAttachments() {
1308
+ clear(attachPreview);
1309
+ attachPreview.hidden = !state.attachments.length;
1310
+ state.attachments.forEach((url, index) => {
1311
+ const thumb = el('div', 'attach-thumb');
1312
+ const img = el('img');
1313
+ img.src = url;
1314
+ img.alt = `adjunto ${index + 1}`;
1315
+ const remove = button('', () => {
1316
+ state.attachments.splice(index, 1);
1317
+ renderAttachments();
1318
+ }, 'attach-remove');
1319
+ remove.appendChild(icon('x', 12));
1320
+ thumb.append(img, remove);
1321
+ attachPreview.appendChild(thumb);
1322
+ });
1323
+ }
1324
+
1325
+ function readAsDataUrl(file) {
1326
+ return new Promise((resolve, reject) => {
1327
+ const reader = new FileReader();
1328
+ reader.onerror = () => reject(new Error('No se pudo leer la imagen'));
1329
+ reader.onload = () => resolve(reader.result);
1330
+ reader.readAsDataURL(file);
1331
+ });
1332
+ }
1333
+
1334
+ async function fileToDataUrl(file) {
1335
+ if (file.type === 'image/gif' || file.size <= 256 * 1024) return readAsDataUrl(file);
1336
+ const url = await readAsDataUrl(file);
1337
+ return new Promise((resolve) => {
1338
+ const img = new Image();
1339
+ img.onerror = () => resolve(url);
1340
+ img.onload = () => {
1341
+ const scale = Math.min(1, 1600 / Math.max(img.width, img.height));
1342
+ const canvas = document.createElement('canvas');
1343
+ canvas.width = Math.max(1, Math.round(img.width * scale));
1344
+ canvas.height = Math.max(1, Math.round(img.height * scale));
1345
+ canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
1346
+ resolve(canvas.toDataURL('image/jpeg', 0.85));
1347
+ };
1348
+ img.src = url;
1349
+ });
1350
+ }
1351
+
1352
+ async function addImageFiles(files) {
1353
+ for (const file of files) {
1354
+ if (!file?.type?.startsWith('image/')) continue;
1355
+ try {
1356
+ state.attachments.push(await fileToDataUrl(file));
1357
+ } catch {
1358
+ // skip unreadable files
1359
+ }
1360
+ }
1361
+ renderAttachments();
1362
+ }
1363
+
1364
+ $('#attach-image').addEventListener('click', () => attachInput.click());
1365
+ attachInput.addEventListener('change', () => {
1366
+ addImageFiles([...attachInput.files]);
1367
+ attachInput.value = '';
1368
+ });
1369
+ chatInput.addEventListener('paste', (event) => {
1370
+ const files = [...(event.clipboardData?.files ?? [])].filter((f) => f.type.startsWith('image/'));
1371
+ if (!files.length) return;
1372
+ event.preventDefault();
1373
+ addImageFiles(files);
1374
+ });
1375
+ chatForm.addEventListener('dragover', (event) => {
1376
+ event.preventDefault();
1377
+ chatForm.classList.add('dragging');
1378
+ });
1379
+ chatForm.addEventListener('dragleave', () => chatForm.classList.remove('dragging'));
1380
+ chatForm.addEventListener('drop', (event) => {
1381
+ const files = [...(event.dataTransfer?.files ?? [])].filter((f) => f.type.startsWith('image/'));
1382
+ if (!files.length) return;
1383
+ event.preventDefault();
1384
+ chatForm.classList.remove('dragging');
1385
+ addImageFiles(files);
1386
+ });
1387
+
1388
+ /* --- slash commands ------------------------------------------------------- */
1389
+
1390
+ const COMMANDS = [
1391
+ ['/new', 'nueva sesión (elegís carpeta)'],
1392
+ ['/init', 'analizar el proyecto y crear/actualizar AGENTS.md'],
1393
+ ['/goal <texto>', 'fijar el objetivo de la sesión'],
1394
+ ['/compact', 'resumir la conversación y liberar contexto'],
1395
+ ['/clear', 'borrar los mensajes de esta sesión'],
1396
+ ['/cwd', 'mostrar la carpeta de trabajo'],
1397
+ ['/help', 'mostrar esta ayuda'],
1398
+ ];
1399
+
1400
+ function notify(markdown, kind = 'notice') {
1401
+ const message = addMessage('assistant', '');
1402
+ message.textEl.classList.add(kind);
1403
+ message.textEl.innerHTML = renderMarkdown(markdown);
1404
+ scrollMessages();
1405
+ return message;
1406
+ }
1407
+
1408
+ function needSession() {
1409
+ notify('Abrí o creá una sesión primero (`/new`).', 'notice err');
1410
+ return { handled: true };
1411
+ }
1412
+
1413
+ async function runCommand(name, arg) {
1414
+ switch (name) {
1415
+ case 'help':
1416
+ case 'ayuda':
1417
+ notify(`### Comandos\n${COMMANDS.map(([cmd, desc]) => `- \`${cmd}\` — ${desc}`).join('\n')}`);
1418
+ return { handled: true };
1419
+ case 'new':
1420
+ case 'nueva':
1421
+ openFolderPicker();
1422
+ return { handled: true };
1423
+ case 'init':
1424
+ return { handled: false, input: INIT_PROMPT };
1425
+ case 'cwd': {
1426
+ const cwd = $('#rail-cwd').textContent;
1427
+ notify(`Carpeta de trabajo: \`${cwd}\``);
1428
+ return { handled: true };
1429
+ }
1430
+ case 'clear':
1431
+ case 'limpiar': {
1432
+ if (!state.sessionId) return needSession();
1433
+ await api(`/api/sessions/${state.sessionId}/messages`, { method: 'DELETE' }).catch(() => null);
1434
+ clear(activePane);
1435
+ activePane.appendChild(el('div', 'empty', 'Sesión vacía.'));
1436
+ return { handled: true };
1437
+ }
1438
+ case 'goal': {
1439
+ if (!state.sessionId) return needSession();
1440
+ if (!arg) {
1441
+ const data = await api(`/api/sessions/${state.sessionId}`).catch(() => null);
1442
+ notify(data?.session?.goal ? `Objetivo actual: **${data.session.goal}**` : 'No hay objetivo fijado.');
1443
+ return { handled: true };
1444
+ }
1445
+ await api(`/api/sessions/${state.sessionId}`, { method: 'PUT', body: { goal: arg } }).catch(() => null);
1446
+ notify(`Objetivo de la sesión: **${arg}**`);
1447
+ return { handled: true };
1448
+ }
1449
+ case 'compact': {
1450
+ if (!state.sessionId) return needSession();
1451
+ notify('Compactando la conversación…');
1452
+ const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
1453
+ error: error.message,
1454
+ }));
1455
+ clear(activePane);
1456
+ if (out.error) notify(`No se pudo compactar: ${out.error}`, 'notice err');
1457
+ else notify(`Contexto compactado. Resumen guardado:\n\n${out.summary}`);
1458
+ refreshSessions();
1459
+ return { handled: true };
1460
+ }
1461
+ default:
1462
+ notify(`Comando desconocido: \`/${name}\`. Probá \`/help\`.`, 'notice err');
1463
+ return { handled: true };
1464
+ }
1465
+ }
1466
+
1467
+ /* --- folder picker -------------------------------------------------------- */
1468
+
1469
+ const folderModal = $('#folder-modal');
1470
+ const folderList = $('#folder-list');
1471
+ const folderCrumbs = $('#folder-crumbs');
1472
+ const folderStatusEl = $('#folder-status');
1473
+ const folderGoto = $('#folder-goto');
1474
+ const folderCreate = $('#folder-create');
1475
+ const folderNewName = $('#folder-new-name');
1476
+ const folderUseBtn = $('#folder-use');
1477
+ let folderListPath = null;
1478
+ let folderParent = null;
1479
+ let folderDirs = [];
1480
+
1481
+ const FOLDER_PATH = 'M3.5 6.3A1.3 1.3 0 0 1 4.8 5h3.1l1.5 1.7h7.8a1.3 1.3 0 0 1 1.3 1.3v7.7a1.3 1.3 0 0 1-1.3 1.3H4.8a1.3 1.3 0 0 1-1.3-1.3z';
1482
+
1483
+ function folderIcon() {
1484
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1485
+ svg.setAttribute('viewBox', '0 0 22 22');
1486
+ svg.setAttribute('class', 'folder-ico');
1487
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1488
+ path.setAttribute('d', FOLDER_PATH);
1489
+ path.setAttribute('fill', 'none');
1490
+ path.setAttribute('stroke', 'currentColor');
1491
+ path.setAttribute('stroke-width', '1.4');
1492
+ path.setAttribute('stroke-linejoin', 'round');
1493
+ svg.appendChild(path);
1494
+ return svg;
1495
+ }
1496
+
1497
+ function setFolderStatus(text, cls = '') {
1498
+ folderStatusEl.className = `summary ${cls}`.trim();
1499
+ folderStatusEl.textContent = text;
1500
+ }
1501
+
1502
+ function closeFolderPicker() {
1503
+ if (folderModal.open) folderModal.close();
1504
+ }
1505
+
1506
+ function openFolderPicker() {
1507
+ folderGoto.value = '';
1508
+ setFolderStatus('');
1509
+ folderModal.showModal();
1510
+ loadFolders(state.home || '');
1511
+ }
1512
+
1513
+ function renderCrumbs(pathStr) {
1514
+ clear(folderCrumbs);
1515
+ if (!pathStr) {
1516
+ folderCrumbs.appendChild(el('span', 'crumb current', 'unidades'));
1517
+ return;
1518
+ }
1519
+ const win = pathStr.includes('\\');
1520
+ const parts = pathStr.split(/[\\/]+/).filter(Boolean);
1521
+ const segments = parts.map((part, index) =>
1522
+ win
1523
+ ? { label: part, path: index === 0 ? `${part}\\` : parts.slice(0, index + 1).join('\\') }
1524
+ : { label: part, path: `/${parts.slice(0, index + 1).join('/')}` }
1525
+ );
1526
+ segments.forEach((segment, index) => {
1527
+ if (index) folderCrumbs.appendChild(el('span', 'crumb-sep', '/'));
1528
+ const last = index === segments.length - 1;
1529
+ const node = el('span', `crumb${last ? ' current' : ''}`, segment.label);
1530
+ if (!last) node.addEventListener('click', () => loadFolders(segment.path));
1531
+ folderCrumbs.appendChild(node);
1532
+ });
1533
+ }
1534
+
1535
+ function renderFolderList(dirs) {
1536
+ clear(folderList);
1537
+ if (!dirs.length) {
1538
+ folderList.appendChild(
1539
+ el('li', 'folders-empty', folderListPath ? 'sin subcarpetas (podés usar esta carpeta igual)' : 'sin unidades')
1540
+ );
1541
+ return;
1542
+ }
1543
+ for (const dir of dirs) {
1544
+ const li = el('li', 'folder-item');
1545
+ li.title = dir.path;
1546
+ li.tabIndex = 0;
1547
+ li.append(folderIcon(), el('span', 'folder-name', dir.name), el('span', 'folder-enter', '↵'));
1548
+ const enter = () => loadFolders(dir.path);
1549
+ li.addEventListener('click', enter);
1550
+ li.addEventListener('keydown', (event) => {
1551
+ if (event.key === 'Enter' || event.key === ' ') {
1552
+ event.preventDefault();
1553
+ enter();
1554
+ }
1555
+ });
1556
+ folderList.appendChild(li);
1557
+ }
1558
+ }
1559
+
1560
+ async function loadFolders(target = '') {
1561
+ folderGoto.value = '';
1562
+ closeCreateFolder();
1563
+ setFolderStatus('cargando…');
1564
+ const data = await api(`/api/folders?path=${encodeURIComponent(target)}`).catch((error) => {
1565
+ setFolderStatus(error.message, 'err');
1566
+ return null;
1567
+ });
1568
+ if (!data) return;
1569
+ folderListPath = data.path;
1570
+ folderParent = data.parent;
1571
+ folderDirs = [...data.dirs].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
1572
+ renderCrumbs(data.path);
1573
+ $('#folder-up').disabled = !data.path;
1574
+ $('#folder-new').disabled = !data.path;
1575
+ folderUseBtn.disabled = !data.path;
1576
+ folderUseBtn.textContent = data.path ? `usar «${basename(data.path)}»` : 'usar esta carpeta';
1577
+ renderFolderList(folderDirs);
1578
+ const count = data.path ? `${folderDirs.length} subcarpetas` : `${folderDirs.length} unidades`;
1579
+ setFolderStatus(data.path ? `${count} · clic para entrar` : 'elegí una unidad');
1580
+ }
1581
+
1582
+ async function useFolder() {
1583
+ const cwd = folderListPath;
1584
+ if (!cwd) return setFolderStatus('Entrá a una carpeta primero (clic para navegar).', 'err');
1585
+ const created = await api('/api/sessions', { method: 'POST', body: { title: 'Nueva sesión', cwd } }).catch((error) => {
1586
+ setFolderStatus(error.message, 'err');
1587
+ return null;
1588
+ });
1589
+ if (!created) return;
1590
+ closeFolderPicker();
1591
+ registerSession(created.session, []);
1592
+ activateSession(created.session.id);
1593
+ const pending = state.pendingInput;
1594
+ if (pending !== null && pending !== undefined) {
1595
+ state.pendingInput = null;
1596
+ chatInput.value = pending;
1597
+ sendMessage();
1598
+ }
1599
+ }
1600
+
1601
+ function filterFolders() {
1602
+ const query = folderGoto.value.trim().toLowerCase();
1603
+ if (!query) return renderFolderList(folderDirs);
1604
+ renderFolderList(folderDirs.filter((dir) => dir.name.toLowerCase().includes(query)));
1605
+ }
1606
+
1607
+ function gotoFolder() {
1608
+ const value = folderGoto.value.trim();
1609
+ if (!value) return;
1610
+ if (/[\\/]/.test(value) || /^[a-z]:$/i.test(value)) return loadFolders(value);
1611
+ const matches = folderDirs.filter((dir) => dir.name.toLowerCase().includes(value.toLowerCase()));
1612
+ if (matches.length === 1) loadFolders(matches[0].path);
1613
+ else if (!matches.length) setFolderStatus('sin coincidencias', 'err');
1614
+ else setFolderStatus(`${matches.length} coincidencias`, '');
1615
+ }
1616
+
1617
+ function openCreateFolder() {
1618
+ if (!folderListPath) return;
1619
+ folderCreate.hidden = false;
1620
+ folderNewName.value = '';
1621
+ folderNewName.focus();
1622
+ }
1623
+
1624
+ function closeCreateFolder() {
1625
+ folderCreate.hidden = true;
1626
+ folderNewName.value = '';
1627
+ }
1628
+
1629
+ async function createFolder() {
1630
+ const name = folderNewName.value.trim();
1631
+ if (!name || !folderListPath) return;
1632
+ const created = await api('/api/folders', {
1633
+ method: 'POST',
1634
+ body: { parent: folderListPath, name },
1635
+ }).catch((error) => {
1636
+ setFolderStatus(error.message, 'err');
1637
+ return null;
1638
+ });
1639
+ if (!created) return;
1640
+ await loadFolders(created.path);
1641
+ setFolderStatus(`carpeta «${name}» creada · usala para el nuevo proyecto`, 'ok');
1642
+ }
1643
+
1644
+ $('#folder-new').addEventListener('click', openCreateFolder);
1645
+ $('#folder-new-cancel').addEventListener('click', closeCreateFolder);
1646
+ $('#folder-new-create').addEventListener('click', createFolder);
1647
+ folderNewName.addEventListener('keydown', (event) => {
1648
+ if (event.key === 'Enter') {
1649
+ event.preventDefault();
1650
+ createFolder();
1651
+ } else if (event.key === 'Escape') {
1652
+ event.preventDefault();
1653
+ closeCreateFolder();
1654
+ }
1655
+ });
1656
+ $('#folder-roots').addEventListener('click', () => loadFolders(''));
1657
+ $('#folder-home').addEventListener('click', () => loadFolders(state.home || ''));
1658
+ $('#folder-close').addEventListener('click', closeFolderPicker);
1659
+ $('#folder-cancel').addEventListener('click', () => {
1660
+ state.pendingInput = null;
1661
+ closeFolderPicker();
1662
+ });
1663
+ $('#folder-up').addEventListener('click', () => loadFolders(folderParent ?? ''));
1664
+ $('#folder-use').addEventListener('click', useFolder);
1665
+ $('#folder-go').addEventListener('click', gotoFolder);
1666
+ folderGoto.addEventListener('input', filterFolders);
1667
+ folderGoto.addEventListener('keydown', (event) => {
1668
+ if (event.key === 'Enter') {
1669
+ event.preventDefault();
1670
+ gotoFolder();
1671
+ }
1672
+ });
1673
+ folderList.addEventListener('keydown', (event) => {
1674
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
1675
+ const items = [...folderList.querySelectorAll('.folder-item')];
1676
+ if (!items.length) return;
1677
+ event.preventDefault();
1678
+ const current = items.indexOf(document.activeElement);
1679
+ const next = event.key === 'ArrowDown' ? Math.min(items.length - 1, current + 1) : Math.max(0, current - 1);
1680
+ items[current === -1 ? 0 : next].focus();
1681
+ });
1682
+ folderModal.addEventListener('click', (event) => {
1683
+ if (event.target === folderModal) {
1684
+ state.pendingInput = null;
1685
+ closeFolderPicker();
1686
+ }
1687
+ });
1688
+
1689
+ /* --- providers ----------------------------------------------------------- */
1690
+
1691
+ const providerListEl = $('#provider-list');
1692
+ const providerForm = $('#provider-form');
1693
+ const providerModal = $('#provider-modal');
1694
+
1695
+ async function refreshProviders() {
1696
+ const data = await api('/api/providers').catch(() => null);
1697
+ if (!data) return;
1698
+ state.providers = data.providers;
1699
+ state.active = data.active;
1700
+ syncTopbar();
1701
+ renderProviderList();
1702
+ }
1703
+
1704
+ function isConfigured(p) {
1705
+ return Boolean(p.hasKey) || !p.requiresKey;
1706
+ }
1707
+
1708
+ function syncTopbar() {
1709
+ clear(providerSelect);
1710
+ const configured = state.providers.filter(isConfigured);
1711
+ for (const p of configured) {
1712
+ const option = document.createElement('option');
1713
+ option.value = p.id;
1714
+ option.textContent = p.label && p.label !== p.id ? `${p.label} (${p.id})` : p.id;
1715
+ providerSelect.appendChild(option);
1716
+ }
1717
+ const active = state.active.provider;
1718
+ if (active && !configured.some((p) => p.id === active)) {
1719
+ const current = state.providers.find((p) => p.id === active);
1720
+ const option = document.createElement('option');
1721
+ option.value = active;
1722
+ option.textContent = `${current?.label ?? active} (sin configurar)`;
1723
+ providerSelect.appendChild(option);
1724
+ }
1725
+ if (!providerSelect.options.length) {
1726
+ const option = document.createElement('option');
1727
+ option.value = '';
1728
+ option.textContent = 'sin proveedores configurados';
1729
+ providerSelect.appendChild(option);
1730
+ providerSelect.disabled = true;
1731
+ } else {
1732
+ providerSelect.disabled = false;
1733
+ if (active) providerSelect.value = active;
1734
+ }
1735
+ updateStatus();
1736
+ }
1737
+
1738
+ function renderProviderList() {
1739
+ clear(providerListEl);
1740
+ for (const p of state.providers) {
1741
+ const li = el('li', p.id === state.active.provider ? 'active' : '');
1742
+ li.appendChild(el('span', `pdot${p.hasKey || !p.requiresKey ? ' on' : ''}`));
1743
+ const main = el('div', 'grow');
1744
+ const title = el('div');
1745
+ title.append(el('span', 'pid', p.id), document.createTextNode(' '), el('span', 'pmeta', p.label));
1746
+ main.append(
1747
+ title,
1748
+ el(
1749
+ 'div',
1750
+ 'pmeta',
1751
+ `${p.baseUrl || p.defaultBaseUrl || 'sin base URL'} · ${p.models.length} modelos · ${p.apiKeyHint || (p.requiresKey ? 'sin key' : 'sin key requerida')}`
1752
+ )
1753
+ );
1754
+ li.appendChild(main);
1755
+ li.appendChild(button('usar', (event) => { event.stopPropagation(); useProvider(p.id); }));
1756
+ li.appendChild(button('editar', (event) => { event.stopPropagation(); openProviderForm(p); }));
1757
+ if (p.stored) li.appendChild(button('borrar', (event) => { event.stopPropagation(); removeProvider(p.id); }, 'btn danger'));
1758
+ li.addEventListener('click', () => openProviderForm(p));
1759
+ providerListEl.appendChild(li);
1760
+ }
1761
+ }
1762
+
1763
+ async function useProvider(id) {
1764
+ await saveSettings({ provider: id });
1765
+ await loadModels({ save: true });
1766
+ await refreshProviders();
1767
+ }
1768
+
1769
+ function openProviderForm(provider = null) {
1770
+ $('#provider-title').textContent = provider ? `editar ${provider.id}` : 'nuevo proveedor';
1771
+ $('#pf-id').value = provider?.id ?? '';
1772
+ $('#pf-id').readOnly = Boolean(provider);
1773
+ $('#pf-label').value = provider?.label ?? '';
1774
+ $('#pf-baseurl').value = provider?.baseUrl ?? '';
1775
+ $('#pf-key').value = '';
1776
+ $('#pf-key').placeholder = provider?.apiKeyHint ? `guardada (${provider.apiKeyHint})` : 'dejar vacío para no cambiar';
1777
+ $('#pf-models').value = (provider?.models ?? []).join(', ');
1778
+ setProviderStatus('');
1779
+ providerModal.showModal();
1780
+ if (!provider) $('#pf-id').focus();
1781
+ }
1782
+
1783
+ function closeProviderForm() {
1784
+ if (providerModal.open) providerModal.close();
1785
+ }
1786
+
1787
+ function setProviderStatus(text, cls = '') {
1788
+ const node = $('#pf-status');
1789
+ node.className = `summary ${cls}`.trim();
1790
+ node.textContent = text;
1791
+ }
1792
+
1793
+ async function submitProvider(event) {
1794
+ event.preventDefault();
1795
+ const id = $('#pf-id').value.trim();
1796
+ if (!id) return setProviderStatus('Falta el id.', 'err');
1797
+ const body = {
1798
+ label: $('#pf-label').value.trim() || id,
1799
+ baseUrl: $('#pf-baseurl').value.trim(),
1800
+ models: $('#pf-models').value.split(',').map((s) => s.trim()).filter(Boolean),
1801
+ };
1802
+ const key = $('#pf-key').value;
1803
+ if (key) body.apiKey = key;
1804
+ try {
1805
+ await api(`/api/providers/${encodeURIComponent(id)}`, { method: 'PUT', body });
1806
+ closeProviderForm();
1807
+ await refreshProviders();
1808
+ await loadModels();
1809
+ } catch (error) {
1810
+ setProviderStatus(error.message, 'err');
1811
+ }
1812
+ }
1813
+
1814
+ async function removeProvider(id) {
1815
+ if (!confirm(`¿Borrar la configuración de "${id}"?`)) return;
1816
+ await api(`/api/providers/${encodeURIComponent(id)}`, { method: 'DELETE' }).catch(() => null);
1817
+ await refreshProviders();
1818
+ await loadModels();
1819
+ }
1820
+
1821
+ async function testProvider() {
1822
+ const id = $('#pf-id').value.trim();
1823
+ if (!id) return setProviderStatus('Guardá primero el proveedor para probarlo.', 'err');
1824
+ setProviderStatus('probando…');
1825
+ const out = await api(`/api/providers/${encodeURIComponent(id)}/test`, { method: 'POST' }).catch((error) => ({
1826
+ ok: false,
1827
+ error: error.message,
1828
+ }));
1829
+ if (!out.ok) return setProviderStatus(`error: ${out.error}`, 'err');
1830
+
1831
+ const ids = (out.models ?? []).map((m) => m.id ?? m).filter(Boolean);
1832
+ let note = '';
1833
+ if (out.source === 'api' && ids.length) {
1834
+ $('#pf-models').value = ids.join(', ');
1835
+ note = ' · modelos autocompletados';
1836
+ }
1837
+ if (id === state.active.provider) await loadModels();
1838
+ setProviderStatus(`ok · ${out.count} modelos (${out.source})${note}`, 'ok');
1839
+ }
1840
+
1841
+ $('#provider-new').addEventListener('click', () => openProviderForm());
1842
+ $('#pf-cancel').addEventListener('click', closeProviderForm);
1843
+ $('#pf-close').addEventListener('click', closeProviderForm);
1844
+ $('#pf-test').addEventListener('click', testProvider);
1845
+ providerForm.addEventListener('submit', submitProvider);
1846
+ providerModal.addEventListener('click', (event) => {
1847
+ if (event.target === providerModal) closeProviderForm();
1848
+ });
1849
+
1850
+ /* --- eval ---------------------------------------------------------------- */
1851
+
1852
+ const evalTasks = $('#eval-tasks');
1853
+ const evalResults = $('#eval-results');
1854
+ const evalSummary = $('#eval-summary');
1855
+ const evalRunBtn = $('#eval-run');
1856
+
1857
+ evalTasks.value = [
1858
+ '{"id":"math","input":"¿Cuánto es 17 * 23? Respondé solo el número.","expect":{"equals":"391"}}',
1859
+ '{"id":"regex","input":"Escribí una oración que contenga un número.","expect":{"regex":"\\\\d"}}',
1860
+ ].join('\n');
1861
+
1862
+ function parseTasks(text) {
1863
+ const trimmed = text.trim();
1864
+ if (!trimmed) return [];
1865
+ try {
1866
+ const parsed = JSON.parse(trimmed);
1867
+ return Array.isArray(parsed) ? parsed : [parsed];
1868
+ } catch {
1869
+ // fall through to JSONL
1870
+ }
1871
+ return trimmed
1872
+ .split('\n')
1873
+ .map((l) => l.trim())
1874
+ .filter(Boolean)
1875
+ .map((l) => JSON.parse(l));
1876
+ }
1877
+
1878
+ function setEvalSummary(text, cls = '') {
1879
+ evalSummary.className = `summary ${cls}`.trim();
1880
+ evalSummary.textContent = text;
1881
+ }
1882
+
1883
+ function detailOf(entry) {
1884
+ if (entry.error) return entry.error;
1885
+ return (entry.trace?.scorers ?? []).map((s) => `${s.scorer}:${s.passed ? 'ok' : 'no'}`).join(' ');
1886
+ }
1887
+
1888
+ function addResultRow(tbody, entry) {
1889
+ const tr = el('tr');
1890
+ const pass = entry.passed;
1891
+ tr.append(
1892
+ el('td', pass ? 'pass' : 'fail', pass ? '✓' : '·'),
1893
+ el('td', '', entry.taskId ?? '—'),
1894
+ el('td', '', entry.model ?? '—'),
1895
+ el('td', '', entry.score === undefined || entry.score === null ? '' : String(entry.score)),
1896
+ el('td', '', entry.durationMs === undefined ? '' : String(entry.durationMs)),
1897
+ el('td', 'detail', detailOf(entry))
1898
+ );
1899
+ tbody.appendChild(tr);
1900
+ }
1901
+
1902
+ async function runEval() {
1903
+ let tasks;
1904
+ try {
1905
+ tasks = parseTasks(evalTasks.value);
1906
+ } catch (error) {
1907
+ return setEvalSummary(`Dataset inválido: ${error.message}`, 'err');
1908
+ }
1909
+ const models = $('#eval-models')
1910
+ .value.split(',')
1911
+ .map((s) => s.trim())
1912
+ .filter(Boolean);
1913
+ if (!tasks.length) return setEvalSummary('Ingresá al menos una tarea.', 'err');
1914
+ if (!models.length) return setEvalSummary('Ingresá al menos un modelo.', 'err');
1915
+
1916
+ clear(evalResults);
1917
+ evalRunBtn.disabled = true;
1918
+ setEvalSummary('corriendo…');
1919
+ try {
1920
+ await streamPost(
1921
+ '/api/eval',
1922
+ {
1923
+ tasks,
1924
+ models,
1925
+ provider: providerSelect.value || undefined,
1926
+ trials: Number($('#eval-trials').value) || 1,
1927
+ concurrency: Number($('#eval-concurrency').value) || 1,
1928
+ judgeModel: $('#eval-judge').value.trim() || undefined,
1929
+ yes: yesInput.checked ? true : undefined,
1930
+ },
1931
+ (event, data) => {
1932
+ if (event === 'run') setEvalSummary(`Run ${data.runId} · ${data.total} ejecuciones`);
1933
+ else if (event === 'result') addResultRow(evalResults, data);
1934
+ else if (event === 'summary') {
1935
+ const s = data.summary;
1936
+ setEvalSummary(`total ${s.total} · ${s.passed} ok · ${s.errors} errores · ${(s.durationMs / 1000).toFixed(1)}s`);
1937
+ } else if (event === 'error') setEvalSummary(`Error: ${data.message}`, 'err');
1938
+ }
1939
+ );
1940
+ } catch (error) {
1941
+ setEvalSummary(`Error: ${error.message}`, 'err');
1942
+ } finally {
1943
+ evalRunBtn.disabled = false;
1944
+ }
1945
+ }
1946
+ evalRunBtn.addEventListener('click', runEval);
1947
+
1948
+ /* --- runs ---------------------------------------------------------------- */
1949
+
1950
+ const runsList = $('#runs-list');
1951
+ const runDetail = $('#run-detail');
1952
+
1953
+ async function refreshRuns() {
1954
+ const data = await api('/api/runs').catch(() => ({ runs: [] }));
1955
+ clear(runsList);
1956
+ clear(runDetail);
1957
+ for (const run of data.runs) {
1958
+ const li = el('li');
1959
+ li.append(el('span', '', run.dataset || '(inline)'), el('span', 'muted', `${run.status} · ${new Date(run.created_at).toLocaleString()}`));
1960
+ li.addEventListener('click', () => {
1961
+ runsList.querySelectorAll('li').forEach((n) => n.classList.remove('active'));
1962
+ li.classList.add('active');
1963
+ openRun(run.id);
1964
+ });
1965
+ runsList.appendChild(li);
1966
+ }
1967
+ if (!data.runs.length) runsList.appendChild(el('li', 'muted', 'Sin runs.'));
1968
+ }
1969
+
1970
+ async function openRun(id) {
1971
+ const data = await api(`/api/runs/${id}`);
1972
+ clear(runDetail);
1973
+ const summary = data.run.summary ?? {};
1974
+ runDetail.appendChild(el('p', 'muted', `total ${summary.total ?? 0} · ${summary.passed ?? 0} ok · ${summary.errors ?? 0} errores`));
1975
+ const table = el('table', 'results');
1976
+ const thead = el('thead');
1977
+ const head = el('tr');
1978
+ for (const label of ['ok', 'tarea', 'modelo', 'score', 'ms', 'detalle']) head.appendChild(el('th', '', label));
1979
+ thead.appendChild(head);
1980
+ const tbody = el('tbody');
1981
+ table.append(thead, tbody);
1982
+ runDetail.appendChild(table);
1983
+ for (const row of data.results) {
1984
+ addResultRow(tbody, {
1985
+ taskId: row.task_id,
1986
+ model: row.model,
1987
+ passed: Boolean(row.passed),
1988
+ score: row.score,
1989
+ durationMs: row.duration_ms,
1990
+ trace: row.trace,
1991
+ });
1992
+ }
1993
+ }
1994
+ $('#runs-refresh').addEventListener('click', refreshRuns);
1995
+
1996
+ /* --- explorer (right panel) ----------------------------------------------- */
1997
+
1998
+ const explorer = $('#explorer');
1999
+ const explorerTree = $('#explorer-tree');
2000
+ const explorerRoot = $('#explorer-root');
2001
+ const explorerFilter = $('#explorer-filter');
2002
+ const explorerStatus = $('#explorer-status');
2003
+ const explorerToggle = $('#explorer-toggle');
2004
+
2005
+ function setExplorerStatus(text, isError = false) {
2006
+ explorerStatus.textContent = text;
2007
+ explorerStatus.classList.toggle('err', isError);
2008
+ }
2009
+
2010
+ function setExplorerOpen(open) {
2011
+ state.explorerOpen = open;
2012
+ explorer.hidden = !open;
2013
+ panels.explorer.resizer.hidden = !open;
2014
+ explorerToggle.setAttribute('aria-pressed', String(open));
2015
+ explorerToggle.classList.toggle('active', open);
2016
+ try {
2017
+ localStorage.setItem('tamnora-explorer-open', open ? '1' : '0');
2018
+ } catch {
2019
+ // storage unavailable
2020
+ }
2021
+ if (open) refreshExplorer();
2022
+ }
2023
+
2024
+ function fileListUrl(relPath) {
2025
+ const params = new URLSearchParams({ root: state.cwd, path: relPath });
2026
+ return `/api/files?${params}`;
2027
+ }
2028
+
2029
+ async function loadTreeDir(relPath, container) {
2030
+ clear(container);
2031
+ const placeholder = el('li', 'tree-empty muted', 'cargando…');
2032
+ container.appendChild(placeholder);
2033
+ const data = await api(fileListUrl(relPath)).catch((error) => {
2034
+ placeholder.textContent = error.message;
2035
+ return null;
2036
+ });
2037
+ if (!data) return;
2038
+ clear(container);
2039
+ if (!data.entries.length) {
2040
+ container.appendChild(el('li', 'tree-empty muted', '(vacío)'));
2041
+ return;
2042
+ }
2043
+ for (const entry of data.entries) container.appendChild(treeRow(entry, relPath));
2044
+ applyTreeFilter();
2045
+ }
2046
+
2047
+ function joinPath(base, name) {
2048
+ return base ? `${base}/${name}` : name;
2049
+ }
2050
+
2051
+ function treeRow(entry, parentPath) {
2052
+ const full = joinPath(parentPath, entry.name);
2053
+ const li = el('li');
2054
+ const row = el('div', `tree-row ${entry.type}`);
2055
+ const twisty = el('span', 'tw');
2056
+ const iconName = entry.type === 'dir' ? 'folder' : 'file';
2057
+ row.append(twisty, icon(iconName, 15, 'fi'), el('span', 'tree-name', entry.name));
2058
+ li.appendChild(row);
2059
+
2060
+ if (entry.type === 'dir') {
2061
+ twisty.appendChild(icon('chevron-right', 14));
2062
+ let children = null;
2063
+ row.addEventListener('click', async () => {
2064
+ const open = !li.classList.contains('open');
2065
+ li.classList.toggle('open', open);
2066
+ clear(twisty);
2067
+ twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
2068
+ if (open && !children) {
2069
+ children = el('ul', 'tree');
2070
+ li.appendChild(children);
2071
+ await loadTreeDir(full, children);
2072
+ }
2073
+ });
2074
+ } else {
2075
+ row.addEventListener('click', () => openFilePreview(full, entry.name));
2076
+ }
2077
+ return li;
2078
+ }
2079
+
2080
+ async function refreshExplorer() {
2081
+ if (!state.explorerOpen) return;
2082
+ explorerRoot.textContent = state.cwd || '—';
2083
+ explorerRoot.title = state.cwd || '';
2084
+ explorerFilter.value = '';
2085
+ if (!state.cwd) {
2086
+ clear(explorerTree);
2087
+ setExplorerStatus('sin carpeta de trabajo', true);
2088
+ return;
2089
+ }
2090
+ setExplorerStatus('cargando…');
2091
+ const data = await api(fileListUrl('')).catch((error) => {
2092
+ setExplorerStatus(error.message, true);
2093
+ return null;
2094
+ });
2095
+ if (!data) return;
2096
+ clear(explorerTree);
2097
+ if (!data.entries.length) explorerTree.appendChild(el('li', 'tree-empty muted', '(vacío)'));
2098
+ for (const entry of data.entries) explorerTree.appendChild(treeRow(entry, ''));
2099
+ const dirs = data.entries.filter((e) => e.type === 'dir').length;
2100
+ const files = data.entries.length - dirs;
2101
+ setExplorerStatus(`${files} archivos · ${dirs} carpetas`);
2102
+ }
2103
+
2104
+ function matchesFilter(name, query) {
2105
+ return name.toLowerCase().includes(query);
2106
+ }
2107
+
2108
+ function applyTreeFilter() {
2109
+ const query = explorerFilter.value.trim().toLowerCase();
2110
+ const walk = (ul) => {
2111
+ let visible = false;
2112
+ for (const li of ul.children) {
2113
+ if (!li.querySelector(':scope > .tree-row')) continue;
2114
+ const name = li.querySelector(':scope > .tree-row .tree-name')?.textContent ?? '';
2115
+ const childUl = li.querySelector(':scope > .tree');
2116
+ const childVisible = childUl ? walk(childUl) : false;
2117
+ const show = !query || matchesFilter(name, query) || childVisible;
2118
+ li.hidden = !show;
2119
+ if (show && query && childUl && childVisible && !li.classList.contains('open')) {
2120
+ li.classList.add('open');
2121
+ refreshTwisty(li, true);
2122
+ }
2123
+ visible = visible || show;
2124
+ }
2125
+ return visible;
2126
+ };
2127
+ walk(explorerTree);
2128
+ }
2129
+
2130
+ function refreshTwisty(li, open) {
2131
+ const twisty = li.querySelector(':scope > .tree-row .tw');
2132
+ if (!twisty) return;
2133
+ clear(twisty);
2134
+ twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
2135
+ }
2136
+
2137
+ async function openFilePreview(relPath, name) {
2138
+ const params = new URLSearchParams({ root: state.cwd, path: relPath });
2139
+ const data = await api(`/api/files/content?${params}`).catch((error) => {
2140
+ setExplorerStatus(error.message, true);
2141
+ return null;
2142
+ });
2143
+ if (!data) return;
2144
+ $('#file-title').textContent = name;
2145
+ $('#file-meta').textContent = `${data.size} bytes`;
2146
+ $('#file-content').textContent = data.content;
2147
+ $('#file-modal').showModal();
2148
+ }
2149
+
2150
+ explorerToggle.addEventListener('click', () => setExplorerOpen(!state.explorerOpen));
2151
+ $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
2152
+ $('#explorer-refresh').addEventListener('click', refreshExplorer);
2153
+ explorerFilter.addEventListener('input', applyTreeFilter);
2154
+ $('#file-close').addEventListener('click', () => $('#file-modal').close());
2155
+ $('#file-modal').addEventListener('click', (event) => {
2156
+ if (event.target === $('#file-modal')) $('#file-modal').close();
2157
+ });
2158
+
2159
+ try {
2160
+ setExplorerOpen(localStorage.getItem('tamnora-explorer-open') === '1');
2161
+ } catch {
2162
+ setExplorerOpen(false);
2163
+ }
2164
+
2165
+ /* --- boot ---------------------------------------------------------------- */
2166
+
2167
+ async function boot() {
2168
+ const auth = await api('/api/auth/me').catch(() => null);
2169
+ if (auth?.enabled && !auth.authenticated) {
2170
+ location.replace('/landing');
2171
+ return;
2172
+ }
2173
+ csrfToken = auth?.csrf || '';
2174
+ document.documentElement.dataset.auth = 'ok';
2175
+
2176
+ const health = await api('/api/health').catch(() => null);
2177
+ if (health?.cwd) {
2178
+ document.title = `Tamnora Console · ${health.cwd}`;
2179
+ setCwd(health.cwd);
2180
+ }
2181
+ if (health?.home) state.home = health.home;
2182
+ await refreshProviders();
2183
+ await loadModes();
2184
+ await loadModels();
2185
+ await refreshSessions();
2186
+ await restoreTabs();
2187
+ }
2188
+ boot();