@danieltmn/tamnora 0.6.2 → 0.7.1

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 (34) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/SECURITY.md +6 -0
  3. package/harness/packages/cli/src/commands/repl.js +2 -0
  4. package/harness/packages/cli/src/commands/run.js +2 -0
  5. package/harness/packages/cli/src/commands/tui.js +2 -0
  6. package/harness/packages/cli/src/lib/runtime.js +4 -0
  7. package/harness/packages/cli/src/tui/app.js +4 -0
  8. package/harness/packages/core/src/agent/runAgent.js +131 -47
  9. package/harness/packages/core/src/config/index.js +3 -1
  10. package/harness/packages/core/src/config/secrets.js +36 -0
  11. package/harness/packages/core/src/eval/runTask.js +2 -11
  12. package/harness/packages/core/src/index.js +13 -1
  13. package/harness/packages/core/src/mcp/common.js +15 -11
  14. package/harness/packages/core/src/mcp/index.js +1 -1
  15. package/harness/packages/core/src/mcp/registry.js +1 -0
  16. package/harness/packages/core/src/mcp/stdio.js +41 -2
  17. package/harness/packages/core/src/redact.js +104 -0
  18. package/harness/packages/core/test/agent.test.js +93 -0
  19. package/harness/packages/core/test/mcp.test.js +67 -0
  20. package/harness/packages/core/test/redact.test.js +45 -0
  21. package/harness/packages/server/public/app.js +368 -41
  22. package/harness/packages/server/public/index.html +102 -26
  23. package/harness/packages/server/public/markdown.js +43 -0
  24. package/harness/packages/server/public/styles.css +171 -21
  25. package/harness/packages/server/src/lib/agentTools.js +9 -3
  26. package/harness/packages/server/src/lib/channels/telegram.js +11 -5
  27. package/harness/packages/server/src/routes/chat.js +8 -1
  28. package/harness/packages/server/src/routes/mcp.js +29 -5
  29. package/harness/packages/server/src/routes/providers.js +16 -15
  30. package/harness/packages/server/src/server.js +13 -0
  31. package/harness/packages/server/test/markdown.test.js +33 -0
  32. package/harness/packages/server/test/server.test.js +77 -1
  33. package/harness/tamnora.example.json +17 -1
  34. package/package.json +1 -1
@@ -58,6 +58,7 @@ const ICON_PATHS = {
58
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
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
60
  trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
61
+ 'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
61
62
  };
62
63
 
63
64
  function icon(name, size = 16, className = '') {
@@ -178,7 +179,7 @@ function activateTab(name) {
178
179
  hideMoreMenu();
179
180
  if (name === 'runs') refreshRuns();
180
181
  if (name === 'providers') refreshProviders();
181
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); }
182
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); }
182
183
  }
183
184
 
