@danieltmn/openbridge 0.1.0 → 0.3.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.
@@ -27,6 +27,9 @@ var els = {
27
27
  input: document.getElementById('input'),
28
28
  sendBtn: document.getElementById('sendBtn'),
29
29
  btnImg: document.getElementById('btnImg'),
30
+ btnMic: document.getElementById('btnMic'),
31
+ btnTpl: document.getElementById('btnTpl'),
32
+ tplPanel: document.getElementById('tplPanel'),
30
33
  imgInput: document.getElementById('imgInput'),
31
34
  imgPreview: document.getElementById('imgPreview'),
32
35
  imgThumb: document.getElementById('imgThumb'),
@@ -44,6 +47,7 @@ var els = {
44
47
  btnSkin: document.getElementById('btnSkin'),
45
48
  skinPanel: document.getElementById('skinPanel'),
46
49
  viewProcs: document.getElementById('viewProcs'),
50
+ viewChanges: document.getElementById('viewChanges'),
47
51
  themeColor: document.getElementById('themeColor'),
48
52
  themeLink: document.getElementById('themeStylesheet'),
49
53
  statusbar: document.getElementById('statusbar'),
@@ -220,8 +224,17 @@ function updateStatusbar() {
220
224
  if (state.currentId !== null && state.currentSession) {
221
225
  extra = '· ' + extra;
222
226
  var tokTxt = sessionTokensLabel(state.currentSession);
223
- if (tokTxt) extra += ' · ' + tokTxt + ' tok';
227
+ if (tokTxt) {
228
+ var pct = sessionTokensPct(state.currentSession);
229
+ extra += ' · ' + tokTxt + ' tokens' + (pct ? ' (' + pct + ')' : '');
230
+ }
231
+ var cost = fmtCost(state.currentSession.cost);
232
+ if (cost) extra += ' · ' + cost;
224
233
  }
234
+ var totalCost = 0;
235
+ for (var ci = 0; ci < state.sessions.length; ci++) totalCost += Number(state.sessions[ci].cost) || 0;
236
+ var totalTxt = fmtCost(totalCost);
237
+ if (totalTxt) extra += ' · ' + totalTxt + ' total';
225
238
  els.sbExtra.textContent = extra;
226
239
  }
227
240
 
@@ -761,6 +774,14 @@ function fmtTokens(n) {
761
774
  return String(n);
762
775
  }
763
776
 
777
+ // Costo acumulado (USD) con precision segun magnitud ('' si no hay dato).
778
+ function fmtCost(n) {
779
+ var v = Number(n) || 0;
780
+ if (v <= 0) return '';
781
+ var s = v >= 1 ? v.toFixed(2) : (v >= 0.01 ? v.toFixed(3) : v.toFixed(4));
782
+ return '$' + s.replace('.', ',');
783
+ }
784
+
764
785
  // Ventana de contexto del modelo segun el catalogo (0 si no se conoce).
765
786
  function modelContext(model) {
766
787
  var c = state.catalog || {};
@@ -779,7 +800,16 @@ function sessionTokensLabel(s) {
779
800
  function sessionTokensTitle(s) {
780
801
  var tok = Math.round(Number(s.tokens) || 0);
781
802
  var ctx = modelContext(s.model);
782
- return tok + ' tokens' + (ctx ? ' de ' + ctx + ' de contexto' : '');
803
+ var cost = fmtCost(s.cost);
804
+ return tok + ' tokens' + (ctx ? ' de ' + ctx + ' de contexto' : '') + (cost ? ' · ' + cost + ' acumulado' : '');
805
+ }
806
+
807
+ // Porcentaje de contexto consumido ('' si no hay datos).
808
+ function sessionTokensPct(s) {
809
+ var tok = Math.round(Number(s.tokens) || 0);
810
+ var ctx = modelContext(s.model);
811
+ if (!tok || !ctx) return '';
812
+ return Math.min(100, Math.round((tok / ctx) * 100)) + '%';
783
813
  }
784
814
 
785
815
  // Etiqueta "consumidos / capacidad" de la sesion.
@@ -789,11 +819,23 @@ function sessionTokensHtml(s) {
789
819
  return '<span class="stok" title="' + sessionTokensTitle(s) + '">' + label + '</span>';
790
820
  }
791
821
 
792
- // Subtitulo del encabezado: proyecto · modelo · agente · tokens/contexto.
822
+ // Costo acumulado de la sesion ('' si no hay dato).
823
+ function sessionCostHtml(s) {
824
+ var cost = fmtCost(s.cost);
825
+ if (!cost) return '';
826
+ return '<span class="scost" title="' + sessionTokensTitle(s) + '">' + cost + '</span>';
827
+ }
828
+
829
+ // Subtitulo del encabezado: proyecto · modelo · agente · tokens/contexto · costo.
793
830
  function sessionSubtitle(s) {
794
831
  var txt = projectLabel(s.folder) + ' · ' + (s.model || 'sin modelo') + ' · ' + (s.agent || 'build');
795
832
  var label = sessionTokensLabel(s);
796
- if (label) txt += ' · ' + label + ' tok';
833
+ if (label) {
834
+ var pct = sessionTokensPct(s);
835
+ txt += ' · ' + label + ' tokens' + (pct ? ' (' + pct + ')' : '');
836
+ }
837
+ var cost = fmtCost(s.cost);
838
+ if (cost) txt += ' · ' + cost;
797
839
  return txt;
798
840
  }
799
841
 
@@ -818,6 +860,7 @@ function sessionItemHtml(s, withFolder) {
818
860
  + (withFolder ? '<span class="sfolder">' + esc(projectLabel(s.folder)) + '</span>' : '')
819
861
  + pen
820
862
  + sessionTokensHtml(s)
863
+ + sessionCostHtml(s)
821
864
  + '<span class="stime">' + esc(timeShort(s.last_ts)) + '</span>'
822
865
  + '</button>';
823
866
  }
@@ -940,6 +983,7 @@ function showView(name) {
940
983
  els.viewHistory.style.display = 'none';
941
984
  els.viewPreview.style.display = 'none';
942
985
  els.viewProcs.style.display = 'none';
986
+ els.viewChanges.style.display = 'none';
943
987
 
944
988
  if (name !== 'procs') stopProcView();
945
989
 
@@ -1006,6 +1050,12 @@ function showView(name) {
1006
1050
  var pf = sessionProcFolder();
1007
1051
  els.hSub.textContent = pf ? projectLabel(pf) : 'de la sesión abierta';
1008
1052
  enterProcsView();
1053
+ } else if (name === 'changes') {
1054
+ els.viewChanges.style.display = '';
1055
+ els.btnBack.style.display = 'none';
1056
+ els.sendForm.style.display = 'none';
1057
+ els.btnCmds.style.display = 'none';
1058
+ renderChangesView();
1009
1059
  }
1010
1060
 
1011
1061
  if (els.fabNew) els.fabNew.classList.toggle('show', name === 'home');
@@ -1057,6 +1107,8 @@ function composeOpenSheet() {
1057
1107
  }
1058
1108
  function composeCloseSheet() {
1059
1109
  if (!els.sendForm) return;
1110
+ voiceStop();
1111
+ tplClose();
1060
1112
  els.sendForm.classList.remove('open');
1061
1113
  document.body.classList.remove('compose-open');
1062
1114
  if (els.imgPreview && els.imgPreview.parentNode === els.sendForm && els.composeOpen) {
@@ -1216,9 +1268,12 @@ function renderHome(sessions) {
1216
1268
  + '</div>';
1217
1269
  }
1218
1270
  html += '</div>';
1219
- html += '<button type="button" class="histbtn" data-folder="' + esc(key) + '">'
1271
+ html += '<div class="histrow">'
1272
+ + '<button type="button" class="histbtn" data-folder="' + esc(key) + '">'
1220
1273
  + (hidden > 0 ? 'ver historial · ' + hidden + ' más' : 'ver historial')
1221
- + '</button>';
1274
+ + '</button>'
1275
+ + '<button type="button" class="histbtn filesbtn" data-folder="' + esc(key) + '" title="Archivos del proyecto">ver archivos</button>'
1276
+ + '</div>';
1222
1277
  html += '</div></div>';
1223
1278
  }
1224
1279
  }
@@ -1594,7 +1649,8 @@ function msgSig(m) {
1594
1649
  function msgNodeHtml(m, chatQuery) {
1595
1650
  var cls = m.role === 'user' ? 'mine' : 'theirs';
1596
1651
  if (m.status === 'canceled') cls += ' canceled';
1597
- var who = m.role === 'user' ? '❯ vos' : '● agent';
1652
+ var who = m.role === 'user' ? '❯ vos' : '● Agente';
1653
+ if (m.agent === 'plan') cls += ' plan';
1598
1654
  var agentBadge = m.agent ? ' <span class="abadge ' + esc(m.agent) + '">' + esc(m.agent) + '</span>' : '';
1599
1655
  var stopBadge = m.canceled ? ' <span class="stopbadge">⏹ detenido</span>' : '';
1600
1656
  var streaming = m.role === 'assistant' && m.status === 'streaming';
@@ -1855,6 +1911,19 @@ function focusProject(folder) {
1855
1911
  }
1856
1912
  }
1857
1913
 
1914
+ // Convierte la carpeta absoluta de un proyecto en ruta relativa al workspace
1915
+ // (para la vista de archivos). '' si no se puede derivar (usa la raiz).
1916
+ function folderToFilesPath(folder) {
1917
+ var ws = state.catalog && state.catalog.workspace ? state.catalog.workspace : '';
1918
+ if (!folder || !ws) return '';
1919
+ var norm = function (p) { return String(p).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); };
1920
+ var nws = norm(ws), nf = norm(folder);
1921
+ if (nf === nws || nf.indexOf(nws + '/') !== 0) return '';
1922
+ var raw = String(folder).replace(/\\/g, '/').replace(/\/+$/, '');
1923
+ var rawWs = String(ws).replace(/\\/g, '/').replace(/\/+$/, '');
1924
+ return raw.slice(rawWs.length + 1);
1925
+ }
1926
+
1858
1927
  // Historial de un proyecto: vista aparte con buscador dentro del proyecto.
1859
1928
  function openHistory(folder) {
1860
1929
  state.histFolder = folder;
@@ -2364,6 +2433,148 @@ if (els.btnImg) {
2364
2433
  els.imgRemove.addEventListener('click', function () { setPendingImage(null); });
2365
2434
  }
2366
2435
 
2436
+ // ---------------------------------------------------------------------------
2437
+ // Dictado por voz (Web Speech API nativa del navegador; sin dependencias).
2438
+ // ---------------------------------------------------------------------------
2439
+ var SpeechRec = window.SpeechRecognition || window.webkitSpeechRecognition;
2440
+ var voice = { rec: null, listening: false, base: '' };
2441
+
2442
+ function voiceSetListening(on) {
2443
+ voice.listening = on;
2444
+ if (!els.btnMic) return;
2445
+ els.btnMic.classList.toggle('listening', on);
2446
+ els.btnMic.title = on ? 'Detener dictado' : 'Dictar por voz';
2447
+ els.btnMic.setAttribute('aria-label', on ? 'Detener dictado' : 'Dictar por voz');
2448
+ }
2449
+
2450
+ function voiceStop() {
2451
+ if (voice.rec && voice.listening) {
2452
+ try { voice.rec.stop(); } catch (e) {}
2453
+ }
2454
+ voiceSetListening(false);
2455
+ }
2456
+
2457
+ function voiceStart() {
2458
+ if (!voice.rec || state.currentId === null) return;
2459
+ voice.base = els.input.value ? els.input.value.replace(/\s+$/, ' ') : '';
2460
+ try { voice.rec.start(); } catch (e) { /* ya estaba iniciado */ }
2461
+ voiceSetListening(true);
2462
+ toast('dictado activo · hablá ahora', 'ok');
2463
+ }
2464
+
2465
+ if (els.btnMic) {
2466
+ if (!SpeechRec) {
2467
+ els.btnMic.style.display = 'none';
2468
+ } else {
2469
+ voice.rec = new SpeechRec();
2470
+ voice.rec.lang = navigator.language || 'es-AR';
2471
+ voice.rec.continuous = true;
2472
+ voice.rec.interimResults = true;
2473
+ voice.rec.onresult = function (ev) {
2474
+ var fin = '', interim = '';
2475
+ for (var i = ev.resultIndex; i < ev.results.length; i++) {
2476
+ var r = ev.results[i];
2477
+ if (r.isFinal) fin += r[0].transcript;
2478
+ else interim += r[0].transcript;
2479
+ }
2480
+ if (fin) voice.base = (voice.base + fin).replace(/\s+/g, ' ');
2481
+ var tail = interim ? (voice.base ? ' ' : '') + interim : '';
2482
+ els.input.value = (voice.base + tail).replace(/^\s+/, '');
2483
+ autoGrow();
2484
+ };
2485
+ voice.rec.onerror = function (ev) {
2486
+ voiceSetListening(false);
2487
+ var m = (ev && ev.error) || '';
2488
+ if (m === 'not-allowed' || m === 'service-not-allowed') toast('permiso de micrófono denegado', 'error');
2489
+ else if (m !== 'aborted' && m !== 'no-speech') toast('dictado: ' + m, 'error');
2490
+ };
2491
+ voice.rec.onend = function () { voiceSetListening(false); };
2492
+ els.btnMic.addEventListener('click', function () {
2493
+ if (voice.listening) voiceStop();
2494
+ else voiceStart();
2495
+ });
2496
+ }
2497
+ }
2498
+
2499
+ // ---------------------------------------------------------------------------
2500
+ // Plantillas de prompts (atajos guardados en el navegador).
2501
+ // ---------------------------------------------------------------------------
2502
+ var TPL_KEY = 'ob_tpl';
2503
+
2504
+ function tplLoad() {
2505
+ try {
2506
+ var a = JSON.parse(localStorage.getItem(TPL_KEY) || '[]');
2507
+ if (!Array.isArray(a)) return [];
2508
+ return a.filter(function (x) { return typeof x === 'string' && x.trim() !== ''; }).slice(0, 30);
2509
+ } catch (e) { return []; }
2510
+ }
2511
+ function tplStore(list) {
2512
+ try { localStorage.setItem(TPL_KEY, JSON.stringify(list)); } catch (e) {}
2513
+ }
2514
+ function tplClose() {
2515
+ if (els.tplPanel) els.tplPanel.style.display = 'none';
2516
+ }
2517
+ function tplRender() {
2518
+ var list = tplLoad();
2519
+ var html = '';
2520
+ for (var i = 0; i < list.length; i++) {
2521
+ html += '<div class="tpl-row" data-tpl="' + i + '"><span class="tpl-text">' + esc(list[i]) + '</span>'
2522
+ + '<button type="button" class="tpl-del" data-del="' + i + '" title="Borrar plantilla" aria-label="Borrar plantilla">✕</button></div>';
2523
+ }
2524
+ if (!list.length) html += '<div class="tpl-empty">No hay plantillas. Escribí un prompt y tocá “guardar lo escrito”.</div>';
2525
+ html += '<button type="button" class="tpl-save" id="tplSave">+ guardar lo escrito</button>';
2526
+ els.tplPanel.innerHTML = html;
2527
+ }
2528
+ function tplOpen() {
2529
+ closeSlash();
2530
+ tplRender();
2531
+ els.tplPanel.style.display = 'block';
2532
+ }
2533
+ function tplInsert(text) {
2534
+ var cur = els.input.value;
2535
+ els.input.value = cur && cur.trim() !== '' ? (cur.replace(/\s+$/, '') + ' ' + text) : text;
2536
+ tplClose();
2537
+ autoGrow();
2538
+ els.input.focus();
2539
+ try { els.input.setSelectionRange(els.input.value.length, els.input.value.length); } catch (e) {}
2540
+ }
2541
+ function tplSaveCurrent() {
2542
+ var text = els.input.value.trim();
2543
+ if (!text) { toast('escribí un prompt primero', 'error'); return; }
2544
+ var list = tplLoad();
2545
+ if (list.indexOf(text) >= 0) { toast('esa plantilla ya existe', 'ok'); return; }
2546
+ list.unshift(text);
2547
+ tplStore(list.slice(0, 30));
2548
+ toast('plantilla guardada', 'ok');
2549
+ tplRender();
2550
+ }
2551
+
2552
+ if (els.btnTpl && els.tplPanel) {
2553
+ els.btnTpl.addEventListener('click', function () {
2554
+ if (state.currentId === null) return;
2555
+ if (els.tplPanel.style.display === 'block') tplClose();
2556
+ else tplOpen();
2557
+ });
2558
+ els.tplPanel.addEventListener('click', function (e) {
2559
+ var del = e.target.closest('.tpl-del');
2560
+ if (del) {
2561
+ e.stopPropagation();
2562
+ var list = tplLoad();
2563
+ list.splice(parseInt(del.getAttribute('data-del'), 10), 1);
2564
+ tplStore(list);
2565
+ tplRender();
2566
+ return;
2567
+ }
2568
+ if (e.target.closest('.tpl-save')) { tplSaveCurrent(); return; }
2569
+ var row = e.target.closest('.tpl-row');
2570
+ if (row) {
2571
+ var list2 = tplLoad();
2572
+ var t = list2[parseInt(row.getAttribute('data-tpl'), 10)];
2573
+ if (typeof t === 'string') tplInsert(t);
2574
+ }
2575
+ });
2576
+ }
2577
+
2367
2578
  // ---------------------------------------------------------------------------
2368
2579
  // Composer: textarea multilínea (Enter envía, Shift+Enter salta de línea)
2369
2580
  // y menú de comandos "/" con filtro, flechas y Enter.
@@ -2411,6 +2622,7 @@ els.input.addEventListener('input', function () {
2411
2622
  autoGrow();
2412
2623
  var v = els.input.value;
2413
2624
  if (state.view === 'chat' && v.charAt(0) === '/') {
2625
+ tplClose();
2414
2626
  slash.items = slashFilter(v);
2415
2627
  slash.sel = 0;
2416
2628
  renderSlash();
@@ -2443,7 +2655,8 @@ els.input.addEventListener('keydown', function (e) {
2443
2655
  }
2444
2656
  if (e.key === 'Escape') {
2445
2657
  e.stopPropagation();
2446
- closeSlash();
2658
+ if (els.tplPanel && els.tplPanel.style.display === 'block') tplClose();
2659
+ else closeSlash();
2447
2660
  return;
2448
2661
  }
2449
2662
  }
@@ -2472,6 +2685,7 @@ els.sendForm.addEventListener('submit', async function (ev) {
2472
2685
  var text = els.input.value.trim();
2473
2686
  if ((!text && !state.pendingImage) || state.isSending) return;
2474
2687
  state.isSending = true;
2688
+ voiceStop();
2475
2689
  try {
2476
2690
  var body = { session: state.currentId, text: text };
2477
2691
  if (state.pendingImage) body.image = state.pendingImage;
@@ -2480,6 +2694,7 @@ els.sendForm.addEventListener('submit', async function (ev) {
2480
2694
  els.input.value = '';
2481
2695
  autoGrow();
2482
2696
  closeSlash();
2697
+ tplClose();
2483
2698
  setPendingImage(null);
2484
2699
  await loadHistory(state.currentId, true);
2485
2700
  } else {
@@ -2644,9 +2859,17 @@ els.home.addEventListener('click', function (e) {
2644
2859
  return;
2645
2860
  }
2646
2861
  var hist = e.target.closest('.histbtn');
2647
- if (hist) {
2862
+ if (hist && !e.target.closest('.filesbtn')) {
2648
2863
  e.stopPropagation();
2649
2864
  openHistory(hist.getAttribute('data-folder'));
2865
+ return;
2866
+ }
2867
+ var fbtn = e.target.closest('.filesbtn');
2868
+ if (fbtn) {
2869
+ e.stopPropagation();
2870
+ state.filesPath = folderToFilesPath(fbtn.getAttribute('data-folder'));
2871
+ delete state.filesCache[state.filesPath];
2872
+ showView('files');
2650
2873
  }
2651
2874
  });
2652
2875
 
@@ -2751,6 +2974,132 @@ function filesJoin(name) {
2751
2974
  return state.filesPath ? state.filesPath + '/' + name : name;
2752
2975
  }
2753
2976
 
2977
+ // ---------------------------------------------------------------------------
2978
+ // Cambios del proyecto (git status/diff) vía la cola del puente.
2979
+ // ---------------------------------------------------------------------------
2980
+ function changesFolder() {
2981
+ return (state.currentSession && state.currentSession.folder) || '';
2982
+ }
2983
+
2984
+ function renderChangesView() {
2985
+ els.hTitle.textContent = 'cambios';
2986
+ var folder = changesFolder();
2987
+ els.hSub.textContent = folder ? projectLabel(folder) : 'de la sesión abierta';
2988
+ if (!folder) {
2989
+ els.viewChanges.innerHTML = '<div class="placeholder">Abrí un chat para ver los cambios de su proyecto.</div>';
2990
+ return;
2991
+ }
2992
+ if (!state.online) {
2993
+ els.viewChanges.innerHTML = '<div class="placeholder">El puente está apagado. Iniciá “OpenBridge” en tu PC.</div>';
2994
+ return;
2995
+ }
2996
+ els.viewChanges.innerHTML = '<div class="view-head"><h2>cambios sin commitear</h2>'
2997
+ + '<div class="view-sub">' + esc(projectLabel(folder)) + '</div></div>'
2998
+ + '<div class="placeholder">consultando git…</div>';
2999
+ loadChanges(folder);
3000
+ }
3001
+
3002
+ function chgStatusClass(st) {
3003
+ if (st === '??' || st === 'A') return 'add';
3004
+ if (st.indexOf('D') >= 0) return 'del';
3005
+ if (st.indexOf('M') >= 0 || st.indexOf('R') >= 0) return 'mod';
3006
+ return '';
3007
+ }
3008
+
3009
+ function renderDiff(text) {
3010
+ var max = 200000;
3011
+ var trunc = false;
3012
+ if (text.length > max) { text = text.slice(0, max); trunc = true; }
3013
+ var lines = text.split('\n');
3014
+ var html = '';
3015
+ for (var i = 0; i < lines.length; i++) {
3016
+ var l = lines[i];
3017
+ var cls = 'diff-line';
3018
+ if (l.charAt(0) === '+') cls += ' add';
3019
+ else if (l.charAt(0) === '-') cls += ' del';
3020
+ else if (l.indexOf('@@') === 0) cls += ' hunk';
3021
+ else if (l.indexOf('diff --git') === 0 || l.indexOf('index ') === 0 || l.indexOf('--- ') === 0 || l.indexOf('+++ ') === 0) cls += ' head';
3022
+ html += '<div class="' + cls + '">' + esc(l || ' ') + '</div>';
3023
+ }
3024
+ if (trunc) html += '<div class="diff-line head">… (diff recortado)</div>';
3025
+ return '<div class="diff">' + html + '</div>';
3026
+ }
3027
+
3028
+ function changesHead(folder, branch) {
3029
+ return '<div class="view-head"><h2>cambios sin commitear</h2><div class="view-sub">'
3030
+ + esc(projectLabel(folder)) + (branch ? ' · ' + esc(branch) : '') + '</div></div>';
3031
+ }
3032
+
3033
+ async function loadChanges(folder) {
3034
+ var st = await ocCommand('git_status', [folder], 15, 500);
3035
+ if (!st.ok) {
3036
+ els.viewChanges.innerHTML = changesHead(folder, '')
3037
+ + '<div class="placeholder">' + esc(st.error || 'no se pudo consultar git') + '</div>';
3038
+ return;
3039
+ }
3040
+ var status = st.data || {};
3041
+ var files = status.files || [];
3042
+ if (!files.length) {
3043
+ els.viewChanges.innerHTML = changesHead(folder, status.branch)
3044
+ + '<div class="placeholder">Sin cambios: el árbol está limpio.</div>';
3045
+ return;
3046
+ }
3047
+ var list = '<div class="chg-files">';
3048
+ for (var i = 0; i < files.length; i++) {
3049
+ var rev = files[i].status !== '??'
3050
+ ? '<button type="button" class="chg-rev" data-path="' + esc(files[i].path) + '" title="Revertir este archivo" aria-label="Revertir este archivo">↩</button>'
3051
+ : '';
3052
+ list += '<div class="chg-row"><span class="chg-st ' + chgStatusClass(files[i].status) + '">' + esc(files[i].status) + '</span>'
3053
+ + '<span class="chg-path">' + esc(files[i].path) + '</span>' + rev + '</div>';
3054
+ }
3055
+ list += '</div>';
3056
+ var tracked = files.some(function (f) { return f.status !== '??'; });
3057
+ var toolbar = tracked
3058
+ ? '<div class="files-toolbar"><button type="button" class="linkbtn" id="chgRevert">↩ revertir cambios rastreados</button></div>'
3059
+ : '';
3060
+ els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + '<div class="placeholder">cargando diff…</div>';
3061
+
3062
+ var diff = await ocCommand('git_diff', [folder], 20, 700);
3063
+ var diffHtml;
3064
+ if (diff.ok) {
3065
+ var text = (diff.data && diff.data.diff) || '';
3066
+ diffHtml = text ? renderDiff(text) : '<div class="placeholder">Hay archivos nuevos o renombrados sin diff de contenido.</div>';
3067
+ } else {
3068
+ diffHtml = '<div class="placeholder">' + esc(diff.error || 'no se pudo obtener el diff') + '</div>';
3069
+ }
3070
+ els.viewChanges.innerHTML = changesHead(folder, status.branch) + toolbar + list + diffHtml;
3071
+ }
3072
+
3073
+ // Revertir cambios rastreados (con confirmacion): borra las modificaciones que
3074
+ // el agente u otros hicieron en archivos ya versionados. No toca los nuevos.
3075
+ // Sirve para un archivo puntual (boton de la fila) o para todos.
3076
+ if (els.viewChanges) {
3077
+ els.viewChanges.addEventListener('click', function (e) {
3078
+ var folder = changesFolder();
3079
+ if (!folder) return;
3080
+ var one = e.target.closest('.chg-rev');
3081
+ if (one) {
3082
+ var rel = one.getAttribute('data-path');
3083
+ if (!window.confirm('¿Revertir “' + rel + '”?\n\nNo se puede deshacer.')) return;
3084
+ one.disabled = true;
3085
+ ocCommand('git_checkout', [folder, rel], 15, 500).then(function (r) {
3086
+ toast(r.ok ? 'revertido: ' + rel : (r.error || 'no se pudo revertir'), r.ok ? 'ok' : 'error');
3087
+ renderChangesView();
3088
+ });
3089
+ return;
3090
+ }
3091
+ var all = e.target.closest('#chgRevert');
3092
+ if (!all) return;
3093
+ if (!window.confirm('¿Revertir TODOS los cambios de archivos rastreados en “' + projectLabel(folder) + '”?\n\nNo se puede deshacer. Los archivos nuevos sin seguimiento no se tocan.')) return;
3094
+ all.disabled = true;
3095
+ all.textContent = 'revirtiendo…';
3096
+ ocCommand('git_checkout', [folder], 15, 500).then(function (r) {
3097
+ toast(r.ok ? 'cambios revertidos' : (r.error || 'no se pudo revertir'), r.ok ? 'ok' : 'error');
3098
+ renderChangesView();
3099
+ });
3100
+ });
3101
+ }
3102
+
2754
3103
  function renderFilesView() {
2755
3104
  state.fileView = null;
2756
3105
  var ws = (state.catalog && state.catalog.workspace) || (state.filesWorkspace || '');
package/src/web/routes.js CHANGED
@@ -171,6 +171,9 @@ const OC_ALLOWED = {
171
171
  opencode_version: [],
172
172
  fs_list: ['path'],
173
173
  fs_read: ['path'],
174
+ git_status: ['path'],
175
+ git_diff: ['path'],
176
+ git_checkout: ['path', 'path'],
174
177
  tunnel_start: ['arg'],
175
178
  tunnel_stop: ['arg'],
176
179
  tunnel_list: [],
@@ -69,6 +69,7 @@
69
69
  z-index: 30;
70
70
  }
71
71
  #sidebar .brand {
72
+ min-height: 54px;
72
73
  padding: 12px 14px 10px;
73
74
  display: flex;
74
75
  align-items: center;
@@ -233,6 +234,7 @@
233
234
  .sitem .sname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .78; }
234
235
  .sitem.active .sname { opacity: 1; }
235
236
  .sitem .stok { font-size: 10.5px; color: var(--muted); flex-shrink: 0; }
237
+ .sitem .scost { font-size: 10.5px; color: var(--warn-text, #d29922); flex-shrink: 0; }
236
238
  .sitem .stime { font-size: 11px; color: var(--muted); flex-shrink: 0; }
237
239
  .sitem .spen { width: 6px; height: 6px; border-radius: 50%; background: var(--mine); flex-shrink: 0; }
238
240
  .sdot {
@@ -495,8 +497,9 @@
495
497
  .projsec .pshead .pscount { font-size: 11px; color: var(--muted); flex-shrink: 0; }
496
498
  .projsec .pbody { display: none; }
497
499
  .projsec.open .pbody { display: block; }
500
+ .histrow { display: flex; gap: 6px; margin-top: 6px; }
498
501
  .histbtn {
499
- width: 100%; margin-top: 6px; padding: 8px; border: 1px dashed var(--border); border-radius: 8px;
502
+ flex: 1; min-width: 0; padding: 8px; border: 1px dashed var(--border); border-radius: 8px;
500
503
  background: transparent; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer;
501
504
  }
502
505
  .histbtn:hover { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
@@ -598,6 +601,8 @@
598
601
  .msg.mine .who { color: var(--mine); }
599
602
  .msg.theirs { border-left-color: var(--accent); }
600
603
  .msg.theirs .who { color: var(--accent); }
604
+ .msg.theirs.plan { border-left-color: var(--warn-text, #d29922); }
605
+ .msg.theirs.plan .who { color: var(--warn-text, #d29922); }
601
606
  .msg.error { border-left-color: var(--danger); }
602
607
  /* Aire extra en el cambio de turno: lo tuyo ↔ lo que responde el agente */
603
608
  .msg.mine + .msg.theirs,
@@ -763,6 +768,17 @@
763
768
  .slash-panel .item.sel, .slash-panel .item:hover { background: var(--accent-soft); }
764
769
  .slash-panel .item b { color: var(--mine); font-weight: 600; }
765
770
  .slash-panel .item span { display: block; color: var(--muted); font-size: 11px; margin-top: 1px; }
771
+ .tpl-panel .tpl-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13.5px; }
772
+ .tpl-panel .tpl-row:hover { background: var(--accent-soft); }
773
+ .tpl-panel .tpl-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
774
+ .tpl-panel .tpl-del { background: transparent; border: 0; color: var(--muted); cursor: pointer; font: inherit; padding: 2px 6px; border-radius: 6px; flex-shrink: 0; }
775
+ .tpl-panel .tpl-del:hover { color: var(--danger); }
776
+ .tpl-panel .tpl-empty { color: var(--muted); font-size: 12px; padding: 8px 10px; line-height: 1.4; }
777
+ .tpl-panel .tpl-save {
778
+ display: block; width: 100%; margin-top: 6px; padding: 8px; border: 1px dashed var(--border);
779
+ border-radius: 8px; background: transparent; color: var(--muted); font: inherit; font-size: 12.5px; cursor: pointer;
780
+ }
781
+ .tpl-panel .tpl-save:hover { color: var(--accent); border-color: var(--accent); }
766
782
  form.sendbar button {
767
783
  padding: 9px 16px; border: 1px solid var(--mine); border-radius: 8px; background: transparent; color: var(--mine);
768
784
  font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
@@ -778,6 +794,14 @@
778
794
  }
779
795
  form.sendbar .imgbtn:hover { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
780
796
  form.sendbar .imgbtn svg { flex-shrink: 0; display: block; }
797
+ form.sendbar .imgbtn.listening {
798
+ color: var(--danger); border-color: var(--danger); background: transparent;
799
+ animation: micpulse 1.1s ease-in-out infinite;
800
+ }
801
+ @keyframes micpulse {
802
+ 0%, 100% { box-shadow: 0 0 0 0 transparent; }
803
+ 50% { box-shadow: 0 0 0 4px var(--accent-soft); }
804
+ }
781
805
  /* ----- Botón que abre la hoja de escritura (móvil) ----- */
782
806
  .composeopen {
783
807
  display: none; align-items: center; gap: 10px; width: 100%;
@@ -945,6 +969,33 @@
945
969
  color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center;
946
970
  }
947
971
 
972
+ /* ----- Cambios (git) ----- */
973
+ .chg-files { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 0 0 14px; }
974
+ .chg-row { display: grid; grid-template-columns: 34px 1fr auto; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; align-items: center; }
975
+ .chg-row:last-child { border-bottom: 0; }
976
+ .chg-rev {
977
+ background: transparent; border: 1px solid var(--border); color: var(--muted);
978
+ border-radius: 6px; width: 26px; height: 24px; line-height: 1; cursor: pointer; font: inherit; font-size: 12px;
979
+ }
980
+ .chg-rev:hover { color: var(--danger); border-color: var(--danger); }
981
+ .chg-rev:disabled { opacity: .4; cursor: not-allowed; }
982
+ .chg-st { font-weight: 700; text-align: center; font-size: 11px; color: var(--muted); }
983
+ .chg-st.mod { color: var(--warn-text, #d29922); }
984
+ .chg-st.add { color: var(--accent); }
985
+ .chg-st.del { color: var(--danger); }
986
+ .chg-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
987
+ .diff {
988
+ background: var(--code-bg, var(--bg)); border: 1px solid var(--border);
989
+ border-radius: 8px; overflow: auto; padding: 10px 0; margin: 0 0 20px;
990
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
991
+ font-size: 12px; line-height: 1.5;
992
+ }
993
+ .diff-line { white-space: pre; padding: 0 12px; }
994
+ .diff-line.add { background: rgba(63, 185, 80, .12); color: #3fb950; }
995
+ .diff-line.del { background: rgba(248, 81, 73, .12); color: #f85149; }
996
+ .diff-line.hunk { color: var(--accent); }
997
+ .diff-line.head { color: var(--muted); }
998
+
948
999
  .view-head { margin-bottom: 12px; }
949
1000
  .view-head h2 { margin: 0 0 4px; font-size: 16px; }
950
1001
  .view-sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -1146,6 +1197,7 @@
1146
1197
  <button data-view="search" type="button" title="Buscar">buscar</button>
1147
1198
  <button data-view="preview" type="button" title="Vista previa del proyecto (túnel)">preview</button>
1148
1199
  <button data-view="procs" type="button" title="Procesos de la sesión">procs</button>
1200
+ <button data-view="changes" type="button" title="Cambios del proyecto (git)">cambios</button>
1149
1201
  </div>
1150
1202
  <div class="footer">
1151
1203
  <div class="row" id="bridgeStatusRow">
@@ -1208,10 +1260,13 @@
1208
1260
  </div>
1209
1261
  <span class="prompt">❯</span>
1210
1262
  <textarea id="input" rows="1" placeholder="escribí un mensaje…" autocomplete="off"></textarea>
1263
+ <button type="button" class="imgbtn" id="btnTpl" title="Plantillas de prompts" aria-label="Plantillas de prompts"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/></svg></button>
1211
1264
  <button type="button" class="imgbtn" id="btnImg" title="Adjuntar imagen (modelos con visión)" aria-label="Adjuntar imagen"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/></svg></button>
1265
+ <button type="button" class="imgbtn" id="btnMic" title="Dictar por voz" aria-label="Dictar por voz"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg></button>
1212
1266
  <input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
1213
1267
  <button type="submit" id="sendBtn">enviar</button>
1214
1268
  <div id="slashPanel" class="slash-panel"></div>
1269
+ <div id="tplPanel" class="slash-panel tpl-panel"></div>
1215
1270
  </form>
1216
1271
  </div>
1217
1272
 
@@ -1221,6 +1276,7 @@
1221
1276
  <div id="viewHistory" class="view" style="display:none"></div>
1222
1277
  <div id="viewPreview" class="view" style="display:none"></div>
1223
1278
  <div id="viewProcs" class="view" style="display:none"></div>
1279
+ <div id="viewChanges" class="view" style="display:none"></div>
1224
1280
 
1225
1281
  <footer id="statusbar">
1226
1282
  <span class="dot" id="sbDot"></span>