@danieltmn/tamnora 0.9.2 → 0.9.4

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.
Files changed (33) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +30 -10
  3. package/harness/packages/cli/src/tui/app.js +22 -3
  4. package/harness/packages/cli/src/tui/layout.js +59 -2
  5. package/harness/packages/cli/test/tui.test.js +30 -0
  6. package/harness/packages/core/src/agent/modes.js +14 -1
  7. package/harness/packages/core/src/agent/runAgent.js +23 -2
  8. package/harness/packages/core/src/browser/index.js +289 -0
  9. package/harness/packages/core/src/config/home.js +5 -0
  10. package/harness/packages/core/src/index.js +3 -2
  11. package/harness/packages/core/src/providers/presets.js +4 -0
  12. package/harness/packages/core/src/tools/diff.js +70 -1
  13. package/harness/packages/core/src/tools/executors.js +96 -0
  14. package/harness/packages/core/src/tools/labels.js +4 -0
  15. package/harness/packages/core/src/tools/permissions.js +4 -0
  16. package/harness/packages/core/src/tools/schema.js +47 -0
  17. package/harness/packages/core/test/agent.test.js +30 -1
  18. package/harness/packages/core/test/browser.test.js +18 -0
  19. package/harness/packages/core/test/diff.test.js +24 -1
  20. package/harness/packages/core/test/tools.test.js +16 -1
  21. package/harness/packages/server/public/app.js +203 -38
  22. package/harness/packages/server/public/index.html +2 -1
  23. package/harness/packages/server/public/landing/index.html +14 -1
  24. package/harness/packages/server/public/reconcile.js +3 -1
  25. package/harness/packages/server/public/styles.css +98 -12
  26. package/harness/packages/server/src/lib/agentRun.js +2 -2
  27. package/harness/packages/server/src/lib/channels/telegram.js +129 -44
  28. package/harness/packages/server/src/routes/files.js +25 -1
  29. package/harness/packages/server/src/routes/sessions.js +55 -33
  30. package/harness/packages/server/test/server.test.js +84 -14
  31. package/harness/packages/server/test/telegram-features.test.js +25 -4
  32. package/harness/packages/server/test/telegram-voz.test.js +3 -5
  33. package/package.json +1 -1
@@ -72,6 +72,10 @@ const ICON_PATHS = {
72
72
  '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'],
73
73
  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'],
74
74
  'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
75
+ 'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
76
+ play: ['m6 4 14 8-14 8z'],
77
+ pause: ['M8 5v14', 'M16 5v14'],
78
+ square: ['M6 6h12v12H6z'],
75
79
  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'],
76
80
  pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
77
81
  };
