@danieltmn/tamnora 0.7.0 → 0.8.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.
@@ -20,13 +20,14 @@ const state = {
20
20
  tabOrder: [],
21
21
  activeTab: null,
22
22
  collapsedGroups: new Set(),
23
+ queues: new Map(),
23
24
  };
24
25
 
25
26
  function setCwd(value) {
26
27
  if (!value) return;
27
28
  state.cwd = value;
28
29
  $('#rail-cwd').textContent = value;
29
- if (state.explorerOpen) refreshExplorer();
30
+ if (state.explorerOpen) refreshExplorerMode();
30
31
  }
31
32
 
32
33
  /* --- DOM helpers --------------------------------------------------------- */
@@ -58,6 +59,9 @@ const ICON_PATHS = {
58
59
  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
60
  '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
  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'],
62
+ 'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
63
+ copy: ['M8 8V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3', 'M5 8h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z'],
64
+ pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
61
65
  };
62
66
 
63
67
  function icon(name, size = 16, className = '') {
@@ -523,11 +527,13 @@ function persistLayout() {
523
527
  } catch {
524
528
  // storage unavailable
525
529
  }
530
+ scheduleScrollNav();
526
531
  }
527
532
 
528
533
  function applyPanelWidths() {
529
534
  panels.sidebar.el.style.width = `${panels.sidebar.width}px`;
530
535
  panels.explorer.el.style.width = `${panels.explorer.width}px`;
536
+ scheduleScrollNav();
531
537
  }
532
538
 
