@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.
- package/CHANGELOG.md +38 -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 +509 -75
- package/harness/packages/server/public/index.html +45 -1
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/markdown.js +43 -0
- package/harness/packages/server/public/styles.css +148 -22
- 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/markdown.test.js +33 -0
- package/harness/packages/server/test/server.test.js +107 -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 --------------------------------------------------------- */
|
|
@@ -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
|
-
|
|
631
|
-
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;
|
|
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
|
-
(
|
|
644
|
-
if (
|
|
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 = `${
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
861
|
-
|
|
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
|
-
|
|
864
|
-
|
|
1000
|
+
footerActivityEl.hidden = true;
|
|
1001
|
+
footerActivityEl.replaceChildren();
|
|
865
1002
|
return;
|
|
866
1003
|
}
|
|
867
|
-
const def = ACTIVITY[kind];
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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 =
|
|
1398
|
-
|
|
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 =
|
|
1593
|
+
submitBtn.disabled = false;
|
|
1594
|
+
updateSubmitLabel(working);
|
|
1439
1595
|
if (stopBtn) stopBtn.hidden = !working;
|
|
1440
|
-
$('#sb-rounds').textContent = working ? '
|
|
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
|
-
|
|
1735
|
-
const
|
|
1736
|
-
|
|
1737
|
-
|
|
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
|
-
|
|
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:
|
|
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');
|