@@ -301,6 +305,8 @@ if (statusbarEl) {
301
305
  const PALETTE_COMMANDS = [
302
306
  { id: 'new', label: 'Nueva sesión en otra carpeta', hint: '/new', run: () => openFolderPicker() },
303
307
  { id: 'init', label: 'Analizar el proyecto y crear AGENTS.md', hint: '/init', run: () => runSlash('init') },
308
+ { id: 'resumen', label: 'Resumir la conversación', hint: '/resumen', run: () => runSlash('resumen') },
309
+ { id: 'audioresumen', label: 'Resumen como audio', hint: '/audioresumen', run: () => runSlash('audioresumen') },
304
310
  { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
305
311
  { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
306
312
  { id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
@@ -330,7 +336,7 @@ function runSlash(name) {
330
336
  function paletteItems(query) {
331
337
  const q = query.trim().toLowerCase();
332
338
  const items = PALETTE_COMMANDS.filter(
333
- (c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q)
339
+ (c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q) || (c.hint ?? '').toLowerCase().includes(q)
334
340
  ).map((c) => ({ label: c.label, hint: c.hint || 'comando', run: c.run }));
335
341
 
336
342
  for (const session of palette.results.sessions) {
@@ -902,7 +908,7 @@ function addMessageActions(message) {
902
908
  const actions = el('div', 'msg-actions');
903
909
  const trigger = el('button', 'msg-menu-btn');
904
910
  trigger.type = 'button';
905
- trigger.appendChild(icon('dots-vertical', 16));
911
+ trigger.appendChild(icon(window.matchMedia('(min-width: 821px)').matches ? 'dots-horizontal' : 'dots-vertical', 16));
906
912
  trigger.title = 'opciones del mensaje';
907
913
  trigger.setAttribute('aria-haspopup', 'menu');
908
914
  trigger.setAttribute('aria-expanded', 'false');
@@ -1152,6 +1158,10 @@ function toolSummary(name, args) {
1152
1158
  return shortText(a.url, 90);
1153
1159
  case 'speak':
1154
1160
  return shortText(a.texto, 90);
1161
+ case 'screenshot':
1162
+ case 'console_errors':
1163
+ case 'preview_url':
1164
+ return shortText(a.url, 90);
1155
1165
  case 'git_diff':
1156
1166
  return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
1157
1167
  .filter(Boolean)
@@ -1183,6 +1193,26 @@ function isFailedToolText(text) {
1183
1193
  return /^Error|^Permiso denegado/i.test(String(text ?? ''));
1184
1194
  }
1185
1195
 
1196
+ /** Nombre de archivo de una captura, del meta o del texto del resultado. */
1197
+ function screenshotName(data, text) {
1198
+ const fromMeta = data?.meta?.screenshot?.name;
1199
+ if (fromMeta) return String(fromMeta);
1200
+ const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
1201
+ return match ? match[1] : null;
1202
+ }
1203
+
1204
+ /** Muestra la captura del tool `screenshot` en su tarjeta (clic para ampliar). */
1205
+ function showScreenshot(entry, name) {
1206
+ if (!entry || !name || entry.shot) return;
1207
+ entry.shot = true;
1208
+ const img = el('img', 'tool-shot');
1209
+ img.loading = 'lazy';
1210
+ img.alt = 'captura de pantalla';
1211
+ img.src = `/api/screenshots/${encodeURIComponent(name)}`;
1212
+ img.addEventListener('click', () => window.open(img.src, '_blank'));
1213
+ entry.card.appendChild(img);
1214
+ }
1215
+
1186
1216
  /** Cierra la tarjeta de un tool-call (evento tool_result o mensaje persistido). */
1187
1217
  function finishToolCard(entry, data) {
1188
1218
  if (!entry) return;
@@ -1197,6 +1227,7 @@ function finishToolCard(entry, data) {
1197
1227
  entry.truncated = true;
1198
1228
  entry.more.hidden = false;
1199
1229
  }
1230
+ if (!failed) showScreenshot(entry, screenshotName(data, preview));
1200
1231
  }
1201
1232
 
1202
1233
  function previewText(value, maxLines = 24, maxChars = 4000) {
@@ -1252,18 +1283,13 @@ async function loadFullToolResult(entry, message) {
1252
1283
  }
1253
1284
  }
1254
1285
 
1255
- /** Reproductor del tool `speak`: sintetiza el texto pedido contra /api/tts. */
1256
- function speakAudio(args) {
1286
+ /** Botón del tool `speak`: encola el audio pedido en el reproductor global. */
1287
+ function speakControl(args) {
1257
1288
  const texto = String(args?.texto ?? '').trim();
1258
1289
  if (!texto) return null;
1259
- const params = new URLSearchParams({ texto });
1260
- if (args?.voice) params.set('voice', args.voice);
1261
- if (args?.rate) params.set('rate', args.rate);
1262
- const audio = el('audio', 'tool-audio');
1263
- audio.controls = true;
1264
- audio.preload = 'none';
1265
- audio.src = `/api/tts?${params.toString()}`;
1266
- return audio;
1290
+ const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1291
+ btn.append(icon('play', 13), el('span', '', 'escuchar'));
1292
+ return btn;
1267
1293
  }
1268
1294
 
1269
1295
  /** Texto de la tarjeta: el server ya manda `summary` calculado; esto es el fallback. */
@@ -1277,17 +1303,17 @@ function addToolCard(message, name, args, { callId = null, open = null, round =
1277
1303
  const argsEl = el('span', 'tool-args', summary ?? toolSummary(name, args));
1278
1304
  head.append(glyph, el('span', 'tool-name', name), argsEl);
1279
1305
  const out = el('div', 'tool-out');
1280
- const audio = name === 'speak' ? speakAudio(args) : null;
1306
+ const speak = name === 'speak' ? speakControl(args) : null;
1281
1307
  const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
1282
1308
  more.hidden = true;
1283
1309
  card.append(head, out);
1284
- if (audio) card.appendChild(audio);
1310
+ if (speak) card.appendChild(speak);
1285
1311
  card.appendChild(more);
1286
1312
  message.tools.appendChild(card);
1287
1313
  ensureToolsToggle(message);
1288
1314
  scrollMessages(paneOf(message));
1289
1315
 
1290
- const entry = { card, out, glyph, argsEl, more, audio, callId, full: false, truncated: false };
1316
+ const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
1291
1317
  card.addEventListener('toggle', () => {
1292
1318
  if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
1293
1319
  });
@@ -2022,8 +2048,12 @@ function handleChatEvent(event, data, turn) {
2022
2048
  setActivity(target, 'working');
2023
2049
  turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
2024
2050
  if (turn.live && data.name === 'speak') {
2025
- const player = turn.lastTool?.audio;
2026
- if (player) player.play?.().catch?.(() => {});
2051
+ const texto = String(data.args?.texto ?? '').trim();
2052
+ if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
2053
+ }
2054
+ if (turn.live && data.name === 'preview_url' && data.args?.url) {
2055
+ setExplorerMode('preview');
2056
+ loadPreview(data.args.url);
2027
2057
  }
2028
2058
  } else if (event === 'tool_result') {
2029
2059
  setActivity(turn.assistant(), 'thinking');
@@ -2446,8 +2476,8 @@ const micBtn = $('#mic-btn');
2446
2476
 
2447
2477
  // La cuenta 3-2-1 le da tiempo al micro a arrancar (si hablás apenas tocás se
2448
2478
  // pierden las primeras palabras); la cola al cortar evita perder el final.
2449
- const REC_COUNT = [3, 2, 1];
2450
- const REC_COUNT_STEP_MS = 900;
2479
+ const REC_COUNT = [3, 2, 1, 0];
2480
+ const REC_COUNT_STEP_MS = 500;
2451
2481
  const REC_TAIL_MS = 500;
2452
2482
 
2453
2483
  /** Estado visual del botón de dictado: cuenta regresiva, grabando (verde) o transcribiendo. */
@@ -2891,6 +2921,8 @@ const COMMANDS = [
2891
2921
  ['/new', 'nueva sesión (elegís carpeta)'],
2892
2922
  ['/init', 'analizar el proyecto y crear/actualizar AGENTS.md'],
2893
2923
  ['/goal <texto>', 'fijar el objetivo de la sesión'],
2924
+ ['/resumen', 'resumir la conversación (sin borrarla)'],
2925
+ ['/audioresumen', 'el resumen como audio'],
2894
2926
  ['/compact', 'resumir la conversación y liberar contexto'],
2895
2927
  ['/clear', 'borrar los mensajes de esta sesión'],
2896
2928
  ['/cwd', 'mostrar la carpeta de trabajo'],
@@ -2991,6 +3023,32 @@ async function runCommand(name, arg) {
2991
3023
  notify(`Objetivo de la sesión: **${arg}**`);
2992
3024
  return { handled: true };
2993
3025
  }
3026
+ case 'resumen':
3027
+ case 'resume': {
3028
+ if (!state.sessionId) return needSession();
3029
+ notify('Resumiendo la conversación…');
3030
+ const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
3031
+ error: error.message,
3032
+ }));
3033
+ if (out.error) notify(`No se pudo resumir: ${out.error}`, 'notice err');
3034
+ else notify(`**Resumen**\n\n${out.summary}`);
3035
+ return { handled: true };
3036
+ }
3037
+ case 'audioresumen':
3038
+ case 'audio-resumen': {
3039
+ if (!state.sessionId) return needSession();
3040
+ notify('Preparando el resumen…');
3041
+ const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
3042
+ error: error.message,
3043
+ }));
3044
+ if (out.error) {
3045
+ notify(`No se pudo resumir: ${out.error}`, 'notice err');
3046
+ return { handled: true };
3047
+ }
3048
+ notify(`**Resumen**\n\n${out.summary}`);
3049
+ reproducirVoz(out.summary, { label: 'resumen' }).catch((error) => notify(`No se pudo reproducir el audio: ${error.message}`, 'notice err'));
3050
+ return { handled: true };
3051
+ }
2994
3052
  case 'compact': {
2995
3053
  if (!state.sessionId) return needSession();
2996
3054
  notify('Compactando la conversación…');
@@ -3053,7 +3111,10 @@ function updateSlashMenu() {
3053
3111
  const value = chatInput.value;
3054
3112
  if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
3055
3113
  const query = value.slice(1).toLowerCase();
3056
- slashMatches = slashCommandEntries().filter((entry) => entry.name.slice(1).toLowerCase().startsWith(query));
3114
+ slashMatches = slashCommandEntries().filter(
3115
+ (entry) =>
3116
+ entry.name.slice(1).toLowerCase().startsWith(query) || entry.desc.toLowerCase().includes(query)
3117
+ );
3057
3118
  if (!slashMatches.length) return hideSlashMenu();
3058
3119
  if (slashIndex >= slashMatches.length) slashIndex = 0;
3059
3120
  renderSlashMenu();
@@ -3896,35 +3957,139 @@ const voz = {
3896
3957
  rate: '+0%',
3897
3958
  announce: { start: true, tools: true, end: true },
3898
3959
  },
3899
- cola: Promise.resolve(),
3900
3960
  };
3901
3961
 
3902
- /** Reproduce una frase contra el endpoint de sintesis del server. */
3903
- function reproducirVoz(frase, { voice, rate } = {}) {
3962
+ /**
3963
+ * Un único reproductor para TODO el audio (narración, tool `speak`,
3964
+ * `/audioresumen`, prueba de voz): encola y reproduce de a uno, en orden, con
3965
+ * controles visibles para pausar o detener. Antes cada audio creaba su propio
3966
+ * `<audio>` y, si el modelo pedía dos, sonaban encimados y sin forma de pararlos.
3967
+ */
3968
+ const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
3969
+
3970
+ function ensurePlayer() {
3971
+ if (!ap.el || ap.ready) return;
3972
+ ap.ready = true;
3973
+ ap.toggle = button('', () => toggleAudio(), 'ap-btn');
3974
+ ap.iconEl = el('span', 'ap-icon');
3975
+ ap.toggle.appendChild(ap.iconEl);
3976
+ ap.labelEl = el('span', 'ap-label');
3977
+ ap.barEl = el('div', 'ap-bar');
3978
+ const progress = el('div', 'ap-progress');
3979
+ progress.appendChild(ap.barEl);
3980
+ const info = el('div', 'ap-info');
3981
+ info.append(ap.labelEl, progress);
3982
+ ap.queueEl = el('span', 'ap-queue');
3983
+ ap.stopBtn = button('', () => stopAudio(), 'ap-btn ap-stop');
3984
+ ap.stopBtn.title = 'detener';
3985
+ ap.stopBtn.setAttribute('aria-label', 'detener audio');
3986
+ ap.stopBtn.appendChild(icon('square', 14));
3987
+ ap.el.append(ap.toggle, info, ap.queueEl, ap.stopBtn);
3988
+
3989
+ ap.audio.addEventListener('ended', () => advanceAudio(ap.current));
3990
+ ap.audio.addEventListener('error', () => {
3991
+ if (ap.current && ap.audio.getAttribute('src')) advanceAudio(ap.current, new Error('No se pudo reproducir el audio.'));
3992
+ });
3993
+ ap.audio.addEventListener('play', renderAudio);
3994
+ ap.audio.addEventListener('pause', renderAudio);
3995
+ ap.audio.addEventListener('timeupdate', () => {
3996
+ const { duration, currentTime } = ap.audio;
3997
+ if (!Number.isFinite(duration) || duration <= 0) return;
3998
+ ap.barEl.style.width = `${Math.min(100, (currentTime / duration) * 100)}%`;
3999
+ });
4000
+ }
4001
+
4002
+ /** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
4003
+ function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
4004
+ const frase = String(texto ?? '').trim();
4005
+ if (!frase) return Promise.resolve();
4006
+ ensurePlayer();
3904
4007
  return new Promise((resolve, reject) => {
3905
- const params = new URLSearchParams({
3906
- texto: frase,
3907
- voice: voice ?? voz.settings.voice,
3908
- rate: rate ?? voz.settings.rate,
3909
- });
3910
- const audio = new Audio(`/api/tts?${params}`);
3911
- audio.onended = () => resolve();
3912
- audio.onerror = () => reject(new Error('no se pudo reproducir (¿sin conexión?)'));
3913
- audio.play().catch(() => reject(new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo')));
4008
+ ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
4009
+ if (ap.current) renderAudio();
4010
+ else playNextAudio();
3914
4011
  });
3915
4012
  }
3916
4013
 
4014
+ function playNextAudio() {
4015
+ const item = ap.queue.shift();
4016
+ if (!item) {
4017
+ ap.current = null;
4018
+ renderAudio();
4019
+ return;
4020
+ }
4021
+ if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
4022
+ item.resolve?.();
4023
+ return playNextAudio();
4024
+ }
4025
+ ap.current = item;
4026
+ const params = new URLSearchParams({
4027
+ texto: item.texto,
4028
+ voice: item.voice ?? voz.settings.voice,
4029
+ rate: item.rate ?? voz.settings.rate,
4030
+ });
4031
+ ap.audio.src = `/api/tts?${params}`;
4032
+ ap.audio.play().catch(() => {
4033
+ advanceAudio(item, new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo'));
4034
+ });
4035
+ renderAudio();
4036
+ }
4037
+
4038
+ function advanceAudio(item, error) {
4039
+ if (!item || ap.current !== item) return;
4040
+ ap.current = null;
4041
+ if (error) item.reject?.(error);
4042
+ else item.resolve?.();
4043
+ playNextAudio();
4044
+ }
4045
+
4046
+ function toggleAudio() {
4047
+ if (!ap.current) return;
4048
+ if (ap.audio.paused) ap.audio.play().catch(() => {});
4049
+ else ap.audio.pause();
4050
+ }
4051
+
4052
+ /** Detiene lo que suena y vacía lo que espera. */
4053
+ function stopAudio() {
4054
+ for (const item of ap.queue) item.resolve?.();
4055
+ ap.queue = [];
4056
+ const current = ap.current;
4057
+ ap.current = null;
4058
+ current?.resolve?.();
4059
+ ap.audio.pause();
4060
+ ap.audio.removeAttribute('src');
4061
+ ap.audio.load();
4062
+ if (ap.barEl) ap.barEl.style.width = '0%';
4063
+ renderAudio();
4064
+ }
4065
+
4066
+ function renderAudio() {
4067
+ if (!ap.el || !ap.ready) return;
4068
+ const active = Boolean(ap.current) || ap.queue.length > 0;
4069
+ ap.el.hidden = !active;
4070
+ if (!active) return;
4071
+ ap.labelEl.textContent = ap.current?.label ?? 'audio';
4072
+ ap.queueEl.hidden = ap.queue.length === 0;
4073
+ ap.queueEl.textContent = ap.queue.length ? `+${ap.queue.length}` : '';
4074
+ const playing = Boolean(ap.current) && !ap.audio.paused;
4075
+ ap.iconEl.replaceChildren(icon(playing ? 'pause' : 'play', 14));
4076
+ ap.toggle.title = playing ? 'pausar' : 'reproducir';
4077
+ ap.toggle.setAttribute('aria-label', ap.toggle.title);
4078
+ }
4079
+
4080
+ /** Reproduce una frase contra el endpoint de sintesis del server. */
4081
+ function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
4082
+ return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
4083
+ }
4084
+
3917
4085
  /**
3918
- * Encola una frase para hablar (secuencial: una a la vez). Si el turno tarda y
3919
- * la frase quedó vieja en la cola, se descarta: narrar el pasado no informa.
4086
+ * Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
4087
+ * cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
3920
4088
  */
3921
4089
  function hablar(texto) {
3922
4090
  const frase = String(texto ?? '').trim();
3923
4091
  if (!frase || !voz.settings.enabled) return;
3924
- const creado = Date.now();
3925
- voz.cola = voz.cola
3926
- .then(() => (Date.now() - creado > 45000 ? null : reproducirVoz(frase)))
3927
- .catch(() => {});
4092
+ reproducirVoz(frase, { label: 'narrando', maxAgeMs: 45000 }).catch(() => {});
3928
4093
  }
3929
4094
 
3930
4095
  /** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
@@ -34,7 +34,7 @@
34
34
  </head>
35
35
  <body>
36
36
  <header class="topbar">
37
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.2</span></div>
37
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.4</span></div>
38
38
  <nav class="tabs">
39
39
  <button class="tab active" data-tab="chat">chat</button>
40
40
  <button class="tab" data-tab="providers">proveedores</button>
@@ -141,6 +141,7 @@
141
141
  <div id="toasts" class="toasts" aria-live="polite"></div>
142
142
  <div id="attach-preview" class="attach-preview" hidden></div>
143
143
  <div id="chat-queue" class="chat-queue" hidden></div>
144
+ <div id="audio-player" class="audio-player" hidden></div>
144
145
  <div class="composer-row">
145
146
  <span class="prompt-mark">❯</span>
146
147
  <button type="button" id="attach-image" class="btn icon" title="Adjuntar imagen (o pegala / arrastrala)" aria-label="adjuntar imagen">
@@ -451,7 +451,8 @@
451
451
 
452
452
  <p class="hero-copy">
453
453
  Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
454
- control desde tu máquina o el celular (túnel + QR o Telegram).
454
+ control desde tu máquina o el celular (túnel + QR o Telegram). Renderiza páginas con un
455
+ navegador headless, narra su progreso y hace trazable cada corrida.
455
456
  </p>
456
457
 
457
458
  <div class="actions">
@@ -472,6 +473,18 @@
472
473
  <div class="feature-icon">✓</div>
473
474
  <div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
474
475
  </div>
476
+ <div class="feature">
477
+ <div class="feature-icon">◎</div>
478
+ <div><b>Navegador</b><span>Screenshot y errores de consola de una página.</span></div>
479
+ </div>
480
+ <div class="feature">
481
+ <div class="feature-icon">♪</div>
482
+ <div><b>Voz</b><span>Narra el progreso y manda audio bajo pedido.</span></div>
483
+ </div>
484
+ <div class="feature">
485
+ <div class="feature-icon">⌘</div>
486
+ <div><b>Multi-modelo</b><span>OpenRouter, OpenAI, Claude, Gemini y Ollama.</span></div>
487
+ </div>
475
488
  </div>
476
489
  </section>
477
490
 
@@ -110,7 +110,9 @@ export function summarizeArgs(name, args, max = 240) {
110
110
  if ((name === 'write' || name === 'edit' || name === 'read') && a.path) {
111
111
  return a.content ? `${a.path} · ${humanSize(a.content)}` : String(a.path);
112
112
  }
113
- if ((name === 'fetch_url' || name === 'preview_url') && a.url) return cut(a.url);
113
+ if ((name === 'fetch_url' || name === 'preview_url' || name === 'screenshot' || name === 'console_errors') && a.url) {
114
+ return cut(a.url);
115
+ }
114
116
  if (name === 'task' && (a.description || a.prompt)) return cut(a.description || a.prompt);
115
117
  const keys = Object.keys(a);
116
118
  if (keys.length === 1 && typeof a[keys[0]] === 'string') return cut(a[keys[0]]);
@@ -521,7 +521,7 @@ body.resizing * { cursor: col-resize !important; }
521
521
  }
522
522
  .msg-menu[hidden] { display: none; }
523
523
  .msg-menu-sep { height: 1px; margin: 4px 6px; background: var(--border); }
524
- .msg-actions button.msg-act {
524
+ .msg-menu .msg-act {
525
525
  width: 100%;
526
526
  display: flex;
527
527
  align-items: center;
@@ -537,11 +537,24 @@ body.resizing * { cursor: col-resize !important; }
537
537
  }
538
538
  .msg-act .mi { flex: 0 0 auto; color: var(--muted); }
539
539
  .msg-act .mlabel { min-width: 0; }
540
- .msg-actions button.msg-act:hover { background: var(--theirs); }
541
- .msg-actions button.msg-act:hover .mi { color: var(--accent); }
542
- .msg-actions button.msg-act.danger { color: var(--danger); }
543
- .msg-actions button.msg-act.danger:hover { background: var(--theirs); }
544
- .msg-actions button.msg-act.danger:hover .mi { color: var(--danger); }
540
+ .msg-menu .msg-act:hover { background: var(--theirs); }
541
+ .msg-menu .msg-act:hover .mi { color: var(--accent); }
542
+ .msg-menu .msg-act.danger { color: var(--danger); }
543
+ .msg-menu .msg-act.danger:hover { background: var(--theirs); }
544
+ .msg-menu .msg-act.danger:hover .mi { color: var(--danger); }
545
+
546
+ /* En PC el menú del mensaje va en linea (una fila), con el mismo lenguaje
547
+ visual de la consola. En pantallas chicas queda como lista desplegable. */
548
+ @media (min-width: 821px) {
549
+ .msg-menu {
550
+ flex-direction: row;
551
+ align-items: center;
552
+ min-width: 0;
553
+ gap: 2px;
554
+ }
555
+ .msg-menu .msg-act { width: auto; white-space: nowrap; }
556
+ .msg-menu-sep { width: 1px; height: auto; align-self: stretch; margin: 6px 3px; }
557
+ }
545
558
  .bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; font-weight: 700; }
546
559
 
547
560
  .tools-actions { display: flex; align-items: center; gap: 8px; }
@@ -600,12 +613,84 @@ body.resizing * { cursor: col-resize !important; }
600
613
  overflow: auto;
601
614
  }
602
615
  .tool-card > .tool-out:empty { display: none; }
603
- .tool-card > .tool-audio {
604
- display: block;
605
- width: calc(100% - 28px);
606
- height: 32px;
616
+ .tool-card > .tool-audio-btn {
617
+ display: inline-flex;
618
+ align-items: center;
619
+ gap: 6px;
607
620
  margin: 4px 0 2px 18px;
621
+ padding: 4px 10px;
622
+ border: 1px solid var(--border);
623
+ border-radius: 999px;
624
+ background: transparent;
625
+ color: var(--accent);
626
+ cursor: pointer;
627
+ font: 600 12px var(--sans);
628
+ }
629
+ .tool-card > .tool-audio-btn:hover { background: var(--theirs); }
630
+ .tool-card > .tool-shot {
631
+ display: block;
632
+ max-width: calc(100% - 28px);
633
+ max-height: 420px;
634
+ width: auto;
635
+ margin: 6px 0 4px 18px;
636
+ border: 1px solid var(--border);
637
+ border-radius: 8px;
638
+ cursor: zoom-in;
639
+ background: #fff;
640
+ }
641
+
642
+ /* Reproductor único de audio (narración, speak, resumen): visible y detenible. */
643
+ .audio-player {
644
+ display: flex;
645
+ align-items: center;
646
+ gap: 10px;
647
+ padding: 7px 10px;
648
+ margin-bottom: 6px;
649
+ border: 1px solid var(--border);
650
+ border-radius: 10px;
651
+ background: var(--solid, var(--panel));
652
+ box-shadow: 0 6px 18px rgb(0 0 0 / 0.12);
608
653
  }
654
+ .audio-player[hidden] { display: none; }
655
+ .audio-player .ap-btn {
656
+ flex: 0 0 auto;
657
+ display: inline-grid;
658
+ place-items: center;
659
+ width: 28px;
660
+ height: 28px;
661
+ padding: 0;
662
+ border: 1px solid var(--border);
663
+ border-radius: 999px;
664
+ background: transparent;
665
+ color: var(--accent);
666
+ cursor: pointer;
667
+ }
668
+ .audio-player .ap-btn:hover { background: var(--theirs); }
669
+ .audio-player .ap-stop { color: var(--danger); }
670
+ .audio-player .ap-info { flex: 1 1 auto; min-width: 0; }
671
+ .audio-player .ap-label {
672
+ display: block;
673
+ color: var(--muted);
674
+ font: 600 11px var(--sans);
675
+ text-transform: uppercase;
676
+ letter-spacing: 0.04em;
677
+ margin-bottom: 4px;
678
+ }
679
+ .audio-player .ap-progress {
680
+ height: 4px;
681
+ border-radius: 999px;
682
+ background: var(--border);
683
+ overflow: hidden;
684
+ }
685
+ .audio-player .ap-bar {
686
+ width: 0%;
687
+ height: 100%;
688
+ border-radius: 999px;
689
+ background: var(--accent);
690
+ transition: width 0.15s linear;
691
+ }
692
+ .audio-player .ap-queue { flex: 0 0 auto; color: var(--muted); font: 600 11px var(--sans); }
693
+ .audio-player .ap-queue[hidden] { display: none; }
609
694
  .tool-more {
610
695
  display: inline-block;
611
696
  margin: 2px 0 2px 18px;
@@ -946,7 +1031,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
946
1031
  left: 14px;
947
1032
  right: 14px;
948
1033
  bottom: calc(100% - 6px);
949
- max-height: 260px;
1034
+ max-height: min(60vh, 420px);
950
1035
  overflow-y: auto;
951
1036
  display: flex;
952
1037
  flex-direction: column;
@@ -954,8 +1039,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
954
1039
  padding: 6px;
955
1040
  border: 1px solid var(--border);
956
1041
  border-radius: 12px;
957
- background: var(--panel);
1042
+ background: var(--solid, var(--panel));
958
1043
  box-shadow: 0 12px 32px rgb(0 0 0 / 0.22);
1044
+ backdrop-filter: blur(18px);
959
1045
  z-index: 30;
960
1046
  }
961
1047
  .slash-menu[hidden] { display: none; }
@@ -6,10 +6,10 @@ import { summarizeToolCall, previewToolResult } from './core.js';
6
6
 
7
7
  const FAILED_RE = /^Error|^Permiso denegado/i;
8
8
 
9
- /** Payload estandar de un tool_result para eventos SSE: preview + failed. */
9
+ /** Payload estandar de un tool_result para eventos SSE: preview + failed (+ meta). */
10
10
  export function toolResultPayload(event) {
11
11
  const { preview, truncated } = previewToolResult(event.name, event.result);
12
- return { preview, truncated, failed: FAILED_RE.test(String(event.result)) };
12
+ return { preview, truncated, failed: FAILED_RE.test(String(event.result)), meta: event.meta };
13
13
  }
14
14
 
15
15
  export function toolCallPayload(event) {