184
185
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -203,27 +204,61 @@ document.addEventListener('keydown', (event) => {
203
204
  /* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
204
205
 
205
206
  const settingsModal = $('#settings-modal');
207
+ const agentControls = $('#agent-settings-controls');
208
+ const agentControlsSlot = $('#agent-controls-slot');
209
+ const settingsControlsSlot = $('#settings-controls-slot');
210
+
211
+ /** Los controles modo/proveedor/modelo/auto se comparten entre el tab Agente
212
+ * y el modal: se mueven de un slot al otro para no duplicar IDs ni estado. */
213
+ function moveAgentControls(target) {
214
+ if (agentControls && target) target.appendChild(agentControls);
215
+ }
216
+
217
+ function openSettingsModal() {
218
+ if (!settingsModal) return;
219
+ moveAgentControls(settingsControlsSlot);
220
+ syncDefaultCwd();
221
+ settingsModal.showModal();
222
+ }
223
+
224
+ function closeSettingsModal() {
225
+ moveAgentControls(agentControlsSlot);
226
+ settingsModal?.close();
227
+ }
228
+
206
229
  if (settingsModal) {
207
- $('#settings-toggle')?.addEventListener('click', () => {
208
- syncDefaultCwd();
209
- settingsModal.showModal();
210
- });
211
- $('#settings-close')?.addEventListener('click', () => settingsModal.close());
212
- $('#settings-done')?.addEventListener('click', () => settingsModal.close());
230
+ $('#settings-toggle')?.addEventListener('click', openSettingsModal);
231
+ $('#settings-close')?.addEventListener('click', closeSettingsModal);
232
+ $('#settings-done')?.addEventListener('click', closeSettingsModal);
213
233
  $('#settings-providers')?.addEventListener('click', () => {
214
- settingsModal.close();
234
+ closeSettingsModal();
215
235
  activateTab('providers');
216
236
  });
217
- $('#default-cwd-pick')?.addEventListener('click', () => {
218
- settingsModal.close();
219
- openFolderPicker('default');
220
- });
237
+ $('#agent-settings-open')?.addEventListener('click', openSettingsModal);
238
+ $('#agent-providers')?.addEventListener('click', () => activateTab('providers'));
239
+ $('#default-cwd-pick')?.addEventListener('click', () => openFolderPicker('default'));
221
240
  $('#default-cwd-clear')?.addEventListener('click', () => {
222
241
  writeDefaultCwd('');
223
242
  syncDefaultCwd();
224
243
  });
225
244
  settingsModal.addEventListener('click', (event) => {
226
- if (event.target === settingsModal) settingsModal.close();
245
+ if (event.target === settingsModal) closeSettingsModal();
246
+ });
247
+ settingsModal.addEventListener('close', () => moveAgentControls(agentControlsSlot));
248
+ }
249
+
250
+ // El footer (modo · proveedor · modelo · estado) abre el modal de ajustes.
251
+ const statusbarEl = $('#statusbar');
252
+ if (statusbarEl) {
253
+ statusbarEl.addEventListener('click', (event) => {
254
+ if (event.target.closest('#sb-context')) return;
255
+ openSettingsModal();
256
+ });
257
+ statusbarEl.addEventListener('keydown', (event) => {
258
+ if (event.key === 'Enter' || event.key === ' ') {
259
+ event.preventDefault();
260
+ openSettingsModal();
261
+ }
227
262
  });
228
263
  }
229
264
 
@@ -235,7 +270,7 @@ const PALETTE_COMMANDS = [
235
270
  { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
236
271
  { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
237
272
  { id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
238
- { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => { syncDefaultCwd(); settingsModal?.showModal(); } },
273
+ { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => openSettingsModal() },
239
274
  { id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('providers') },
240
275
  { id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
241
276
  { id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
@@ -489,11 +524,13 @@ function persistLayout() {
489
524
  } catch {
490
525
  // storage unavailable
491
526
  }
527
+ scheduleScrollNav();
492
528
  }
493
529
 
494
530
  function applyPanelWidths() {
495
531
  panels.sidebar.el.style.width = `${panels.sidebar.width}px`;
496
532
  panels.explorer.el.style.width = `${panels.explorer.width}px`;
533
+ scheduleScrollNav();
497
534
  }
498
535
 
499
536
  function attachResizer(panel) {
@@ -575,6 +612,10 @@ function initLayout() {
575
612
  mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
576
613
  }
577
614
 
615
+ // `scrollNavFrame` debe existir antes de `initLayout()`: `applyPanelWidths`
616
+ // llama a `scheduleScrollNav()` durante el arranque (evita el TDZ).
617
+ let scrollNavFrame = 0;
618
+
578
619
  initLayout();
579
620
 
580
621
  function formatK(n) {
@@ -699,12 +740,50 @@ const controllers = new Map();
699
740
  const sessionsEl = $('#sessions');
700
741
 
701
742
  let activePane = blankPane;
743
+ let restorePreviewMax = null;
702
744
  const paneOf = (message) => message?.bubble?.closest('.messages') ?? activePane;
703
745
 
704
746
  const scrollMessages = (pane = activePane) => {
705
747
  if (pane) pane.scrollTop = pane.scrollHeight;
706
748
  };
707
749
 
750
+ const scrollNav = $('#scroll-nav');
751
+ const scrollTopBtn = $('#scroll-top');
752
+ const scrollDownBtn = $('#scroll-down');
753
+
754
+ /** Muestra/oculta los saltos de scroll según el pane activo. */
755
+ function updateScrollNav() {
756
+ const pane = activePane;
757
+ if (!pane) return;
758
+ const max = pane.scrollHeight - pane.clientHeight;
759
+ const visible = max > 40;
760
+ scrollNav.hidden = !visible;
761
+ if (!visible) return;
762
+ scrollTopBtn.disabled = pane.scrollTop < 40;
763
+ scrollDownBtn.disabled = max - pane.scrollTop < 40;
764
+ }
765
+
766
+ function scheduleScrollNav() {
767
+ if (scrollNavFrame) return;
768
+ scrollNavFrame = requestAnimationFrame(() => {
769
+ scrollNavFrame = 0;
770
+ updateScrollNav();
771
+ });
772
+ }
773
+
774
+ scrollTopBtn.addEventListener('click', () => activePane?.scrollTo({ top: 0, behavior: 'smooth' }));
775
+ scrollDownBtn.addEventListener('click', () =>
776
+ activePane?.scrollTo({ top: activePane.scrollHeight, behavior: 'smooth' })
777
+ );
778
+ // `scroll` no burbujea: se escucha en captura desde el contenedor de los panes.
779
+ panesEl.addEventListener('scroll', scheduleScrollNav, true);
780
+ window.addEventListener('resize', scheduleScrollNav);
781
+ new MutationObserver(scheduleScrollNav).observe(panesEl, {
782
+ childList: true,
783
+ characterData: true,
784
+ subtree: true,
785
+ });
786
+
708
787
  function addMessage(role, text = '', pane = activePane) {
709
788
  pane.querySelector('.empty')?.remove();
710
789
  const wrap = el('div', `msg ${role}`);
@@ -721,12 +800,10 @@ function addMessage(role, text = '', pane = activePane) {
721
800
  const bubble = el('div', 'bubble');
722
801
  const textEl = el('div', 'text');
723
802
  const tools = el('div', 'tools');
724
- const activity = el('div', 'activity');
725
- activity.hidden = true;
726
- bubble.append(textEl, tools, activity);
803
+ bubble.append(textEl, tools);
727
804
  wrap.append(label, bubble);
728
805
  pane.appendChild(wrap);
729
- const message = { role, wrap, bubble, label, modeEl, textEl, tools, activity, reasoningEl: null, id: null };
806
+ const message = { role, wrap, bubble, label, modeEl, textEl, tools, reasoningEl: null, id: null };
730
807
  addMessageActions(message);
731
808
  if (text) setText(message, text);
732
809
  scrollMessages(pane);
@@ -741,18 +818,79 @@ function setMessageMode(message, modeId) {
741
818
  message.modeEl.hidden = false;
742
819
  }
743
820
 
821
+ let openMessageMenu = null;
822
+
823
+ /** El menu flota (position: fixed) para no quedar recortado por el scroll. */
824
+ function positionMessageMenu() {
825
+ if (!openMessageMenu) return;
826
+ const { menu, trigger } = openMessageMenu;
827
+ const rect = trigger.getBoundingClientRect();
828
+ const size = menu.getBoundingClientRect();
829
+ let top = rect.bottom + 4;
830
+ if (top + size.height > window.innerHeight - 8) top = Math.max(8, rect.top - size.height - 4);
831
+ let left = rect.left;
832
+ if (left + size.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - size.width - 8);
833
+ menu.style.top = `${top}px`;
834
+ menu.style.left = `${left}px`;
835
+ }
836
+
837
+ function closeMessageMenus() {
838
+ if (!openMessageMenu) return;
839
+ const current = openMessageMenu;
840
+ openMessageMenu = null;
841
+ current.menu.hidden = true;
842
+ current.trigger.setAttribute('aria-expanded', 'false');
843
+ current.actions?.appendChild(current.menu);
844
+ }
845
+
846
+ function toggleMessageMenu(message) {
847
+ const wasOpen = openMessageMenu === message;
848
+ closeMessageMenus();
849
+ if (wasOpen) return;
850
+ document.body.appendChild(message.menu);
851
+ message.menu.hidden = false;
852
+ message.trigger.setAttribute('aria-expanded', 'true');
853
+ openMessageMenu = message;
854
+ positionMessageMenu();
855
+ }
856
+
857
+ document.addEventListener('click', (event) => {
858
+ if (!openMessageMenu) return;
859
+ if (event.target.closest('.msg-menu') || event.target.closest('.msg-menu-btn')) return;
860
+ closeMessageMenus();
861
+ });
862
+ document.addEventListener('keydown', (event) => {
863
+ if (event.key === 'Escape') closeMessageMenus();
864
+ });
865
+ window.addEventListener('resize', positionMessageMenu);
866
+ document.addEventListener('scroll', positionMessageMenu, true);
867
+
744
868
  function addMessageActions(message) {
745
869
  const actions = el('div', 'msg-actions');
746
- actions.appendChild(button('copiar', () => copyMessage(message), 'msg-act'));
747
- if (message.role === 'assistant') {
748
- actions.appendChild(button('regenerar', () => regenerate(message), 'msg-act'));
749
- }
750
- if (message.role === 'user') {
751
- actions.appendChild(button('editar', () => editMessage(message), 'msg-act'));
752
- }
753
- actions.appendChild(button('borrar desde acá', () => deleteFrom(message), 'msg-act danger'));
870
+ const trigger = button('Opciones...', () => toggleMessageMenu(message), 'msg-menu-btn');
871
+ trigger.title = 'opciones';
872
+ trigger.setAttribute('aria-haspopup', 'menu');
873
+ trigger.setAttribute('aria-expanded', 'false');
874
+ trigger.setAttribute('aria-label', 'opciones del mensaje');
875
+
876
+ const menu = el('div', 'msg-menu');
877
+ menu.hidden = true;
878
+ const addItem = (label, run, cls = 'msg-act') => {
879
+ menu.appendChild(button(label, () => {
880
+ closeMessageMenus();
881
+ run();
882
+ }, cls));
883
+ };
884
+ addItem('copiar', () => copyMessage(message));
885
+ if (message.role === 'assistant') addItem('regenerar', () => regenerate(message));
886
+ if (message.role === 'user') addItem('editar', () => editMessage(message));
887
+ addItem('borrar desde acá', () => deleteFrom(message), 'msg-act danger');
888
+
889
+ actions.append(trigger, menu);
754
890
  message.bubble.appendChild(actions);
755
891
  message.actions = actions;
892
+ message.menu = menu;
893
+ message.trigger = trigger;
756
894
  }
757
895
 
758
896
  function messageId(message) {
@@ -823,17 +961,25 @@ const ACTIVITY = {
823
961
  waiting: { cls: 'loader-thinking', label: 'esperando aprobación…' },
824
962
  };
825
963
 
826
- function setActivity(message, kind) {
827
- if (!message.activity) return;
964
+ const footerActivityEl = $('#sb-activity');
965
+
966
+ /** El indicador de "pensando/trabajando" vive en el footer (una sola vez). */
967
+ function renderFooterActivity(kind) {
968
+ if (!footerActivityEl) return;
828
969
  if (!kind) {
829
- message.activity.hidden = true;
830
- message.activity.replaceChildren();
970
+ footerActivityEl.hidden = true;
971
+ footerActivityEl.replaceChildren();
831
972
  return;
832
973
  }
833
- const def = ACTIVITY[kind];
834
- message.activity.hidden = false;
835
- message.activity.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
836
- scrollMessages(paneOf(message));
974
+ const def = ACTIVITY[kind] ?? ACTIVITY.thinking;
975
+ footerActivityEl.hidden = false;
976
+ footerActivityEl.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
977
+ }
978
+
979
+ function setActivity(message, kind) {
980
+ // Solo el turno de la sesion activa refleja su actividad en el footer.
981
+ if (paneOf(message) !== activePane) return;
982
+ renderFooterActivity(kind);
837
983
  }
838
984
 
839
985
  function setText(message, text) {
@@ -1302,6 +1448,8 @@ function showPane(id) {
1302
1448
  for (const item of state.panes.values()) item.el.hidden = item.id !== id;
1303
1449
  blankPane.hidden = Boolean(record);
1304
1450
  activePane = record ? record.el : blankPane;
1451
+ renderFooterActivity(record?.working ? 'thinking' : null);
1452
+ scheduleScrollNav();
1305
1453
  }
1306
1454
 
1307
1455
  function renderTabs() {
@@ -1361,8 +1509,9 @@ function activateSession(id) {
1361
1509
 
1362
1510
  function auditStatus(record) {
1363
1511
  submitBtn.disabled = record.working;
1364
- $('#sb-rounds').textContent = record.working ? 'pensando…' : 'listo';
1512
+ $('#sb-rounds').textContent = record.working ? '' : 'listo';
1365
1513
  $('#sb-rounds').classList.toggle('running', record.working);
1514
+ renderFooterActivity(record.working ? 'thinking' : null);
1366
1515
  }
1367
1516
 
1368
1517
  function closeTab(id) {
@@ -1403,8 +1552,9 @@ function setWorking(id, working) {
1403
1552
  if (id === state.sessionId) {
1404
1553
  submitBtn.disabled = working;
1405
1554
  if (stopBtn) stopBtn.hidden = !working;
1406
- $('#sb-rounds').textContent = working ? 'pensando.' : 'listo';
1555
+ $('#sb-rounds').textContent = working ? '' : 'listo';
1407
1556
  $('#sb-rounds').classList.toggle('running', working);
1557
+ renderFooterActivity(working ? 'thinking' : null);
1408
1558
  }
1409
1559
  }
1410
1560
 
@@ -1545,11 +1695,25 @@ function renderCompletion(message, data) {
1545
1695
  const duration = formatDuration(data.durationMs);
1546
1696
  if (duration) parts.push(duration);
1547
1697
  if (data.tokens?.total_tokens) parts.push(`${Number(data.tokens.total_tokens).toLocaleString('es')} tokens`);
1548
- const maxed = data.stopReason === 'max_rounds';
1549
- message.tools.appendChild(el('div', maxed ? 'chip warn' : 'chip ok', `${maxed ? '⚠ máximo de rondas' : '✓ terminado'} · ${parts.join(' · ')}`));
1698
+ const cut = ['max_rounds', 'no_progress', 'budget'].includes(data.stopReason);
1699
+ const label = cut ? (data.wrappedUp ? '⚠ corte de seguridad · resumen' : '⚠ corte de seguridad') : '✓ terminado';
1700
+ message.tools.appendChild(el('div', cut ? 'chip warn' : 'chip ok', `${label} · ${parts.join(' · ')}`));
1550
1701
  scrollMessages(paneOf(message));
1551
1702
  }
1552
1703
 
1704
+ /** Retoma un turno cortado por límite/bucle sin tener que escribir "continúa". */
1705
+ function addContinueButton(turn, data) {
1706
+ const btn = el('button', 'chip', data.wrappedUp ? '↻ Continuar el trabajo' : '↻ Continuar');
1707
+ btn.type = 'button';
1708
+ btn.addEventListener('click', () => {
1709
+ if (state.panes.get(turn.sessionKey)?.working) return;
1710
+ chatInput.value = 'Continuá donde quedaste.';
1711
+ sendMessage();
1712
+ });
1713
+ turn.assistant().tools.appendChild(btn);
1714
+ scrollMessages(paneOf(turn.assistant()));
1715
+ }
1716
+
1553
1717
  function handleChatEvent(event, data, turn) {
1554
1718
  if (handleAgentEvent(event, data, turn)) return;
1555
1719
  if (event === 'agent_start') {
@@ -1615,6 +1779,8 @@ function handleChatEvent(event, data, turn) {
1615
1779
  if (!turn.sawText) setText(turn.assistant(), `↻ reintentando (${data.attempt}) en ${Math.round((data.delayMs || 0) / 1000)} s...`);
1616
1780
  } else if (event === 'trim') {
1617
1781
  turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1782
+ } else if (event === 'repeat') {
1783
+ turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
1618
1784
  } else if (event === 'warning') {
1619
1785
  turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1620
1786
  } else if (event === 'error') {
@@ -1625,12 +1791,16 @@ function handleChatEvent(event, data, turn) {
1625
1791
  const finalText = (turn.active?.textEl?.textContent ?? '').trim();
1626
1792
  const tools = Number(data.toolCalls) || 0;
1627
1793
  if (!finalText) {
1628
- if (data.stopReason === 'max_rounds') {
1794
+ if (data.stopReason === 'no_progress') {
1795
+ setText(turn.assistant(), '⚠ El agente se quedó repitiendo las mismas herramientas sin avanzar.');
1796
+ } else if (data.stopReason === 'budget') {
1797
+ setText(turn.assistant(), '⚠ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.');
1798
+ } else if (data.stopReason === 'max_rounds') {
1629
1799
  setText(
1630
1800
  turn.assistant(),
1631
1801
  `⚠ El agente alcanzó el máximo de rondas (${data.rounds ?? '?'}) sin dejar una respuesta final.${
1632
1802
  tools ? ` Hizo ${tools} llamadas a herramientas.` : ''
1633
- } Pedile que continúe o subí \`maxRounds\`.`
1803
+ }`
1634
1804
  );
1635
1805
  } else if (tools) {
1636
1806
  setText(turn.assistant(), '✓ El agente terminó sus herramientas, pero no devolvió un mensaje final.');
@@ -1639,6 +1809,7 @@ function handleChatEvent(event, data, turn) {
1639
1809
  }
1640
1810
  }
1641
1811
  renderCompletion(turn.active, data);
1812
+ if (data.continuable) addContinueButton(turn, data);
1642
1813
  updateStatus({ rounds: data.rounds, stopReason: data.stopReason });
1643
1814
  addTokens(data.tokens);
1644
1815
  } else if (event === 'aborted') {
@@ -1679,6 +1850,7 @@ async function attachRun(sessionKey, pane) {
1679
1850
  async function sendMessage() {
1680
1851
  const current = state.panes.get(state.sessionId);
1681
1852
  if (current?.working) return;
1853
+ hideSlashMenu();
1682
1854
  let input = chatInput.value.trim();
1683
1855
  const images = state.attachments.slice();
1684
1856
  if (input.startsWith('/') && !images.length) {
@@ -1778,11 +1950,13 @@ chatForm.addEventListener('submit', (event) => {
1778
1950
  sendMessage();
1779
1951
  });
1780
1952
  chatInput.addEventListener('keydown', (event) => {
1953
+ if (handleSlashKey(event)) return;
1781
1954
  if (event.key === 'Enter' && !event.shiftKey) {
1782
1955
  event.preventDefault();
1783
1956
  sendMessage();
1784
1957
  }
1785
1958
  });
1959
+ chatInput.addEventListener('input', onChatInputChanged);
1786
1960
 
1787
1961
  /* --- image attachments ---------------------------------------------------- */
1788
1962
 
@@ -1956,6 +2130,99 @@ async function runCommand(name, arg) {
1956
2130
  }
1957
2131
  }
1958
2132
 
2133
+ /* --- menú de comandos "/" ------------------------------------------------- */
2134
+
2135
+ const slashMenuEl = $('#slash-menu');
2136
+ let slashMatches = [];
2137
+ let slashIndex = 0;
2138
+
2139
+ function slashCommandEntries() {
2140
+ return COMMANDS.map(([cmd, desc]) => {
2141
+ const name = cmd.split(' ')[0];
2142
+ const takesArgs = cmd.includes(' ');
2143
+ return { name, desc, insert: takesArgs ? `${name} ` : name, takesArgs };
2144
+ });
2145
+ }
2146
+
2147
+ function hideSlashMenu() {
2148
+ if (!slashMenuEl) return;
2149
+ slashMenuEl.hidden = true;
2150
+ slashMenuEl.replaceChildren();
2151
+ slashMatches = [];
2152
+ slashIndex = 0;
2153
+ }
2154
+
2155
+ function renderSlashMenu() {
2156
+ if (!slashMenuEl) return;
2157
+ slashMenuEl.replaceChildren();
2158
+ slashMatches.forEach((entry, index) => {
2159
+ const item = el('button', `slash-item${index === slashIndex ? ' active' : ''}`);
2160
+ item.type = 'button';
2161
+ item.append(el('span', 'slash-cmd', entry.name), el('span', 'slash-desc', entry.desc));
2162
+ // mousedown (no click) para no perder el foco del textarea antes de elegir.
2163
+ item.addEventListener('mousedown', (event) => {
2164
+ event.preventDefault();
2165
+ chooseSlash(index);
2166
+ });
2167
+ slashMenuEl.appendChild(item);
2168
+ });
2169
+ slashMenuEl.hidden = false;
2170
+ }
2171
+
2172
+ function updateSlashMenu() {
2173
+ if (!slashMenuEl) return;
2174
+ const value = chatInput.value;
2175
+ if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
2176
+ const query = value.slice(1).toLowerCase();
2177
+ slashMatches = slashCommandEntries().filter((entry) => entry.name.slice(1).toLowerCase().startsWith(query));
2178
+ if (!slashMatches.length) return hideSlashMenu();
2179
+ if (slashIndex >= slashMatches.length) slashIndex = 0;
2180
+ renderSlashMenu();
2181
+ }
2182
+
2183
+ function chooseSlash(index) {
2184
+ const entry = slashMatches[index];
2185
+ if (!entry) return;
2186
+ chatInput.value = entry.insert;
2187
+ chatInput.focus();
2188
+ hideSlashMenu();
2189
+ if (!entry.takesArgs) sendMessage();
2190
+ }
2191
+
2192
+ function handleSlashKey(event) {
2193
+ if (!slashMenuEl || slashMenuEl.hidden || !slashMatches.length) return false;
2194
+ if (event.key === 'ArrowDown') {
2195
+ event.preventDefault();
2196
+ slashIndex = (slashIndex + 1) % slashMatches.length;
2197
+ renderSlashMenu();
2198
+ return true;
2199
+ }
2200
+ if (event.key === 'ArrowUp') {
2201
+ event.preventDefault();
2202
+ slashIndex = (slashIndex - 1 + slashMatches.length) % slashMatches.length;
2203
+ renderSlashMenu();
2204
+ return true;
2205
+ }
2206
+ if (event.key === 'Escape') {
2207
+ event.preventDefault();
2208
+ hideSlashMenu();
2209
+ return true;
2210
+ }
2211
+ if (event.key === 'Enter' || event.key === 'Tab') {
2212
+ event.preventDefault();
2213
+ chooseSlash(slashIndex);
2214
+ return true;
2215
+ }
2216
+ return false;
2217
+ }
2218
+
2219
+ function onChatInputChanged() {
2220
+ slashIndex = 0;
2221
+ updateSlashMenu();
2222
+ }
2223
+
2224
+ chatInput.addEventListener('blur', () => setTimeout(hideSlashMenu, 120));
2225
+
1959
2226
  /* --- folder picker -------------------------------------------------------- */
1960
2227
 
1961
2228
  const folderModal = $('#folder-modal');
@@ -2402,6 +2669,50 @@ const tgTranscribeEl = $('#tg-transcribe');
2402
2669
  const tgTranscribeProviderEl = $('#tg-transcribe-provider');
2403
2670
  const tgStatusEl = $('#tg-status');
2404
2671
 
2672
+ /* --- ajustes: pestañas internas + límites del agente --------------------- */
2673
+
2674
+ const ajustesTabs = document.querySelectorAll('.ajustes-tab');
2675
+
2676
+ function activateAjustesPanel(name) {
2677
+ ajustesTabs.forEach((tab) => tab.classList.toggle('active', tab.dataset.panel === name));
2678
+ document
2679
+ .querySelectorAll('.ajustes-panel')
2680
+ .forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === name));
2681
+ if (name === 'agente') moveAgentControls(agentControlsSlot);
2682
+ }
2683
+
2684
+ ajustesTabs.forEach((tab) =>
2685
+ tab.addEventListener('click', () => activateAjustesPanel(tab.dataset.panel))
2686
+ );
2687
+
2688
+ const limitsRoundsEl = $('#limits-rounds');
2689
+ const limitsTokensEl = $('#limits-tokens');
2690
+ const limitsDurationEl = $('#limits-duration');
2691
+
2692
+ async function refreshLimits() {
2693
+ const data = await api('/api/settings').catch(() => null);
2694
+ const settings = data?.settings;
2695
+ if (!settings) return;
2696
+ if (limitsRoundsEl) limitsRoundsEl.value = settings.maxRounds ?? 0;
2697
+ if (limitsTokensEl) limitsTokensEl.value = settings.maxTokens ?? 0;
2698
+ if (limitsDurationEl) limitsDurationEl.value = Math.round((settings.maxDurationMs ?? 0) / 1000);
2699
+ }
2700
+
2701
+ $('#limits-save')?.addEventListener('click', async () => {
2702
+ const body = {
2703
+ maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
2704
+ maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
2705
+ maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
2706
+ };
2707
+ try {
2708
+ await api('/api/settings', { method: 'PUT', body });
2709
+ refreshLimits();
2710
+ setStatus('#limits-msg', 'guardado', 'ok');
2711
+ } catch (error) {
2712
+ setStatus('#limits-msg', error.message, 'err');
2713
+ }
2714
+ });
2715
+
2405
2716
  function setStatus(node, text, cls = '') {
2406
2717
  const elNode = $(node);
2407
2718
  if (!elNode) return;
@@ -2443,6 +2754,8 @@ function syncMcpFormFields() {
2443
2754
  const http = $('#mf-transport').value === 'http';
2444
2755
  $('#mf-command-field').hidden = http;
2445
2756
  $('#mf-args-field').hidden = http;
2757
+ $('#mf-env-field').hidden = http;
2758
+ $('#mf-inherit-field').hidden = http;
2446
2759
  $('#mf-url-field').hidden = !http;
2447
2760
  }
2448
2761
 
@@ -2458,6 +2771,7 @@ function openMcpForm(server = null) {
2458
2771
  $('#mf-env').value = Object.entries(server?.env ?? {})
2459
2772
  .map(([key, value]) => `${key}=${value ?? ''}`)
2460
2773
  .join('\n');
2774
+ $('#mf-inherit-env').checked = server?.inheritEnv === true;
2461
2775
  $('#mf-enabled').checked = server ? server.enabled !== false : true;
2462
2776
  syncMcpFormFields();
2463
2777
  setStatus('#mf-status', '');
@@ -2517,6 +2831,7 @@ async function submitMcp(event) {
2517
2831
  args: $('#mf-args').value.trim(),
2518
2832
  url: $('#mf-url').value.trim(),
2519
2833
  env: parseEnv($('#mf-env').value),
2834
+ inheritEnv: $('#mf-inherit-env').checked,
2520
2835
  enabled: $('#mf-enabled').checked,
2521
2836
  };
2522
2837
  try {
@@ -2897,6 +3212,7 @@ function setExplorerOpen(open) {
2897
3212
  state.explorerOpen = open;
2898
3213
  explorer.hidden = !open;
2899
3214
  panels.explorer.resizer.hidden = !open;
3215
+ if (!open) restorePreviewMax?.();
2900
3216
  const handle = $('#explorer-handle');
2901
3217
  if (handle) {
2902
3218
  handle.hidden = open;
@@ -3164,14 +3480,23 @@ function setPreviewStatus(text, isError = false) {
3164
3480
  }
3165
3481
 
3166
3482
  /* Ancho por dispositivo + maximizar, compartido por preview y visor de archivos. */
3167
- function setupPreviewTools({ container, stage, frame, select, maxButton }) {
3483
+ function setupPreviewTools({ container, stage, frame, select, maxButton, teleport = false }) {
3168
3484
  const applyDevice = () => {
3169
3485
  const value = select?.value || 'auto';
3170
3486
  if (stage) stage.dataset.device = value;
3171
3487
  frame.style.width = value === 'auto' ? '100%' : `${value}px`;
3172
3488
  };
3173
3489
  select?.addEventListener('change', applyDevice);
3490
+ // El panel del explorador usa `backdrop-filter`, que crea un containing block
3491
+ // y dejaria el `position: fixed` encerrado en la barra. Al maximizar movemos
3492
+ // el contenedor a `body` y lo devolvemos a su lugar al restaurar.
3493
+ const home = teleport ? container?.parentNode : null;
3494
+ const homeAfter = teleport ? container?.nextSibling : null;
3174
3495
  const setMax = (on) => {
3496
+ if (teleport && container && home) {
3497
+ if (on) document.body.appendChild(container);
3498
+ else if (!home.contains(container)) home.insertBefore(container, homeAfter ?? null);
3499
+ }
3175
3500
  container?.classList.toggle('preview-maximized', on);
3176
3501
  maxButton?.setAttribute('aria-pressed', String(on));
3177
3502
  if (maxButton) maxButton.title = on ? 'restaurar' : 'maximizar';
@@ -3189,7 +3514,9 @@ const previewTools = setupPreviewTools({
3189
3514
  frame: previewFrame,
3190
3515
  select: $('#preview-device'),
3191
3516
  maxButton: $('#preview-max'),
3517
+ teleport: true,
3192
3518
  });
3519
+ restorePreviewMax = () => previewTools.setMax(false);
3193
3520
  previewTools.applyDevice();
3194
3521
 
3195
3522
  function loadPreview(url) {