@danieltmn/tamnora 0.7.1 → 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 --------------------------------------------------------- */
@@ -59,6 +60,8 @@ const ICON_PATHS = {
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'],
61
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'],
62
65
  };
63
66
 
64
67
  function icon(name, size = 16, className = '') {
@@ -634,11 +637,13 @@ function renderTokens() {
634
637
  const lastTurn = record?.lastTurn ?? { prompt: 0, completion: 0 };
635
638
  const total = tokens.in + tokens.out;
636
639
  const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
637
- const used = lastTurn.prompt + lastTurn.completion || total;
638
- 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;
639
644
  const tone = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
640
645
 
641
- if (!total) {
646
+ if (!total && !used) {
642
647
  if (wrap) wrap.className = 'ctx muted';
643
648
  node.textContent = 'tokens —';
644
649
  node.title = '';
@@ -647,16 +652,30 @@ function renderTokens() {
647
652
  }
648
653
  const parts =
649
654
  `entrada ${formatK(tokens.in)} · salida ${formatK(tokens.out)}` +
650
- (context ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
651
- if (context) {
655
+ (hasContext ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
656
+ if (hasContext) {
652
657
  node.textContent = `contexto ${pct}% · ${formatK(used)}/${formatK(context)}`;
653
658
  if (wrap) wrap.className = `ctx ${tone}`;
659
+ } else if (context) {
660
+ node.textContent = `contexto — · ${formatK(context)}`;
661
+ if (wrap) wrap.className = 'ctx muted';
654
662
  } else {
655
663
  node.textContent = `tokens ${formatK(total)}`;
656
664
  if (wrap) wrap.className = 'ctx muted';
657
665
  }
658
666
  node.title = parts;
659
- 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
+ }
660
679
  }
661
680
 
662
681
  function addTokens(tokens) {
@@ -867,24 +886,35 @@ document.addEventListener('scroll', positionMessageMenu, true);
867
886
 
868
887
  function addMessageActions(message) {
869
888
  const actions = el('div', 'msg-actions');
870
- const trigger = button('Opciones...', () => toggleMessageMenu(message), 'msg-menu-btn');
871
- trigger.title = 'opciones';
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';
872
893
  trigger.setAttribute('aria-haspopup', 'menu');
873
894
  trigger.setAttribute('aria-expanded', 'false');
874
895
  trigger.setAttribute('aria-label', 'opciones del mensaje');
896
+ trigger.addEventListener('click', () => toggleMessageMenu(message));
875
897
 
876
898
  const menu = el('div', 'msg-menu');
877
899
  menu.hidden = true;
878
- const addItem = (label, run, cls = 'msg-act') => {
879
- menu.appendChild(button(label, () => {
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', () => {
880
908
  closeMessageMenus();
881
909
  run();
882
- }, cls));
910
+ });
911
+ menu.appendChild(item);
883
912
  };
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');
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');
888
918
 
889
919
  actions.append(trigger, menu);
890
920
  message.bubble.appendChild(actions);
@@ -1501,14 +1531,22 @@ function activateSession(id) {
1501
1531
  auditStatus(record);
1502
1532
  updateStatus();
1503
1533
  renderTokens();
1534
+ renderQueue(id);
1504
1535
  renderTabs();
1505
1536
  refreshSessions();
1506
1537
  persistTabs();
1507
1538
  chatInput.focus();
1508
1539
  }
1509
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
+
1510
1547
  function auditStatus(record) {
1511
- submitBtn.disabled = record.working;
1548
+ submitBtn.disabled = false;
1549
+ updateSubmitLabel(record.working);
1512
1550
  $('#sb-rounds').textContent = record.working ? '' : 'listo';
1513
1551
  $('#sb-rounds').classList.toggle('running', record.working);
1514
1552
  renderFooterActivity(record.working ? 'thinking' : null);
@@ -1519,6 +1557,7 @@ function closeTab(id) {
1519
1557
  if (!record) return;
1520
1558
  record.el.remove();
1521
1559
  state.panes.delete(id);
1560
+ state.queues.delete(id);
1522
1561
  state.tabOrder = state.tabOrder.filter((item) => item !== id);
1523
1562
  if (state.activeTab === id) {
1524
1563
  const next = state.tabOrder[state.tabOrder.length - 1] ?? null;
@@ -1543,6 +1582,7 @@ function refreshTokensDefaults() {
1543
1582
  $('#sb-rounds').textContent = 'listo';
1544
1583
  $('#sb-rounds').classList.remove('running');
1545
1584
  submitBtn.disabled = false;
1585
+ updateSubmitLabel(false);
1546
1586
  }
1547
1587
 
1548
1588
  function setWorking(id, working) {
@@ -1550,7 +1590,8 @@ function setWorking(id, working) {
1550
1590
  if (record) record.working = working;
1551
1591
  renderTabs();
1552
1592
  if (id === state.sessionId) {
1553
- submitBtn.disabled = working;
1593
+ submitBtn.disabled = false;
1594
+ updateSubmitLabel(working);
1554
1595
  if (stopBtn) stopBtn.hidden = !working;
1555
1596
  $('#sb-rounds').textContent = working ? '' : 'listo';
1556
1597
  $('#sb-rounds').classList.toggle('running', working);
@@ -1781,6 +1822,8 @@ function handleChatEvent(event, data, turn) {
1781
1822
  turn.assistant().tools.appendChild(el('div', 'chip', `✂ contexto recortado (${data.trimmed} resultados, ${data.dropped} mensajes)`));
1782
1823
  } else if (event === 'repeat') {
1783
1824
  turn.assistant().tools.appendChild(el('div', 'chip warn', `↻ ${data.name} repetida sin cambios`));
1825
+ } else if (event === 'usage') {
1826
+ setContextUsage(data.tokens);
1784
1827
  } else if (event === 'warning') {
1785
1828
  turn.assistant().tools.appendChild(el('div', 'chip', `⚠ ${data.message}`));
1786
1829
  } else if (event === 'error') {
@@ -1847,46 +1890,14 @@ async function attachRun(sessionKey, pane) {
1847
1890
  }
1848
1891
  }
1849
1892
 
1850
- async function sendMessage() {
1851
- const current = state.panes.get(state.sessionId);
1852
- if (current?.working) return;
1853
- hideSlashMenu();
1854
- let input = chatInput.value.trim();
1855
- const images = state.attachments.slice();
1856
- if (input.startsWith('/') && !images.length) {
1857
- const name = input.slice(1).split(/\s+/)[0].toLowerCase();
1858
- const arg = input.slice(1 + name.length).trim();
1859
- const outcome = await runCommand(name, arg);
1860
- if (!outcome || outcome.handled) {
1861
- chatInput.value = '';
1862
- return;
1863
- }
1864
- input = outcome.input ?? input;
1865
- }
1866
- if (!input && !images.length) return;
1867
- if (!state.sessionId) {
1868
- state.pendingInput = input;
1869
- openFolderPicker();
1870
- return;
1871
- }
1872
-
1873
- if (state.pendingEdit) {
1874
- await api(`/api/sessions/${state.sessionId}/truncate`, {
1875
- method: 'POST',
1876
- body: { fromId: state.pendingEdit.fromId },
1877
- }).catch(() => {});
1878
- await reloadPane(state.sessionId);
1879
- state.pendingEdit = null;
1880
- }
1893
+ function contextTokensForRequest() {
1894
+ const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
1895
+ return context ? Math.floor(context * 0.8) : undefined;
1896
+ }
1881
1897
 
1882
- const pane = activePane;
1883
- const sessionKey = state.sessionId;
1898
+ async function startTurn(sessionKey, pane, input, images) {
1884
1899
  const user = addMessage('user', input || `(${images.length} imagen${images.length > 1 ? 'es' : ''})`, pane);
1885
1900
  appendImages(user, images);
1886
- chatInput.value = '';
1887
- state.attachments = [];
1888
- renderAttachments();
1889
-
1890
1901
  const turn = createChatTurn(pane, sessionKey);
1891
1902
  turn.active = addMessage('assistant', '', pane);
1892
1903
  setMessageMode(turn.active, turn.turnMode);
@@ -1900,15 +1911,12 @@ async function sendMessage() {
1900
1911
  {
1901
1912
  input: input || '(imagen adjunta)',
1902
1913
  images: images.length ? images : undefined,
1903
- sessionId: state.sessionId || undefined,
1914
+ sessionId: sessionKey,
1904
1915
  provider: providerSelect.value || undefined,
1905
1916
  model: modelSelect.value || undefined,
1906
1917
  mode: modeSelect.value || undefined,
1907
1918
  yes: yesInput.checked ? true : undefined,
1908
- contextTokens: (() => {
1909
- const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
1910
- return context ? Math.floor(context * 0.8) : undefined;
1911
- })(),
1919
+ contextTokens: contextTokensForRequest(),
1912
1920
  },
1913
1921
  (event, data) => handleChatEvent(event, data, turn),
1914
1922
  controller.signal
@@ -1933,9 +1941,142 @@ async function sendMessage() {
1933
1941
  setActivity(turn.active, null);
1934
1942
  setWorking(sessionKey, false);
1935
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);
1936
1984
  }
1937
1985
  }
1938
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;
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);
2078
+ }
2079
+
1939
2080
  if (stopBtn) {
1940
2081
  stopBtn.addEventListener('click', () => {
1941
2082
  const id = state.sessionId;
@@ -2053,6 +2194,7 @@ const COMMANDS = [
2053
2194
  ['/compact', 'resumir la conversación y liberar contexto'],
2054
2195
  ['/clear', 'borrar los mensajes de esta sesión'],
2055
2196
  ['/cwd', 'mostrar la carpeta de trabajo'],
2197
+ ['/kill <puerto>', 'matar el proceso que escucha en un puerto'],
2056
2198
  ['/help', 'mostrar esta ayuda'],
2057
2199
  ];
2058
2200
 
@@ -2093,6 +2235,21 @@ async function runCommand(name, arg) {
2093
2235
  notify(`Carpeta de trabajo: \`${cwd}\``);
2094
2236
  return { handled: true };
2095
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
+ }
2096
2253
  case 'clear':
2097
2254
  case 'limpiar': {
2098
2255
  if (!state.sessionId) return needSession();
@@ -3450,7 +3607,7 @@ $('#file-preview-toggle')?.addEventListener('click', async () => {
3450
3607
 
3451
3608
  /* --- explorer panes: estructura / preview / procs ------------------------- */
3452
3609
 
3453
- 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') };
3454
3611
  let explorerMode = 'files';
3455
3612
 
3456
3613
  function setExplorerMode(mode) {
@@ -3464,6 +3621,7 @@ function refreshExplorerMode() {
3464
3621
  if (!state.explorerOpen) return;
3465
3622
  if (explorerMode === 'files') refreshExplorer();
3466
3623
  else if (explorerMode === 'procs') detectProcs();
3624
+ else if (explorerMode === 'git') refreshGit();
3467
3625
  }
3468
3626
 
3469
3627
  document.querySelectorAll('.xtab').forEach((tab) => tab.addEventListener('click', () => setExplorerMode(tab.dataset.xp)));
@@ -3625,6 +3783,150 @@ async function refreshProcLog() {
3625
3783
 
3626
3784
  $('#procs-detect')?.addEventListener('click', detectProcs);
3627
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
+
3628
3930
  $('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
3629
3931
  $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
3630
3932
  $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
@@ -3633,6 +3935,10 @@ $('#file-close').addEventListener('click', () => $('#file-modal').close());
3633
3935
  $('#file-modal').addEventListener('click', (event) => {
3634
3936
  if (event.target === $('#file-modal')) $('#file-modal').close();
3635
3937
  });
3938
+ $('#git-close')?.addEventListener('click', () => gitModalEl?.close());
3939
+ gitModalEl?.addEventListener('click', (event) => {
3940
+ if (event.target === gitModalEl) gitModalEl.close();
3941
+ });
3636
3942
 
3637
3943
  try {
3638
3944
  setExplorerOpen(localStorage.getItem('tamnora-explorer-open') === '1');
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.7.1</span></div>
36
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.8.0</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
@@ -132,6 +132,7 @@
132
132
  <form id="chat-form" class="composer">
133
133
  <div id="slash-menu" class="slash-menu" hidden></div>
134
134
  <div id="attach-preview" class="attach-preview" hidden></div>
135
+ <div id="chat-queue" class="chat-queue" hidden></div>
135
136
  <div class="composer-row">
136
137
  <span class="prompt-mark">❯</span>
137
138
  <button type="button" id="attach-image" class="btn icon" title="Adjuntar imagen (o pegala / arrastrala)" aria-label="adjuntar imagen">
@@ -172,6 +173,7 @@
172
173
  <button type="button" class="xtab is-active" data-xp="files">estructura</button>
173
174
  <button type="button" class="xtab" data-xp="preview">preview</button>
174
175
  <button type="button" class="xtab" data-xp="procs">procs</button>
176
+ <button type="button" class="xtab" data-xp="git">git</button>
175
177
  </div>
176
178
  <span class="spacer"></span>
177
179
  <button id="explorer-refresh" type="button" class="btn icon" title="refrescar" aria-label="refrescar">
@@ -234,6 +236,18 @@
234
236
  <ul id="procs-list" class="providers"></ul>
235
237
  <pre id="procs-log" class="procs-log"></pre>
236
238
  </div>
239
+
240
+ <div id="xp-git" class="xp-pane" hidden>
241
+ <div class="xp-bar">
242
+ <span id="git-branch" class="git-branch">…</span>
243
+ <span id="git-sync" class="muted grow"></span>
244
+ </div>
245
+ <div class="explorer-filter">
246
+ <input id="git-filter" placeholder="filtrar por nombre…" spellcheck="false" />
247
+ </div>
248
+ <ul id="git-list" class="git-list"></ul>
249
+ <div id="git-status" class="explorer-status"></div>
250
+ </div>
237
251
  </aside>
238
252
 
239
253
  <button id="sidebar-handle" type="button" class="panel-handle panel-handle-left" title="mostrar sesiones" aria-label="mostrar panel de sesiones" aria-pressed="false">
@@ -280,6 +294,23 @@
280
294
  </div>
281
295
  </div>
282
296
  </dialog>
297
+
298
+ <dialog id="git-modal" class="modal file-modal">
299
+ <div class="modal-card">
300
+ <div class="row between">
301
+ <h3 id="git-title">cambios</h3>
302
+ <div class="file-actions">
303
+ <span id="git-meta" class="muted"></span>
304
+ <button id="git-close" type="button" class="btn icon" title="cerrar" aria-label="cerrar">
305
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
306
+ <path d="M18 6 6 18M6 6l12 12" />
307
+ </svg>
308
+ </button>
309
+ </div>
310
+ </div>
311
+ <pre id="git-diff" class="file-preview git-diff"></pre>
312
+ </div>
313
+ </dialog>
283
314
  </section>
284
315
 
285
316
  <section id="view-providers" class="view">