@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.
- package/CHANGELOG.md +12 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +2 -0
- package/harness/packages/core/src/procs/kill.js +95 -0
- package/harness/packages/core/test/procs.test.js +50 -0
- package/harness/packages/server/public/app.js +367 -61
- package/harness/packages/server/public/index.html +32 -1
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/styles.css +54 -18
- package/harness/packages/server/src/routes/chat.js +3 -0
- package/harness/packages/server/src/routes/git.js +43 -0
- package/harness/packages/server/src/routes/procs.js +14 -2
- package/harness/packages/server/src/routes/shell.js +23 -0
- package/harness/packages/server/src/server.js +4 -0
- package/harness/packages/server/test/server.test.js +106 -0
- package/package.json +1 -1
|
@@ -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)
|
|
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
|
-
|
|
638
|
-
const
|
|
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
|
-
(
|
|
651
|
-
if (
|
|
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 = `${
|
|
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 =
|
|
871
|
-
trigger.
|
|
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
|
-
|
|
879
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
1851
|
-
const
|
|
1852
|
-
|
|
1853
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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">
|