533
539
  function attachResizer(panel) {
@@ -609,6 +615,10 @@ function initLayout() {
609
615
  mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
610
616
  }
611
617
 
618
+ // `scrollNavFrame` debe existir antes de `initLayout()`: `applyPanelWidths`
619
+ // llama a `scheduleScrollNav()` durante el arranque (evita el TDZ).
620
+ let scrollNavFrame = 0;
621
+
612
622
  initLayout();
613
623
 
614
624
  function formatK(n) {
@@ -627,11 +637,13 @@ function renderTokens() {
627
637
  const lastTurn = record?.lastTurn ?? { prompt: 0, completion: 0 };
628
638
  const total = tokens.in + tokens.out;
629
639
  const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
630
- const used = lastTurn.prompt + lastTurn.completion || total;
631
- const pct = context ? Math.min(100, Math.round((used / context) * 100)) : 0;
640
+ // El contexto es la ultima ronda (prompt+completion), no el acumulado del run.
641
+ const used = lastTurn.prompt + lastTurn.completion;
642
+ const hasContext = context > 0 && used > 0;
643
+ const pct = hasContext ? Math.min(100, Math.round((used / context) * 100)) : 0;
632
644
  const tone = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
633
645
 
634
- if (!total) {
646
+ if (!total && !used) {
635
647
  if (wrap) wrap.className = 'ctx muted';
636
648
  node.textContent = 'tokens —';
637
649
  node.title = '';
@@ -640,16 +652,30 @@ function renderTokens() {
640
652
  }
641
653
  const parts =
642
654
  `entrada ${formatK(tokens.in)} · salida ${formatK(tokens.out)}` +
643
- (context ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
644
- if (context) {
655
+ (hasContext ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
656
+ if (hasContext) {
645
657
  node.textContent = `contexto ${pct}% · ${formatK(used)}/${formatK(context)}`;
646
658
  if (wrap) wrap.className = `ctx ${tone}`;
659
+ } else if (context) {
660
+ node.textContent = `contexto — · ${formatK(context)}`;
661
+ if (wrap) wrap.className = 'ctx muted';
647
662
  } else {
648
663
  node.textContent = `tokens ${formatK(total)}`;
649
664
  if (wrap) wrap.className = 'ctx muted';
650
665
  }
651
666
  node.title = parts;
652
- if (fill) fill.style.width = `${context ? pct : 0}%`;
667
+ if (fill) fill.style.width = `${hasContext ? pct : 0}%`;
668
+ }
669
+
670
+ function setContextUsage(tokens) {
671
+ const record = state.panes.get(state.sessionId);
672
+ if (record && tokens) {
673
+ record.lastTurn = {
674
+ prompt: tokens.last_prompt_tokens ?? 0,
675
+ completion: tokens.last_completion_tokens ?? 0,
676
+ };
677
+ renderTokens();
678
+ }
653
679
  }
654
680
 
655
681
  function addTokens(tokens) {
@@ -733,12 +759,50 @@ const controllers = new Map();
733
759
  const sessionsEl = $('#sessions');
734
760
 
735
761
  let activePane = blankPane;
762
+ let restorePreviewMax = null;
736
763
  const paneOf = (message) => message?.bubble?.closest('.messages') ?? activePane;
737
764
 
738
765
  const scrollMessages = (pane = activePane) => {
739
766
  if (pane) pane.scrollTop = pane.scrollHeight;
740
767
  };
741
768
 
769
+ const scrollNav = $('#scroll-nav');
770
+ const scrollTopBtn = $('#scroll-top');
771
+ const scrollDownBtn = $('#scroll-down');
772
+
773
+ /** Muestra/oculta los saltos de scroll según el pane activo. */
774
+ function updateScrollNav() {
775
+ const pane = activePane;
776
+ if (!pane) return;
777
+ const max = pane.scrollHeight - pane.clientHeight;
778
+ const visible = max > 40;
779
+ scrollNav.hidden = !visible;
780
+ if (!visible) return;
781
+ scrollTopBtn.disabled = pane.scrollTop < 40;
782
+ scrollDownBtn.disabled = max - pane.scrollTop < 40;
783
+ }
784
+
785
+ function scheduleScrollNav() {
786
+ if (scrollNavFrame) return;
787
+ scrollNavFrame = requestAnimationFrame(() => {
788
+ scrollNavFrame = 0;
789
+ updateScrollNav();
790
+ });
791
+ }
792
+
793
+ scrollTopBtn.addEventListener('click', () => activePane?.scrollTo({ top: 0, behavior: 'smooth' }));
794
+ scrollDownBtn.addEventListener('click', () =>
795
+ activePane?.scrollTo({ top: activePane.scrollHeight, behavior: 'smooth' })
796
+ );
797
+ // `scroll` no burbujea: se escucha en captura desde el contenedor de los panes.
798
+ panesEl.addEventListener('scroll', scheduleScrollNav, true);
799
+ window.addEventListener('resize', scheduleScrollNav);
800
+ new MutationObserver(scheduleScrollNav).observe(panesEl, {
801
+ childList: true,
802
+ characterData: true,
803
+ subtree: true,
804
+ });
805
+
742
806
  function addMessage(role, text = '', pane = activePane) {
743
807
  pane.querySelector('.empty')?.remove();
744
808
  const wrap = el('div', `msg ${role}`);
@@ -755,12 +819,10 @@ function addMessage(role, text = '', pane = activePane) {
755
819
  const bubble = el('div', 'bubble');
756
820
  const textEl = el('div', 'text');
757
821
  const tools = el('div', 'tools');
758
- const activity = el('div', 'activity');
759
- activity.hidden = true;
760
- bubble.append(textEl, tools, activity);
822
+ bubble.append(textEl, tools);
761
823
  wrap.append(label, bubble);
762
824
  pane.appendChild(wrap);
763
- const message = { role, wrap, bubble, label, modeEl, textEl, tools, activity, reasoningEl: null, id: null };
825
+ const message = { role, wrap, bubble, label, modeEl, textEl, tools, reasoningEl: null, id: null };
764
826
  addMessageActions(message);
765
827
  if (text) setText(message, text);
766
828
  scrollMessages(pane);
@@ -775,18 +837,90 @@ function setMessageMode(message, modeId) {
775
837
  message.modeEl.hidden = false;
776
838
  }
777
839
 
840
+ let openMessageMenu = null;
841
+
842
+ /** El menu flota (position: fixed) para no quedar recortado por el scroll. */
843
+ function positionMessageMenu() {
844
+ if (!openMessageMenu) return;
845
+ const { menu, trigger } = openMessageMenu;
846
+ const rect = trigger.getBoundingClientRect();
847
+ const size = menu.getBoundingClientRect();
848
+ let top = rect.bottom + 4;
849
+ if (top + size.height > window.innerHeight - 8) top = Math.max(8, rect.top - size.height - 4);
850
+ let left = rect.left;
851
+ if (left + size.width > window.innerWidth - 8) left = Math.max(8, window.innerWidth - size.width - 8);
852
+ menu.style.top = `${top}px`;
853
+ menu.style.left = `${left}px`;
854
+ }
855
+
856
+ function closeMessageMenus() {
857
+ if (!openMessageMenu) return;
858
+ const current = openMessageMenu;
859
+ openMessageMenu = null;
860
+ current.menu.hidden = true;
861
+ current.trigger.setAttribute('aria-expanded', 'false');
862
+ current.actions?.appendChild(current.menu);
863
+ }
864
+
865
+ function toggleMessageMenu(message) {
866
+ const wasOpen = openMessageMenu === message;
867
+ closeMessageMenus();
868
+ if (wasOpen) return;
869
+ document.body.appendChild(message.menu);
870
+ message.menu.hidden = false;
871
+ message.trigger.setAttribute('aria-expanded', 'true');
872
+ openMessageMenu = message;
873
+ positionMessageMenu();
874
+ }
875
+
876
+ document.addEventListener('click', (event) => {
877
+ if (!openMessageMenu) return;
878
+ if (event.target.closest('.msg-menu') || event.target.closest('.msg-menu-btn')) return;
879
+ closeMessageMenus();
880
+ });
881
+ document.addEventListener('keydown', (event) => {
882
+ if (event.key === 'Escape') closeMessageMenus();
883
+ });
884
+ window.addEventListener('resize', positionMessageMenu);
885
+ document.addEventListener('scroll', positionMessageMenu, true);
886
+
778
887
  function addMessageActions(message) {
779
888
  const actions = el('div', 'msg-actions');
780
- actions.appendChild(button('copiar', () => copyMessage(message), 'msg-act'));
781
- if (message.role === 'assistant') {
782
- actions.appendChild(button('regenerar', () => regenerate(message), 'msg-act'));
783
- }
784
- if (message.role === 'user') {
785
- actions.appendChild(button('editar', () => editMessage(message), 'msg-act'));
786
- }
787
- actions.appendChild(button('borrar desde acá', () => deleteFrom(message), 'msg-act danger'));
889
+ const trigger = el('button', 'msg-menu-btn');
890
+ trigger.type = 'button';
891
+ trigger.appendChild(icon('dots-vertical', 16));
892
+ trigger.title = 'opciones del mensaje';
893
+ trigger.setAttribute('aria-haspopup', 'menu');
894
+ trigger.setAttribute('aria-expanded', 'false');
895
+ trigger.setAttribute('aria-label', 'opciones del mensaje');
896
+ trigger.addEventListener('click', () => toggleMessageMenu(message));
897
+
898
+ const menu = el('div', 'msg-menu');
899
+ menu.hidden = true;
900
+ menu.setAttribute('role', 'menu');
901
+ const addItem = (label, run, iconName, cls = 'msg-act') => {
902
+ const item = el('button', cls);
903
+ item.type = 'button';
904
+ item.setAttribute('role', 'menuitem');
905
+ if (iconName) item.appendChild(icon(iconName, 15, 'mi'));
906
+ item.appendChild(el('span', 'mlabel', label));
907
+ item.addEventListener('click', () => {
908
+ closeMessageMenus();
909
+ run();
910
+ });
911
+ menu.appendChild(item);
912
+ };
913
+ addItem('copiar', () => copyMessage(message), 'copy');
914
+ if (message.role === 'assistant') addItem('regenerar', () => regenerate(message), 'refresh-cw');
915
+ if (message.role === 'user') addItem('editar', () => editMessage(message), 'pencil');
916
+ menu.appendChild(el('div', 'msg-menu-sep'));
917
+ addItem('borrar desde acá', () => deleteFrom(message), 'trash', 'msg-act danger');
918
+
919
+ actions.append(trigger, menu);
788
920
  message.bubble.appendChild(actions);
789
921
  message.actions = actions;
922
+ message.menu = menu;
923
+ message.trigger = trigger;
790
924
  }
791
925
 
792
926
  function messageId(message) {
@@ -857,17 +991,25 @@ const ACTIVITY = {
857
991
  waiting: { cls: 'loader-thinking', label: 'esperando aprobación…' },
858
992
  };
859
993
 
860
- function setActivity(message, kind) {
861
- if (!message.activity) return;
994
+ const footerActivityEl = $('#sb-activity');
995
+
996
+ /** El indicador de "pensando/trabajando" vive en el footer (una sola vez). */
997
+ function renderFooterActivity(kind) {
998
+ if (!footerActivityEl) return;
862
999
  if (!kind) {
863
- message.activity.hidden = true;
864
- message.activity.replaceChildren();
1000
+ footerActivityEl.hidden = true;
1001
+ footerActivityEl.replaceChildren();
865
1002
  return;
866
1003
  }
867
- const def = ACTIVITY[kind];
868
- message.activity.hidden = false;
869
- message.activity.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
870
- scrollMessages(paneOf(message));
1004
+ const def = ACTIVITY[kind] ?? ACTIVITY.thinking;
1005
+ footerActivityEl.hidden = false;
1006
+ footerActivityEl.replaceChildren(el('span', `loader ${def.cls}`), el('span', 'activity-label', def.label));
1007
+ }
1008
+
1009
+ function setActivity(message, kind) {
1010
+ // Solo el turno de la sesion activa refleja su actividad en el footer.
1011
+ if (paneOf(message) !== activePane) return;
1012
+ renderFooterActivity(kind);
871
1013
  }
872
1014
 
873
1015
  function setText(message, text) {
@@ -1336,6 +1478,8 @@ function showPane(id) {
1336
1478
  for (const item of state.panes.values()) item.el.hidden = item.id !== id;
1337
1479
  blankPane.hidden = Boolean(record);
1338
1480
  activePane = record ? record.el : blankPane;
1481
+ renderFooterActivity(record?.working ? 'thinking' : null);
1482
+ scheduleScrollNav();
1339
1483
  }
1340
1484
 
1341
1485
  function renderTabs() {
@@ -1387,16 +1531,25 @@ function activateSession(id) {
1387
1531
  auditStatus(record);
1388
1532
  updateStatus();
1389
1533
  renderTokens();
1534
+ renderQueue(id);
1390
1535
  renderTabs();
1391
1536
  refreshSessions();
1392
1537
  persistTabs();
1393
1538
  chatInput.focus();
1394
1539
  }
1395
1540
 
1541
+ function updateSubmitLabel(working) {
1542
+ if (!submitBtn) return;
1543
+ submitBtn.textContent = working ? 'encolar' : 'enviar';
1544
+ submitBtn.title = working ? 'El agente está trabajando: se envía al terminar' : '';
1545
+ }
1546
+
1396
1547
  function auditStatus(record) {
1397
- submitBtn.disabled = record.working;
1398
- $('#sb-rounds').textContent = record.working ? 'pensando…' : 'listo';
1548
+ submitBtn.disabled = false;
1549
+ updateSubmitLabel(record.working);
1550
+ $('#sb-rounds').textContent = record.working ? '' : 'listo';
1399
1551
  $('#sb-rounds').classList.toggle('running', record.working);
1552
+ renderFooterActivity(record.working ? 'thinking' : null);
1400
1553
  }
1401
1554
 
1402
1555
  function closeTab(id) {
@@ -1404,6 +1557,7 @@ function closeTab(id) {
1404
1557
  if (!record) return;
1405
1558
  record.el.remove();
1406
1559
  state.panes.delete(id);
1560
+ state.queues.delete(id);
1407
1561
  state.tabOrder = state.tabOrder.filter((item) => item !== id);
1408
1562
  if (state.activeTab === id) {
1409
1563
  const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
@@ -1428,6 +1582,7 @@ function refreshTokensDefaults() {
1428
1582
  $('#sb-rounds').textContent = 'listo';
1429
1583
  $('#sb-rounds').classList.remove('running');
1430
1584
  submitBtn.disabled = false;
1585
+ updateSubmitLabel(false);
1431
1586
  }
1432
1587
 
1433
1588
  function setWorking(id, working) {
@@ -1435,10 +1590,12 @@ function setWorking(id, working) {
1435
1590
  if (record) record.working = working;
1436
1591
  renderTabs();
1437
1592
  if (id === state.sessionId) {
1438
- submitBtn.disabled = working;
1593
+ submitBtn.disabled = false;
1594
+ updateSubmitLabel(working);
1439
1595
  if (stopBtn) stopBtn.hidden = !working;
1440
- $('#sb-rounds').textContent = working ? 'pensando.' : 'listo';
1596
+ $('#sb-rounds').textContent = working ? '' : 'listo';
1441
1597
  $('#sb-rounds').classList.toggle('running', working);
1598
+ renderFooterActivity(working ? 'thinking' : null);
1442
1599
  }
1443
1600
  }
1444
1601
 
@@ -1665,6 +1822,8 @@ function handleChatEvent(event, data, turn) {
1665
1822
  turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1666
1823
  } else if (event === 'repeat') {
1667
1824
  turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
1825
+ } else if (event === 'usage') {
1826
+ setContextUsage(data.tokens);
1668
1827
  } else if (event === 'warning') {
1669
1828
  turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1670
1829
  } else if (event === 'error') {
@@ -1731,46 +1890,14 @@ async function attachRun(sessionKey, pane) {
1731
1890
  }
1732
1891
  }
1733
1892
 
1734
- async function sendMessage() {
1735
- const current = state.panes.get(state.sessionId);
1736
- if (current?.working) return;
1737
- hideSlashMenu();
1738
- let input = chatInput.value.trim();
1739
- const images = state.attachments.slice();
1740
- if (input.startsWith('/') && !images.length) {
1741
- const name = input.slice(1).split(/\s+/)[0].toLowerCase();
1742
- const arg = input.slice(1 + name.length).trim();
1743
- const outcome = await runCommand(name, arg);
1744
- if (!outcome || outcome.handled) {
1745
- chatInput.value = '';
1746
- return;
1747
- }
1748
- input = outcome.input ?? input;
1749
- }
1750
- if (!input && !images.length) return;
1751
- if (!state.sessionId) {
1752
- state.pendingInput = input;
1753
- openFolderPicker();
1754
- return;
1755
- }
1756
-
1757
- if (state.pendingEdit) {
1758
- await api(`/api/sessions/${state.sessionId}/truncate`, {
1759
- method: 'POST',
1760
- body: { fromId: state.pendingEdit.fromId },
1761
- }).catch(() => {});
1762
- await reloadPane(state.sessionId);
1763
- state.pendingEdit = null;
1764
- }
1893
+ function contextTokensForRequest() {
1894
+ const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
1895
+ return context ? Math.floor(context * 0.8) : undefined;
1896
+ }
1765
1897
 
1766
- const pane = activePane;
1767
- const sessionKey = state.sessionId;
1898
+ async function startTurn(sessionKey, pane, input, images) {
1768
1899
  const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
1769
1900
  appendImages(user, images);
1770
- chatInput.value = '';
1771
- state.attachments = [];
1772
- renderAttachments();
1773
-
1774
1901
  const turn = createChatTurn(pane, sessionKey);
1775
1902
  turn.active = addMessage('assistant', '', pane);
1776
1903
  setMessageMode(turn.active, turn.turnMode);
@@ -1784,15 +1911,12 @@ async function sendMessage() {
1784
1911
  {
1785
1912
  input: input || '(imagen adjunta)',
1786
1913
  images: images.length ? images : undefined,
1787
- sessionId: state.sessionId || undefined,
1914
+ sessionId: sessionKey,
1788
1915
  provider: providerSelect.value || undefined,
1789
1916
  model: modelSelect.value || undefined,
1790
1917
  mode: modeSelect.value || undefined,
1791
1918
  yes: yesInput.checked ? true : undefined,
1792
- contextTokens: (() => {
1793
- const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
1794
- return context ? Math.floor(context * 0.8) : undefined;
1795
- })(),
1919
+ contextTokens: contextTokensForRequest(),
1796
1920
  },
1797
1921
  (event, data) => handleChatEvent(event, data, turn),
1798
1922
  controller.signal
@@ -1817,7 +1941,140 @@ async function sendMessage() {
1817
1941
  setActivity(turn.active, null);
1818
1942
  setWorking(sessionKey, false);
1819
1943
  refreshSessions();
1944
+ drainQueue(sessionKey);
1945
+ }
1946
+ }
1947
+
1948
+ /* --- cola de mensajes (enviar mientras el agente trabaja) ----------------- */
1949
+
1950
+ function queueFor(sessionKey, create = false) {
1951
+ if (!state.queues.has(sessionKey)) {
1952
+ if (!create) return [];
1953
+ state.queues.set(sessionKey, []);
1954
+ }
1955
+ return state.queues.get(sessionKey);
1956
+ }
1957
+
1958
+ function enqueueMessage(sessionKey, input, images) {
1959
+ queueFor(sessionKey, true).push({ id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, input, images });
1960
+ renderQueue(sessionKey);
1961
+ }
1962
+
1963
+ function renderQueue(sessionKey = state.sessionId) {
1964
+ const queueEl = $('#chat-queue');
1965
+ if (!queueEl || sessionKey !== state.sessionId) return;
1966
+ clear(queueEl);
1967
+ const items = queueFor(sessionKey);
1968
+ queueEl.hidden = !items.length;
1969
+ if (!items.length) return;
1970
+ queueEl.appendChild(el('div', 'queue-label', `en cola (${items.length})`));
1971
+ for (const item of items) {
1972
+ const row = el('div', 'queue-item');
1973
+ const text = item.input || `(${item.images.length} imagen${item.images.length > 1 ? 'es' : ''})`;
1974
+ row.append(el('span', 'queue-text', text));
1975
+ row.appendChild(
1976
+ button('✕', () => {
1977
+ const list = queueFor(sessionKey);
1978
+ const index = list.findIndex((entry) => entry.id === item.id);
1979
+ if (index >= 0) list.splice(index, 1);
1980
+ renderQueue(sessionKey);
1981
+ }, 'queue-remove')
1982
+ );
1983
+ queueEl.appendChild(row);
1984
+ }
1985
+ }
1986
+
1987
+ function drainQueue(sessionKey) {
1988
+ const record = state.panes.get(sessionKey);
1989
+ if (!record || record.working) return;
1990
+ const list = queueFor(sessionKey);
1991
+ if (!list.length) return;
1992
+ const next = list.shift();
1993
+ renderQueue(sessionKey);
1994
+ startTurn(sessionKey, record.el, next.input, next.images);
1995
+ }
1996
+
1997
+ /* --- shell directo con "!" ------------------------------------------------ */
1998
+
1999
+ async function runShell(command) {
2000
+ const record = state.panes.get(state.sessionId);
2001
+ const pane = activePane ?? record?.el;
2002
+ if (!pane) return;
2003
+ addMessage('user', `! ${command}`, pane);
2004
+ const message = addMessage('assistant', '', pane);
2005
+ message.textEl.appendChild(el('pre', 'shell-out', 'ejecutando…'));
2006
+ const data = await api('/api/shell', {
2007
+ method: 'POST',
2008
+ body: { command, cwd: record?.cwd || state.cwd || undefined },
2009
+ }).catch((error) => ({ error: error.message }));
2010
+ if (data?.error) {
2011
+ message.textEl.textContent = '';
2012
+ message.textEl.classList.add('notice', 'err');
2013
+ message.textEl.textContent = data.error;
2014
+ return;
1820
2015
  }
2016
+ message.textEl.textContent = '';
2017
+ const pre = el('pre', 'shell-out');
2018
+ pre.textContent = (data.output || '').trim() || '(sin salida)';
2019
+ message.textEl.appendChild(pre);
2020
+ scheduleScrollNav();
2021
+ }
2022
+
2023
+ async function sendMessage() {
2024
+ const current = state.panes.get(state.sessionId);
2025
+ hideSlashMenu();
2026
+ let input = chatInput.value.trim();
2027
+ const images = state.attachments.slice();
2028
+
2029
+ // "!" ejecuta un comando de shell directo, sin pasar por el modelo.
2030
+ if (input.startsWith('!')) {
2031
+ const command = input.slice(1).trim();
2032
+ chatInput.value = '';
2033
+ state.attachments = [];
2034
+ renderAttachments();
2035
+ if (command) runShell(command);
2036
+ return;
2037
+ }
2038
+
2039
+ if (input.startsWith('/') && !images.length) {
2040
+ const name = input.slice(1).split(/\s+/)[0].toLowerCase();
2041
+ const arg = input.slice(1 + name.length).trim();
2042
+ const outcome = await runCommand(name, arg);
2043
+ if (!outcome || outcome.handled) {
2044
+ chatInput.value = '';
2045
+ return;
2046
+ }
2047
+ input = outcome.input ?? input;
2048
+ }
2049
+ if (!input && !images.length) return;
2050
+ if (!state.sessionId) {
2051
+ state.pendingInput = input;
2052
+ openFolderPicker();
2053
+ return;
2054
+ }
2055
+
2056
+ // Si el agente está trabajando, el mensaje queda en cola y se envía solo.
2057
+ if (current?.working) {
2058
+ enqueueMessage(state.sessionId, input, images);
2059
+ chatInput.value = '';
2060
+ state.attachments = [];
2061
+ renderAttachments();
2062
+ return;
2063
+ }
2064
+
2065
+ if (state.pendingEdit) {
2066
+ await api(`/api/sessions/${state.sessionId}/truncate`, {
2067
+ method: 'POST',
2068
+ body: { fromId: state.pendingEdit.fromId },
2069
+ }).catch(() => {});
2070
+ await reloadPane(state.sessionId);
2071
+ state.pendingEdit = null;
2072
+ }
2073
+
2074
+ chatInput.value = '';
2075
+ state.attachments = [];
2076
+ renderAttachments();
2077
+ await startTurn(state.sessionId, activePane, input, images);
1821
2078
  }
1822
2079
 
1823
2080
  if (stopBtn) {
@@ -1937,6 +2194,7 @@ const COMMANDS = [
1937
2194
  ['/compact', 'resumir la conversación y liberar contexto'],
1938
2195
  ['/clear', 'borrar los mensajes de esta sesión'],
1939
2196
  ['/cwd', 'mostrar la carpeta de trabajo'],
2197
+ ['/kill <puerto>', 'matar el proceso que escucha en un puerto'],
1940
2198
  ['/help', 'mostrar esta ayuda'],
1941
2199
  ];
1942
2200
 
@@ -1977,6 +2235,21 @@ async function runCommand(name, arg) {
1977
2235
  notify(`Carpeta de trabajo: \`${cwd}\``);
1978
2236
  return { handled: true };
1979
2237
  }
2238
+ case 'kill': {
2239
+ const port = Number(arg);
2240
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
2241
+ notify('Uso: `/kill <puerto>`, por ejemplo `/kill 3000`.', 'notice err');
2242
+ return { handled: true };
2243
+ }
2244
+ const out = await api('/api/procs/kill', { method: 'POST', body: { port } }).catch((error) => ({
2245
+ error: error.message,
2246
+ }));
2247
+ if (out.error) notify(`No se pudo matar el puerto ${port}: ${out.error}`, 'notice err');
2248
+ else if (!out.pids.length) notify(`No hay ningún proceso escuchando en el puerto ${port}.`);
2249
+ else if (out.killed.length) notify(`Puerto ${port}: maté el PID ${out.killed.join(', ')}.`);
2250
+ else notify(`Puerto ${port}: no se pudo matar (PID ${out.errors.join(', ')}).`, 'notice err');
2251
+ return { handled: true };
2252
+ }
1980
2253
  case 'clear':
1981
2254
  case 'limpiar': {
1982
2255
  if (!state.sessionId) return needSession();
@@ -3096,6 +3369,7 @@ function setExplorerOpen(open) {
3096
3369
  state.explorerOpen = open;
3097
3370
  explorer.hidden = !open;
3098
3371
  panels.explorer.resizer.hidden = !open;
3372
+ if (!open) restorePreviewMax?.();
3099
3373
  const handle = $('#explorer-handle');
3100
3374
  if (handle) {
3101
3375
  handle.hidden = open;
@@ -3333,7 +3607,7 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
3333
3607
 
3334
3608
  /* --- explorer panes: estructura / preview / procs ------------------------- */
3335
3609
 
3336
- const xpPanes = { files: $('#xp-files'), preview: $('#xp-preview'), procs: $('#xp-procs') };
3610
+ const xpPanes = { files: $('#xp-files'), preview: $('#xp-preview'), procs: $('#xp-procs'), git: $('#xp-git') };
3337
3611
  let explorerMode = 'files';
3338
3612
 
3339
3613
  function setExplorerMode(mode) {
@@ -3347,6 +3621,7 @@ function refreshExplorerMode() {
3347
3621
  if (!state.explorerOpen) return;
3348
3622
  if (explorerMode === 'files') refreshExplorer();
3349
3623
  else if (explorerMode === 'procs') detectProcs();
3624
+ else if (explorerMode === 'git') refreshGit();
3350
3625
  }
3351
3626
 
3352
3627
  document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
@@ -3363,14 +3638,23 @@ function setPreviewStatus(text, isError = false) {
3363
3638
  }
3364
3639
 
3365
3640
  /* Ancho por dispositivo + maximizar, compartido por preview y visor de archivos. */
3366
- function setupPreviewTools({ container, stage, frame, select, maxButton }) {
3641
+ function setupPreviewTools({ container, stage, frame, select, maxButton, teleport = false }) {
3367
3642
  const applyDevice = () => {
3368
3643
  const value = select?.value || 'auto';
3369
3644
  if (stage) stage.dataset.device = value;
3370
3645
  frame.style.width = value === 'auto' ? '100%' : `${value}px`;
3371
3646
  };
3372
3647
  select?.addEventListener('change', applyDevice);
3648
+ // El panel del explorador usa `backdrop-filter`, que crea un containing block
3649
+ // y dejaria el `position: fixed` encerrado en la barra. Al maximizar movemos
3650
+ // el contenedor a `body` y lo devolvemos a su lugar al restaurar.
3651
+ const home = teleport ? container?.parentNode : null;
3652
+ const homeAfter = teleport ? container?.nextSibling : null;
3373
3653
  const setMax = (on) => {
3654
+ if (teleport && container && home) {
3655
+ if (on) document.body.appendChild(container);
3656
+ else if (!home.contains(container)) home.insertBefore(container, homeAfter ?? null);
3657
+ }
3374
3658
  container?.classList.toggle('preview-maximized', on);
3375
3659
  maxButton?.setAttribute('aria-pressed', String(on));
3376
3660
  if (maxButton) maxButton.title = on ? 'restaurar' : 'maximizar';
@@ -3388,7 +3672,9 @@ const previewTools = setupPreviewTools({
3388
3672
  frame: previewFrame,
3389
3673
  select: $('#preview-device'),
3390
3674
  maxButton: $('#preview-max'),
3675
+ teleport: true,
3391
3676
  });
3677
+ restorePreviewMax = () => previewTools.setMax(false);
3392
3678
  previewTools.applyDevice();
3393
3679
 
3394
3680
  function loadPreview(url) {
@@ -3497,6 +3783,150 @@ async function refreshProcLog() {
3497
3783
 
3498
3784
  $('#procs-detect')?.addEventListener('click', detectProcs);
3499
3785
 
3786
+ /* git: estado del árbol de trabajo y diffs */
3787
+
3788
+ const gitListEl = $('#git-list');
3789
+ const gitBranchEl = $('#git-branch');
3790
+ const gitSyncEl = $('#git-sync');
3791
+ const gitStatusEl = $('#git-status');
3792
+ const gitFilterEl = $('#git-filter');
3793
+ const gitModalEl = $('#git-modal');
3794
+ const gitDiffEl = $('#git-diff');
3795
+ const gitTitleEl = $('#git-title');
3796
+ const gitMetaEl = $('#git-meta');
3797
+ let gitEntries = [];
3798
+
3799
+ const GIT_BADGES = {
3800
+ modified: { letter: 'M', cls: 'mod' },
3801
+ added: { letter: 'A', cls: 'add' },
3802
+ deleted: { letter: 'D', cls: 'del' },
3803
+ renamed: { letter: 'R', cls: 'ren' },
3804
+ untracked: { letter: 'U', cls: 'new' },
3805
+ conflicted: { letter: 'C', cls: 'conf' },
3806
+ ignored: { letter: 'I', cls: 'ign' },
3807
+ };
3808
+
3809
+ function setGitStatus(text, isError = false) {
3810
+ gitStatusEl.textContent = text;
3811
+ gitStatusEl.classList.toggle('err', isError);
3812
+ }
3813
+
3814
+ function gitGroup(entry) {
3815
+ const staged = entry.index !== ' ' && entry.index !== '?' && entry.index !== '!';
3816
+ const changed =
3817
+ entry.status === 'untracked' ||
3818
+ (entry.worktree !== ' ' && entry.worktree !== '?' && entry.worktree !== '!');
3819
+ return { staged, changed };
3820
+ }
3821
+
3822
+ async function refreshGit() {
3823
+ clear(gitListEl);
3824
+ gitBranchEl.textContent = '…';
3825
+ gitSyncEl.textContent = '';
3826
+ if (!state.cwd) return setGitStatus('sin carpeta de trabajo', true);
3827
+ setGitStatus('cargando…');
3828
+ const data = await api(`/api/git/status?cwd=${encodeURIComponent(state.cwd)}`).catch((error) => {
3829
+ setGitStatus(error.message, true);
3830
+ return null;
3831
+ });
3832
+ if (!data) return;
3833
+ gitEntries = data.isRepo ? data.entries : [];
3834
+ if (!data.isRepo) {
3835
+ gitBranchEl.textContent = 'git';
3836
+ gitListEl.appendChild(el('li', 'tree-empty muted', 'no es un repositorio git'));
3837
+ setGitStatus('sin repositorio');
3838
+ return;
3839
+ }
3840
+ gitBranchEl.textContent = data.branch ?? '(sin rama)';
3841
+ gitSyncEl.textContent = [data.ahead ? `↑${data.ahead}` : '', data.behind ? `↓${data.behind}` : '']
3842
+ .filter(Boolean)
3843
+ .join(' ');
3844
+ renderGitList();
3845
+ }
3846
+
3847
+ function renderGitList() {
3848
+ clear(gitListEl);
3849
+ const query = gitFilterEl.value.trim().toLowerCase();
3850
+ const entries = gitEntries.filter(
3851
+ (entry) =>
3852
+ !query ||
3853
+ entry.path.toLowerCase().includes(query) ||
3854
+ (entry.origPath ?? '').toLowerCase().includes(query)
3855
+ );
3856
+ const staged = entries.filter((entry) => gitGroup(entry).staged);
3857
+ const changed = entries.filter((entry) => gitGroup(entry).changed);
3858
+ if (!entries.length) {
3859
+ gitListEl.appendChild(el('li', 'tree-empty muted', gitEntries.length ? 'sin coincidencias' : 'sin cambios'));
3860
+ } else {
3861
+ renderGitGroup('Preparados', staged, true);
3862
+ renderGitGroup('Cambios', changed, false);
3863
+ }
3864
+ const files = new Set(entries.map((entry) => entry.path)).size;
3865
+ setGitStatus(
3866
+ entries.length ? `${files} archivo(s) · ${staged.length} preparado(s) · ${changed.length} cambio(s)` : 'sin cambios'
3867
+ );
3868
+ }
3869
+
3870
+ function renderGitGroup(title, entries, staged) {
3871
+ if (!entries.length) return;
3872
+ gitListEl.appendChild(el('li', 'git-group', `${title} (${entries.length})`));
3873
+ for (const entry of entries) gitListEl.appendChild(gitRow(entry, staged));
3874
+ }
3875
+
3876
+ function gitRow(entry, staged) {
3877
+ const li = el('li', 'git-item');
3878
+ const row = el('div', 'git-row');
3879
+ const badge = GIT_BADGES[entry.status] ?? GIT_BADGES.modified;
3880
+ row.append(
3881
+ el('span', `git-badge ${badge.cls}`, badge.letter),
3882
+ el('span', 'git-path', entry.origPath ? `${entry.origPath} → ${entry.path}` : entry.path)
3883
+ );
3884
+ row.addEventListener('click', () => openGitDiff(entry, staged));
3885
+ li.appendChild(row);
3886
+ return li;
3887
+ }
3888
+
3889
+ async function openGitDiff(entry, staged) {
3890
+ if (!gitModalEl) return;
3891
+ gitTitleEl.textContent = entry.origPath ? `${entry.origPath} → ${entry.path}` : entry.path;
3892
+ gitMetaEl.textContent =
3893
+ entry.status === 'untracked' ? 'sin seguimiento' : staged ? 'preparado' : 'cambios';
3894
+ gitDiffEl.classList.remove('err');
3895
+ gitDiffEl.textContent = 'cargando…';
3896
+ gitModalEl.showModal();
3897
+ const params = new URLSearchParams({ cwd: state.cwd, path: entry.path });
3898
+ if (staged) params.set('staged', '1');
3899
+ const data = await api(`/api/git/diff?${params}`).catch((error) => ({ error: error.message }));
3900
+ if (data?.error) {
3901
+ gitDiffEl.classList.add('err');
3902
+ gitDiffEl.textContent = data.error;
3903
+ setGitStatus(data.error, true);
3904
+ return;
3905
+ }
3906
+ renderGitDiff(gitDiffEl, data.diff);
3907
+ }
3908
+
3909
+ function renderGitDiff(node, text) {
3910
+ const content = String(text ?? '').trimEnd();
3911
+ node.textContent = '';
3912
+ if (!content) {
3913
+ node.textContent = '(sin cambios)';
3914
+ return;
3915
+ }
3916
+ for (const line of content.split('\n')) {
3917
+ let cls = 'diff-eq';
3918
+ if (line.startsWith('@@')) cls = 'diff-sep';
3919
+ else if (/^(diff |index |new file|deleted file|rename |similarity |old mode|new mode|\+\+\+|---)/.test(line)) cls = 'diff-meta';
3920
+ else if (line.startsWith('+')) cls = 'diff-add';
3921
+ else if (line.startsWith('-')) cls = 'diff-del';
3922
+ node.appendChild(el('span', `diff-line ${cls}`, line));
3923
+ }
3924
+ }
3925
+
3926
+ gitFilterEl?.addEventListener('input', () => {
3927
+ if (explorerMode === 'git' && gitEntries.length) renderGitList();
3928
+ });
3929
+
3500
3930
  $('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
3501
3931
  $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
3502
3932
  $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
@@ -3505,6 +3935,10 @@ $('#file-close').addEventListener('click', () => $('#file-modal').close());
3505
3935
  $('#file-modal').addEventListener('click', (event) => {
3506
3936
  if (event.target === $('#file-modal')) $('#file-modal').close();
3507
3937
  });
3938
+ $('#git-close')?.addEventListener('click', () => gitModalEl?.close());
3939
+ gitModalEl?.addEventListener('click', (event) => {
3940
+ if (event.target === gitModalEl) gitModalEl.close();
3941
+ });
3508
3942
 
3509
3943
  try {
3510
3944
  setExplorerOpen(localStorage.getItem('tamnora-explorer-open') === '1');