@danieltmn/tamnora 0.4.1 → 0.5.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 CHANGED
@@ -5,6 +5,45 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.5.0] - 2026-10-02
9
+
10
+ ### Agregado
11
+
12
+ - Preview de **sitios estáticos**: si la carpeta del proyecto tiene un `index.html`
13
+ (o `public/index.html`) y no hay comando de dev, `detectar` en la pestaña *procs*
14
+ ofrece un chip "sitio estático (8080)" que lo sirve con un servidor local sin
15
+ dependencias (`harness/scripts/static-server.mjs`). Desde *preview* se ve embebido
16
+ y con *túnel* se abre la URL pública para verlo desde el celular.
17
+ - El **visor de archivos** agrega un botón "vista previa": para `.html`/`.svg` sirve
18
+ el workspace con el servidor estático en un iframe (con sus CSS, JS e imágenes
19
+ relativas) y para `.md` renderiza el markdown.
20
+ - La vista previa (pestaña *preview* y visor de archivos) suma selector de tamaño
21
+ **móvil/tablet/laptop/pc/adaptable** y botón **maximizar** (con `Esc` para salir).
22
+ - Los botones para mostrar/ocultar los paneles laterales pasan a estar dentro de
23
+ cada panel: *sesiones* tiene su botón de colapsar y, al ocultarse, aparece un
24
+ tirador en el borde para volver a mostrarlo (igual para *archivos*).
25
+ - `/new` con una sesión activa abre otra sesión en la **misma carpeta**; el botón
26
+ "+ nueva sesión" (y `/new` sin sesión) abre el selector de carpetas.
27
+ - **Carpeta predeterminada** configurable en Ajustes: el selector de carpetas abre
28
+ ahí por defecto (fallback a la casa si la ruta ya no existe).
29
+ - El **pie del chat** muestra el uso de contexto del último turno (`contexto 12% ·
30
+ 53k/1.0M`) con una **barra de porcentaje** que cambia de color (verde/ámbar/rojo).
31
+
32
+ ### Cambiado
33
+
34
+ - Las pestañas (chat, proveedores, ajustes) quedan **centradas** en la barra superior.
35
+ `eval` y `runs` salen del topbar y quedan en el menú "más" (los tres puntitos, visible
36
+ en desktop), para des-énfasis en la consola; el CLI y la API siguen igual. En mobile
37
+ el menú "más" agrupa las cinco y muestra la pestaña activa.
38
+
39
+ ### Corregido
40
+
41
+ - `normalizeModel` no leía `context_window`, el campo que usa DeepSeek para informar
42
+ la ventana de contexto (y que sí trae `context_length` en otros proveedores). Sin
43
+ esto, el footer nunca mostraba el porcentaje de contexto.
44
+ - "Gestionar proveedores" (Ajustes y paleta) abría una vista inexistente por usar
45
+ `proveedores` en vez de `providers`; ahora entran al panel correcto y lo refrescan.
46
+
8
47
  ## [0.4.1] - 2026-10-02
9
48
 
10
49
  ### Corregido
package/README.md CHANGED
@@ -63,6 +63,31 @@ Tamnora (override con `--home` o `TAMNORA_HOME`):
63
63
  - macOS: `~/Library/Application Support/tamnora`
64
64
  - Linux: `~/.config/tamnora`
65
65
 
66
+ ### Consola web
67
+
68
+ `tamnora serve` sirve la consola en `http://127.0.0.1:4517`. Las pestanas
69
+ principales son `chat`, `proveedores` y `ajustes`; `eval` y `runs` viven en el
70
+ menu "mas" (los tres puntitos). En el chat:
71
+
72
+ - **Paneles laterales** de sesiones y archivos, colapsables desde cada panel (con
73
+ un tirador en el borde para reabrirlos).
74
+ - **Pie** con modo, proveedor, modelo, estado y el **uso de contexto** del ultimo
75
+ turno (`contexto 44% · 464k/1.0M`) con una barra que cambia de color.
76
+ - **`/new`** abre otra sesion en la **misma carpeta** de la sesion activa; el boton
77
+ "+ nueva sesion" (y `/new` sin sesion) abre el selector de carpetas. La
78
+ **carpeta predeterminada** se elige en Ajustes.
79
+
80
+ ### Preview
81
+
82
+ La consola puede levantar un dev server (deteccion de scripts `dev`/`start`,
83
+ Laravel, PHP) y abrir un tunel al puerto para verlo desde el celular. Para sitios
84
+ **estaticos** (un `index.html` sin script de dev) ofrece un chip "sitio estatico"
85
+ que los sirve con un servidor sin dependencias (`harness/scripts/static-server.mjs`).
86
+
87
+ El visor de archivos tiene **vista previa**: `.html`/`.svg` se sirven con sus CSS,
88
+ JS e imagenes relativas, y `.md` se renderiza como markdown. Ambas vistas suman
89
+ selector de tamano (movil/tablet/laptop/pc/adaptable) y boton para maximizar.
90
+
66
91
  ### Login
67
92
 
68
93
  ```bash
@@ -80,5 +80,5 @@ export {
80
80
  normalizeMcpConfig,
81
81
  } from './mcp/registry.js';
82
82
  export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
83
- export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from './procs/detect.js';
83
+ export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
84
84
  export { readPackageVersion } from './version.js';
@@ -2,6 +2,7 @@
2
2
  // package.json, composer/artisan y entrypoints PHP.
3
3
 
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import path from 'node:path';
6
7
 
7
8
  export const DEFAULT_PROCESS_ALLOW = [
@@ -20,6 +21,26 @@ export const DEFAULT_PROCESS_ALLOW = [
20
21
 
21
22
  const SCRIPT_ORDER = ['dev', 'start', 'serve', 'preview'];
22
23
 
24
+ const STATIC_SERVER = fileURLToPath(new URL('../../../../scripts/static-server.mjs', import.meta.url));
25
+ const STATIC_PORT = 8080;
26
+
27
+ function findStaticRoot(dir) {
28
+ if (existsSync(path.join(dir, 'index.html'))) return '.';
29
+ if (existsSync(path.join(dir, 'public', 'index.html'))) return 'public';
30
+ return null;
31
+ }
32
+
33
+ /** Chip para servir `dir` como sitio estático con el servidor sin dependencias. */
34
+ export function staticServerChip(dir, { port = STATIC_PORT, label } = {}) {
35
+ return {
36
+ label: label ?? `sitio estático (${port})`,
37
+ command: process.execPath,
38
+ args: [STATIC_SERVER, dir, String(port)],
39
+ cwd: dir,
40
+ port,
41
+ };
42
+ }
43
+
23
44
  function readJson(file) {
24
45
  try {
25
46
  return JSON.parse(readFileSync(file, 'utf8'));
@@ -65,5 +86,13 @@ export function detectProcs(dir) {
65
86
  break;
66
87
  }
67
88
 
89
+ if (!chips.length) {
90
+ const staticRoot = findStaticRoot(dir);
91
+ if (staticRoot) {
92
+ const target = staticRoot === '.' ? dir : path.join(dir, staticRoot);
93
+ chips.push(staticServerChip(target));
94
+ }
95
+ }
96
+
68
97
  return chips;
69
98
  }
@@ -99,8 +99,14 @@ export function normalizeModel(model) {
99
99
  (typeof modality === 'string' && modality.includes('image')) ||
100
100
  (Array.isArray(model.input_modalities) && model.input_modalities.includes('image'));
101
101
  const context =
102
- Number(model.context_length ?? model.context_length_tokens ?? model.max_context_length ?? model.context ?? 0) ||
103
- null;
102
+ Number(
103
+ model.context_length ??
104
+ model.context_length_tokens ??
105
+ model.max_context_length ??
106
+ model.context_window ??
107
+ model.context ??
108
+ 0
109
+ ) || null;
104
110
  return { id, label, vision, context };
105
111
  }
106
112
 
@@ -32,3 +32,20 @@ test('detectProcs falls back to npm and detects php entrypoints', async () => {
32
32
  assert.ok(php);
33
33
  assert.equal(php.args[0], '-S');
34
34
  });
35
+
36
+ test('detectProcs offers a static server for plain HTML sites', async () => {
37
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
38
+ await writeFile(path.join(dir, 'index.html'), '<h1>hola</h1>');
39
+ const chips = detectProcs(dir);
40
+ const staticChip = chips.find((c) => c.port && c.args.some((a) => a.endsWith('static-server.mjs')));
41
+ assert.ok(staticChip);
42
+ assert.equal(staticChip.cwd, dir);
43
+ assert.ok(staticChip.args.includes(dir));
44
+ });
45
+
46
+ test('detectProcs does not add the static chip when there is a dev script', async () => {
47
+ const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
48
+ await writeFile(path.join(dir, 'index.html'), '<h1>hola</h1>');
49
+ await writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { dev: 'vite' } }));
50
+ assert.ok(!detectProcs(dir).some((c) => c.args.some((a) => String(a).endsWith('static-server.mjs'))));
51
+ });
@@ -3,9 +3,16 @@ import assert from 'node:assert/strict';
3
3
  import { Store } from '../src/storage/sqlite.js';
4
4
  import { composeProviderSettings } from '../src/config/providers.js';
5
5
  import { toOllamaMessages } from '../src/providers/ollama.js';
6
- import { providerFetch } from '../src/providers/utils.js';
6
+ import { providerFetch, normalizeModel } from '../src/providers/utils.js';
7
7
  import { isRetryableError } from '../src/providers/resilience.js';
8
8
 
9
+ test('normalizeModel reads the context window across provider shapes', () => {
10
+ assert.equal(normalizeModel({ id: 'a', context_window: 1048576 }).context, 1048576);
11
+ assert.equal(normalizeModel({ id: 'b', context_length: 128000 }).context, 128000);
12
+ assert.equal(normalizeModel({ id: 'c', max_context_length: 64000 }).context, 64000);
13
+ assert.equal(normalizeModel({ id: 'd' }).context, null);
14
+ });
15
+
9
16
  test('Store persists providers and settings', () => {
10
17
  const store = new Store(':memory:');
11
18
  try {
@@ -154,6 +154,9 @@ async function streamPost(url, body, onEvent, signal) {
154
154
 
155
155
  const moreTab = $('#more-tab');
156
156
  const moreMenu = $('#more-menu');
157
+ const moreTabLabel = $('#more-tab-label');
158
+
159
+ const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
157
160
 
158
161
  function hideMoreMenu() {
159
162
  moreMenu.hidden = true;
@@ -161,12 +164,15 @@ function hideMoreMenu() {
161
164
  }
162
165
 
163
166
  function activateTab(name) {
164
- document.querySelectorAll('.tab[data-tab]').forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
167
+ document
168
+ .querySelectorAll('.tab[data-tab], #more-menu button[data-tab]')
169
+ .forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
170
+ if (moreTabLabel) moreTabLabel.textContent = TAB_LABELS[name] || name;
165
171
  moreTab.classList.toggle('active', name === 'eval' || name === 'runs');
166
172
  document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
167
173
  hideMoreMenu();
168
174
  if (name === 'runs') refreshRuns();
169
- if (name === 'proveedores') refreshProviders();
175
+ if (name === 'providers') refreshProviders();
170
176
  if (name === 'ajustes') { refreshMcp(); refreshSkills(); }
171
177
  }
172
178
 
@@ -193,12 +199,23 @@ document.addEventListener('keydown', (event) => {
193
199
 
194
200
  const settingsModal = $('#settings-modal');
195
201
  if (settingsModal) {
196
- $('#settings-toggle')?.addEventListener('click', () => settingsModal.showModal());
202
+ $('#settings-toggle')?.addEventListener('click', () => {
203
+ syncDefaultCwd();
204
+ settingsModal.showModal();
205
+ });
197
206
  $('#settings-close')?.addEventListener('click', () => settingsModal.close());
198
207
  $('#settings-done')?.addEventListener('click', () => settingsModal.close());
199
208
  $('#settings-providers')?.addEventListener('click', () => {
200
209
  settingsModal.close();
201
- activateTab('proveedores');
210
+ activateTab('providers');
211
+ });
212
+ $('#default-cwd-pick')?.addEventListener('click', () => {
213
+ settingsModal.close();
214
+ openFolderPicker('default');
215
+ });
216
+ $('#default-cwd-clear')?.addEventListener('click', () => {
217
+ writeDefaultCwd('');
218
+ syncDefaultCwd();
202
219
  });
203
220
  settingsModal.addEventListener('click', (event) => {
204
221
  if (event.target === settingsModal) settingsModal.close();
@@ -213,8 +230,8 @@ const PALETTE_COMMANDS = [
213
230
  { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
214
231
  { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
215
232
  { id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
216
- { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => settingsModal?.showModal() },
217
- { id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('proveedores') },
233
+ { id: 'settings', label: 'Ajustes del agente (modelo, modo, auto)', run: () => { syncDefaultCwd(); settingsModal?.showModal(); } },
234
+ { id: 'providers', label: 'Gestionar proveedores', run: () => activateTab('providers') },
218
235
  { id: 'ajustes', label: 'MCP y Skills (ajustes)', run: () => activateTab('ajustes') },
219
236
  { id: 'explorer', label: 'Mostrar u ocultar archivos', run: () => setExplorerOpen(!state.explorerOpen) },
220
237
  { id: 'sidebar', label: 'Mostrar u ocultar sesiones', run: () => setSidebarOpen(!state.sidebarOpen) },
@@ -514,7 +531,11 @@ function setSidebarOpen(open) {
514
531
  panels.sidebar.resizer.hidden = !open;
515
532
  if (sidebarBackdrop) sidebarBackdrop.hidden = true;
516
533
  }
517
- $('#sidebar-toggle').setAttribute('aria-pressed', String(open));
534
+ const handle = $('#sidebar-handle');
535
+ if (handle) {
536
+ handle.hidden = open;
537
+ handle.setAttribute('aria-pressed', String(open));
538
+ }
518
539
  if (!isMobile()) {
519
540
  try {
520
541
  localStorage.setItem('tamnora-sidebar-open', open ? '1' : '0');
@@ -543,7 +564,8 @@ function initLayout() {
543
564
  attachResizer(panels.sidebar);
544
565
  attachResizer(panels.explorer);
545
566
  setSidebarOpen(isMobile() ? false : readSidebarPref());
546
- $('#sidebar-toggle').addEventListener('click', () => setSidebarOpen(!state.sidebarOpen));
567
+ $('#sidebar-collapse')?.addEventListener('click', () => setSidebarOpen(false));
568
+ $('#sidebar-handle')?.addEventListener('click', () => setSidebarOpen(true));
547
569
  if (sidebarBackdrop) sidebarBackdrop.addEventListener('click', () => setSidebarOpen(false));
548
570
  mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
549
571
  }
@@ -558,29 +580,37 @@ function formatK(n) {
558
580
  }
559
581
 
560
582
  function renderTokens() {
583
+ const wrap = $('#sb-context');
561
584
  const node = $('#sb-tokens');
585
+ const fill = $('#sb-context-fill');
562
586
  const record = state.panes.get(state.sessionId);
563
587
  const tokens = record?.tokens ?? { in: 0, out: 0 };
564
588
  const lastTurn = record?.lastTurn ?? { prompt: 0, completion: 0 };
565
589
  const total = tokens.in + tokens.out;
590
+ const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
591
+ const used = lastTurn.prompt + lastTurn.completion || total;
592
+ const pct = context ? Math.min(100, Math.round((used / context) * 100)) : 0;
593
+ const tone = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
594
+
566
595
  if (!total) {
567
- node.textContent = 'tokens 0';
568
- node.className = 'muted';
596
+ if (wrap) wrap.className = 'ctx muted';
597
+ node.textContent = 'tokens —';
569
598
  node.title = '';
599
+ if (fill) fill.style.width = '0%';
570
600
  return;
571
601
  }
572
- node.textContent = `tokens ${formatK(total)}`;
573
- const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
574
- const used = lastTurn.prompt + lastTurn.completion || total;
575
- node.title =
602
+ const parts =
576
603
  `entrada ${formatK(tokens.in)} · salida ${formatK(tokens.out)}` +
577
604
  (context ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
578
- if (!context) {
579
- node.className = 'muted';
580
- return;
605
+ if (context) {
606
+ node.textContent = `contexto ${pct}% · ${formatK(used)}/${formatK(context)}`;
607
+ if (wrap) wrap.className = `ctx ${tone}`;
608
+ } else {
609
+ node.textContent = `tokens ${formatK(total)}`;
610
+ if (wrap) wrap.className = 'ctx muted';
581
611
  }
582
- const pct = (used / context) * 100;
583
- node.className = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
612
+ node.title = parts;
613
+ if (fill) fill.style.width = `${context ? pct : 0}%`;
584
614
  }
585
615
 
586
616
  function addTokens(tokens) {
@@ -1511,9 +1541,16 @@ async function runCommand(name, arg) {
1511
1541
  notify(`### Comandos\n${COMMANDS.map(([cmd, desc]) => `- \`${cmd}\` — ${desc}`).join('\n')}`);
1512
1542
  return { handled: true };
1513
1543
  case 'new':
1514
- case 'nueva':
1515
- openFolderPicker();
1544
+ case 'nueva': {
1545
+ const cwd = state.sessionId ? state.cwd : '';
1546
+ if (cwd) {
1547
+ const created = await createSessionIn(cwd);
1548
+ if (created?.error) notify(`No se pudo crear la sesión: ${created.error}`, 'notice err');
1549
+ } else {
1550
+ openFolderPicker();
1551
+ }
1516
1552
  return { handled: true };
1553
+ }
1517
1554
  case 'init':
1518
1555
  return { handled: false, input: INIT_PROMPT };
1519
1556
  case 'cwd': {
@@ -1572,6 +1609,31 @@ let folderListPath = null;
1572
1609
  let folderParent = null;
1573
1610
  let folderDirs = [];
1574
1611
 
1612
+ const DEFAULT_CWD_KEY = 'tamnora-default-cwd';
1613
+ let folderPickerMode = 'session';
1614
+
1615
+ function readDefaultCwd() {
1616
+ try {
1617
+ return localStorage.getItem(DEFAULT_CWD_KEY) || '';
1618
+ } catch {
1619
+ return '';
1620
+ }
1621
+ }
1622
+
1623
+ function writeDefaultCwd(value) {
1624
+ try {
1625
+ if (value) localStorage.setItem(DEFAULT_CWD_KEY, value);
1626
+ else localStorage.removeItem(DEFAULT_CWD_KEY);
1627
+ } catch {
1628
+ // storage unavailable
1629
+ }
1630
+ }
1631
+
1632
+ function syncDefaultCwd() {
1633
+ const input = $('#default-cwd');
1634
+ if (input) input.value = readDefaultCwd();
1635
+ }
1636
+
1575
1637
  const FOLDER_PATH = 'M3.5 6.3A1.3 1.3 0 0 1 4.8 5h3.1l1.5 1.7h7.8a1.3 1.3 0 0 1 1.3 1.3v7.7a1.3 1.3 0 0 1-1.3 1.3H4.8a1.3 1.3 0 0 1-1.3-1.3z';
1576
1638
 
1577
1639
  function folderIcon() {
@@ -1597,11 +1659,20 @@ function closeFolderPicker() {
1597
1659
  if (folderModal.open) folderModal.close();
1598
1660
  }
1599
1661
 
1600
- function openFolderPicker() {
1662
+ function openFolderPicker(mode = 'session') {
1663
+ folderPickerMode = mode === 'default' ? 'default' : 'session';
1601
1664
  folderGoto.value = '';
1602
1665
  setFolderStatus('');
1603
1666
  folderModal.showModal();
1604
- loadFolders(state.home || '');
1667
+ const def = readDefaultCwd();
1668
+ if (!def) return loadFolders(state.home || '');
1669
+ api(`/api/folders?path=${encodeURIComponent(def)}`)
1670
+ .then(() => loadFolders(def))
1671
+ .catch(() => {
1672
+ writeDefaultCwd('');
1673
+ syncDefaultCwd();
1674
+ loadFolders(state.home || '');
1675
+ });
1605
1676
  }
1606
1677
 
1607
1678
  function renderCrumbs(pathStr) {
@@ -1667,21 +1738,21 @@ async function loadFolders(target = '') {
1667
1738
  $('#folder-up').disabled = !data.path;
1668
1739
  $('#folder-new').disabled = !data.path;
1669
1740
  folderUseBtn.disabled = !data.path;
1670
- folderUseBtn.textContent = data.path ? `usar «${basename(data.path)}»` : 'usar esta carpeta';
1741
+ if (folderPickerMode === 'default') {
1742
+ folderUseBtn.textContent = data.path ? `predeterminada: «${basename(data.path)}»` : 'usar como predeterminada';
1743
+ } else {
1744
+ folderUseBtn.textContent = data.path ? `usar «${basename(data.path)}»` : 'usar esta carpeta';
1745
+ }
1671
1746
  renderFolderList(folderDirs);
1672
1747
  const count = data.path ? `${folderDirs.length} subcarpetas` : `${folderDirs.length} unidades`;
1673
1748
  setFolderStatus(data.path ? `${count} · clic para entrar` : 'elegí una unidad');
1674
1749
  }
1675
1750
 
1676
- async function useFolder() {
1677
- const cwd = folderListPath;
1678
- if (!cwd) return setFolderStatus('Entrá a una carpeta primero (clic para navegar).', 'err');
1679
- const created = await api('/api/sessions', { method: 'POST', body: { title: 'Nueva sesión', cwd } }).catch((error) => {
1680
- setFolderStatus(error.message, 'err');
1681
- return null;
1682
- });
1683
- if (!created) return;
1684
- closeFolderPicker();
1751
+ async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
1752
+ const created = await api('/api/sessions', { method: 'POST', body: { title, cwd } }).catch((error) => ({
1753
+ error: error.message,
1754
+ }));
1755
+ if (created?.error) return created;
1685
1756
  registerSession(created.session, []);
1686
1757
  activateSession(created.session.id);
1687
1758
  const pending = state.pendingInput;
@@ -1690,6 +1761,21 @@ async function useFolder() {
1690
1761
  chatInput.value = pending;
1691
1762
  sendMessage();
1692
1763
  }
1764
+ return created;
1765
+ }
1766
+
1767
+ async function useFolder() {
1768
+ const cwd = folderListPath;
1769
+ if (!cwd) return setFolderStatus('Entrá a una carpeta primero (clic para navegar).', 'err');
1770
+ if (folderPickerMode === 'default') {
1771
+ writeDefaultCwd(cwd);
1772
+ syncDefaultCwd();
1773
+ closeFolderPicker();
1774
+ return;
1775
+ }
1776
+ const created = await createSessionIn(cwd);
1777
+ if (created?.error) return setFolderStatus(created.error, 'err');
1778
+ closeFolderPicker();
1693
1779
  }
1694
1780
 
1695
1781
  function filterFolders() {
@@ -2380,7 +2466,6 @@ const explorerTree = $('#explorer-tree');
2380
2466
  const explorerRoot = $('#explorer-root');
2381
2467
  const explorerFilter = $('#explorer-filter');
2382
2468
  const explorerStatus = $('#explorer-status');
2383
- const explorerToggle = $('#explorer-toggle');
2384
2469
 
2385
2470
  function setExplorerStatus(text, isError = false) {
2386
2471
  explorerStatus.textContent = text;
@@ -2391,8 +2476,11 @@ function setExplorerOpen(open) {
2391
2476
  state.explorerOpen = open;
2392
2477
  explorer.hidden = !open;
2393
2478
  panels.explorer.resizer.hidden = !open;
2394
- explorerToggle.setAttribute('aria-pressed', String(open));
2395
- explorerToggle.classList.toggle('active', open);
2479
+ const handle = $('#explorer-handle');
2480
+ if (handle) {
2481
+ handle.hidden = open;
2482
+ handle.setAttribute('aria-pressed', String(open));
2483
+ }
2396
2484
  try {
2397
2485
  localStorage.setItem('tamnora-explorer-open', open ? '1' : '0');
2398
2486
  } catch {
@@ -2514,6 +2602,22 @@ function refreshTwisty(li, open) {
2514
2602
  twisty.appendChild(icon(open ? 'chevron-down' : 'chevron-right', 14));
2515
2603
  }
2516
2604
 
2605
+ const filePreviewState = { current: null, kind: null, loaded: false, open: false, content: '' };
2606
+
2607
+ const filePreviewTools = setupPreviewTools({
2608
+ container: $('#file-modal'),
2609
+ stage: $('#file-stage'),
2610
+ frame: $('#file-frame'),
2611
+ select: $('#file-device'),
2612
+ maxButton: $('#file-max'),
2613
+ });
2614
+
2615
+ function filePreviewKind(name) {
2616
+ if (/\.(html?|svg)$/i.test(name)) return 'frame';
2617
+ if (/\.(md|markdown)$/i.test(name)) return 'markdown';
2618
+ return null;
2619
+ }
2620
+
2517
2621
  async function openFilePreview(relPath, name) {
2518
2622
  const params = new URLSearchParams({ root: state.cwd, path: relPath });
2519
2623
  const data = await api(`/api/files/content?${params}`).catch((error) => {
@@ -2524,8 +2628,88 @@ async function openFilePreview(relPath, name) {
2524
2628
  $('#file-title').textContent = name;
2525
2629
  $('#file-meta').textContent = `${data.size} bytes`;
2526
2630
  $('#file-content').textContent = data.content;
2527
- $('#file-modal').showModal();
2528
- }
2631
+
2632
+ const toggle = $('#file-preview-toggle');
2633
+ const device = $('#file-device');
2634
+ const maxButton = $('#file-max');
2635
+ const modal = $('#file-modal');
2636
+ filePreviewState.current = { relPath, name };
2637
+ filePreviewState.kind = filePreviewKind(name);
2638
+ filePreviewState.loaded = false;
2639
+ filePreviewState.open = false;
2640
+ filePreviewState.content = data.content;
2641
+
2642
+ toggle.hidden = !filePreviewState.kind;
2643
+ toggle.textContent = 'vista previa';
2644
+ device.hidden = true;
2645
+ device.value = 'auto';
2646
+ maxButton.hidden = true;
2647
+ $('#file-frame').src = 'about:blank';
2648
+ $('#file-stage').hidden = true;
2649
+ $('#file-render').hidden = true;
2650
+ $('#file-render').innerHTML = '';
2651
+ $('#file-content').hidden = false;
2652
+ modal.classList.remove('preview-maximized');
2653
+ filePreviewTools.applyDevice();
2654
+
2655
+ modal.showModal();
2656
+ }
2657
+
2658
+ $('#file-preview-toggle')?.addEventListener('click', async () => {
2659
+ const toggle = $('#file-preview-toggle');
2660
+ const content = $('#file-content');
2661
+ const stage = $('#file-stage');
2662
+ const frame = $('#file-frame');
2663
+ const render = $('#file-render');
2664
+ const device = $('#file-device');
2665
+ const maxButton = $('#file-max');
2666
+ const current = filePreviewState.current;
2667
+ if (!current) return;
2668
+
2669
+ filePreviewState.open = !filePreviewState.open;
2670
+ const off = () => {
2671
+ toggle.textContent = 'vista previa';
2672
+ content.hidden = false;
2673
+ stage.hidden = true;
2674
+ render.hidden = true;
2675
+ device.hidden = true;
2676
+ maxButton.hidden = true;
2677
+ $('#file-modal').classList.remove('preview-maximized');
2678
+ };
2679
+ if (!filePreviewState.open) return off();
2680
+
2681
+ if (filePreviewState.kind === 'markdown') {
2682
+ render.innerHTML = renderMarkdown(filePreviewState.content);
2683
+ content.hidden = true;
2684
+ stage.hidden = true;
2685
+ render.hidden = false;
2686
+ device.hidden = true;
2687
+ maxButton.hidden = true;
2688
+ toggle.textContent = 'ver código';
2689
+ return;
2690
+ }
2691
+
2692
+ device.hidden = false;
2693
+ maxButton.hidden = false;
2694
+ if (!filePreviewState.loaded) {
2695
+ const data = await api('/api/files/preview', {
2696
+ method: 'POST',
2697
+ body: { root: state.cwd, path: current.relPath },
2698
+ }).catch((error) => ({ error: error.message }));
2699
+ if (data?.error) {
2700
+ setExplorerStatus(data.error, true);
2701
+ filePreviewState.open = false;
2702
+ return off();
2703
+ }
2704
+ frame.src = data.url;
2705
+ filePreviewState.loaded = true;
2706
+ filePreviewTools.applyDevice();
2707
+ }
2708
+ toggle.textContent = 'ver código';
2709
+ content.hidden = true;
2710
+ render.hidden = true;
2711
+ stage.hidden = false;
2712
+ });
2529
2713
 
2530
2714
  /* --- explorer panes: estructura / preview / procs ------------------------- */
2531
2715
 
@@ -2558,6 +2742,35 @@ function setPreviewStatus(text, isError = false) {
2558
2742
  previewStatusEl.classList.toggle('err', isError);
2559
2743
  }
2560
2744
 
2745
+ /* Ancho por dispositivo + maximizar, compartido por preview y visor de archivos. */
2746
+ function setupPreviewTools({ container, stage, frame, select, maxButton }) {
2747
+ const applyDevice = () => {
2748
+ const value = select?.value || 'auto';
2749
+ if (stage) stage.dataset.device = value;
2750
+ frame.style.width = value === 'auto' ? '100%' : `${value}px`;
2751
+ };
2752
+ select?.addEventListener('change', applyDevice);
2753
+ const setMax = (on) => {
2754
+ container?.classList.toggle('preview-maximized', on);
2755
+ maxButton?.setAttribute('aria-pressed', String(on));
2756
+ if (maxButton) maxButton.title = on ? 'restaurar' : 'maximizar';
2757
+ };
2758
+ maxButton?.addEventListener('click', () => setMax(!container?.classList.contains('preview-maximized')));
2759
+ document.addEventListener('keydown', (event) => {
2760
+ if (event.key === 'Escape' && container?.classList.contains('preview-maximized')) setMax(false);
2761
+ });
2762
+ return { applyDevice, setMax };
2763
+ }
2764
+
2765
+ const previewTools = setupPreviewTools({
2766
+ container: $('#xp-preview'),
2767
+ stage: $('#preview-stage'),
2768
+ frame: previewFrame,
2769
+ select: $('#preview-device'),
2770
+ maxButton: $('#preview-max'),
2771
+ });
2772
+ previewTools.applyDevice();
2773
+
2561
2774
  function loadPreview(url) {
2562
2775
  const value = String(url ?? '').trim();
2563
2776
  if (!value) return setPreviewStatus('Poné una URL (p. ej. http://127.0.0.1:5173).', true);
@@ -2664,7 +2877,7 @@ async function refreshProcLog() {
2664
2877
 
2665
2878
  $('#procs-detect')?.addEventListener('click', detectProcs);
2666
2879
 
2667
- explorerToggle.addEventListener('click', () => setExplorerOpen(!state.explorerOpen));
2880
+ $('#explorer-handle')?.addEventListener('click', () => setExplorerOpen(true));
2668
2881
  $('#explorer-close').addEventListener('click', () => setExplorerOpen(false));
2669
2882
  $('#explorer-refresh').addEventListener('click', refreshExplorerMode);
2670
2883
  explorerFilter.addEventListener('input', applyTreeFilter);
@@ -33,7 +33,7 @@
33
33
  </head>
34
34
  <body>
35
35
  <header class="topbar">
36
- <div class="brand"><span class="logo">T</span> tamnora <span class="ver">v0.4.1</span></div>
36
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.5.0</span></div>
37
37
  <nav class="tabs">
38
38
  <button class="tab active" data-tab="chat">chat</button>
39
39
  <button class="tab" data-tab="providers">proveedores</button>
@@ -45,8 +45,12 @@
45
45
  <circle cx="19" cy="12" r="1" />
46
46
  <circle cx="5" cy="12" r="1" />
47
47
  </svg>
48
+ <span id="more-tab-label">chat</span>
48
49
  </button>
49
50
  <div id="more-menu" class="menu-pop" hidden>
51
+ <button type="button" class="menu-primary" data-tab="chat">chat</button>
52
+ <button type="button" class="menu-primary" data-tab="providers">proveedores</button>
53
+ <button type="button" class="menu-primary" data-tab="ajustes">ajustes</button>
50
54
  <button type="button" data-tab="eval">eval</button>
51
55
  <button type="button" data-tab="runs">runs</button>
52
56
  </div>
@@ -65,18 +69,6 @@
65
69
  <path d="M14 2v4M8 10v4M16 18v4" />
66
70
  </svg>
67
71
  </button>
68
- <button id="sidebar-toggle" type="button" class="btn icon" title="mostrar/ocultar sesiones" aria-label="mostrar u ocultar el panel de sesiones" aria-pressed="true">
69
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
70
- <rect width="18" height="18" x="3" y="3" rx="2" />
71
- <path d="M9 3v18" />
72
- </svg>
73
- </button>
74
- <button id="explorer-toggle" type="button" class="btn icon" title="mostrar/ocultar archivos" aria-label="mostrar u ocultar el panel de archivos" aria-pressed="false">
75
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
76
- <rect width="18" height="18" x="3" y="3" rx="2" />
77
- <path d="M15 3v18" />
78
- </svg>
79
- </button>
80
72
  <button id="theme-toggle" type="button" class="btn icon" title="cambiar tema" aria-label="cambiar tema">
81
73
  <svg class="ico-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
82
74
  <circle cx="12" cy="12" r="4" />
@@ -92,6 +84,14 @@
92
84
  <main>
93
85
  <section id="view-chat" class="view active">
94
86
  <aside id="sidebar" class="sidebar">
87
+ <div class="panel-head">
88
+ <span class="panel-head-title">sesiones</span>
89
+ <button id="sidebar-collapse" type="button" class="btn icon" title="ocultar panel de sesiones" aria-label="ocultar panel de sesiones">
90
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
91
+ <path d="m15 18-6-6 6-6" />
92
+ </svg>
93
+ </button>
94
+ </div>
95
95
  <button id="new-session" class="btn block">+ nueva sesión</button>
96
96
  <ul id="sessions" class="sessions"></ul>
97
97
  <div class="rail-footer">
@@ -143,7 +143,10 @@
143
143
  <span class="dot">·</span>
144
144
  <span id="sb-rounds">listo</span>
145
145
  <span class="spacer"></span>
146
- <span id="sb-tokens" class="muted">tokens —</span>
146
+ <span id="sb-context" class="ctx muted" title="uso del contexto del modelo">
147
+ <span id="sb-tokens">tokens —</span>
148
+ <span class="ctx-bar" aria-hidden="true"><span id="sb-context-fill" class="ctx-fill"></span></span>
149
+ </span>
147
150
  </div>
148
151
  </div>
149
152
 
@@ -187,8 +190,25 @@
187
190
  <button id="preview-open" class="btn" type="button">abrir</button>
188
191
  <button id="preview-tunnel" class="btn" type="button" title="Abrir un túnel público al puerto (para el celular)">túnel</button>
189
192
  </div>
193
+ <div class="preview-tools">
194
+ <select id="preview-device" title="tamaño de pantalla" aria-label="tamaño de pantalla">
195
+ <option value="auto">adaptable</option>
196
+ <option value="390">móvil</option>
197
+ <option value="768">tablet</option>
198
+ <option value="1280">laptop</option>
199
+ <option value="1600">pc</option>
200
+ </select>
201
+ <span class="spacer"></span>
202
+ <button id="preview-max" class="btn icon" type="button" title="maximizar" aria-pressed="false" aria-label="maximizar la vista previa">
203
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
204
+ <path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3" />
205
+ </svg>
206
+ </button>
207
+ </div>
190
208
  <div id="preview-status" class="explorer-status"></div>
191
- <iframe id="preview-frame" class="preview-frame" title="vista previa" referrerpolicy="no-referrer"></iframe>
209
+ <div id="preview-stage" class="preview-stage" data-device="auto">
210
+ <iframe id="preview-frame" class="preview-frame" title="vista previa" referrerpolicy="no-referrer"></iframe>
211
+ </div>
192
212
  </div>
193
213
 
194
214
  <div id="xp-procs" class="xp-pane" hidden>
@@ -202,12 +222,36 @@
202
222
  </div>
203
223
  </aside>
204
224
 
225
+ <button id="sidebar-handle" type="button" class="panel-handle panel-handle-left" title="mostrar sesiones" aria-label="mostrar panel de sesiones" aria-pressed="false">
226
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
227
+ <path d="m9 18 6-6-6-6" />
228
+ </svg>
229
+ </button>
230
+ <button id="explorer-handle" type="button" class="panel-handle panel-handle-right" title="mostrar archivos" aria-label="mostrar panel de archivos" aria-pressed="false">
231
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
232
+ <path d="m15 18-6-6 6-6" />
233
+ </svg>
234
+ </button>
235
+
205
236
  <dialog id="file-modal" class="modal file-modal">
206
237
  <div class="modal-card">
207
238
  <div class="row between">
208
239
  <h3 id="file-title">archivo</h3>
209
240
  <div class="file-actions">
210
241
  <span id="file-meta" class="muted"></span>
242
+ <select id="file-device" class="file-device" title="tamaño de pantalla" aria-label="tamaño de pantalla" hidden>
243
+ <option value="auto">adaptable</option>
244
+ <option value="390">móvil</option>
245
+ <option value="768">tablet</option>
246
+ <option value="1280">laptop</option>
247
+ <option value="1600">pc</option>
248
+ </select>
249
+ <button id="file-max" type="button" class="btn icon" title="maximizar" aria-pressed="false" aria-label="maximizar la vista previa" hidden>
250
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
251
+ <path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3" />
252
+ </svg>
253
+ </button>
254
+ <button id="file-preview-toggle" type="button" class="btn" hidden>vista previa</button>
211
255
  <button id="file-close" type="button" class="btn icon" title="cerrar" aria-label="cerrar">
212
256
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
213
257
  <path d="M18 6 6 18M6 6l12 12" />
@@ -216,6 +260,10 @@
216
260
  </div>
217
261
  </div>
218
262
  <pre id="file-content" class="file-preview"></pre>
263
+ <div id="file-render" class="file-render text" hidden></div>
264
+ <div id="file-stage" class="preview-stage" data-device="auto" hidden>
265
+ <iframe id="file-frame" class="file-frame" title="vista previa del archivo"></iframe>
266
+ </div>
219
267
  </div>
220
268
  </dialog>
221
269
  </section>
@@ -404,6 +452,16 @@
404
452
  <select id="provider" title="proveedor"></select>
405
453
  <label for="model">Modelo</label>
406
454
  <select id="model" title="modelo"></select>
455
+ <label for="default-cwd">Carpeta predeterminada para nuevas sesiones</label>
456
+ <div class="row default-cwd-row">
457
+ <input id="default-cwd" type="text" readonly placeholder="(la casa del usuario)" title="carpeta donde se abre el selector y /new sin sesión" />
458
+ <button type="button" id="default-cwd-pick" class="btn">elegir</button>
459
+ <button type="button" id="default-cwd-clear" class="btn icon" title="quitar la carpeta predeterminada" aria-label="quitar la carpeta predeterminada">
460
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
461
+ <path d="M18 6 6 18M6 6l12 12" />
462
+ </svg>
463
+ </button>
464
+ </div>
407
465
  <label class="check" title="aprobar automáticamente tools con permiso ask">
408
466
  <input id="yes" type="checkbox" /> Aprobar automáticamente las tools (auto)
409
467
  </label>
@@ -83,7 +83,10 @@ pre, code, kbd, samp { font-family: var(--mono); }
83
83
 
84
84
  /* topbar ------------------------------------------------------------------ */
85
85
  .topbar {
86
- display: flex;
86
+ position: relative;
87
+ z-index: 40;
88
+ display: grid;
89
+ grid-template-columns: 1fr auto 1fr;
87
90
  align-items: center;
88
91
  gap: 16px;
89
92
  padding: 10px 16px;
@@ -92,6 +95,8 @@ pre, code, kbd, samp { font-family: var(--mono); }
92
95
  backdrop-filter: blur(14px);
93
96
  }
94
97
  .brand {
98
+ grid-column: 1;
99
+ justify-self: start;
95
100
  font-family: var(--serif);
96
101
  font-weight: 700;
97
102
  font-size: 15px;
@@ -101,18 +106,23 @@ pre, code, kbd, samp { font-family: var(--mono); }
101
106
  gap: 9px;
102
107
  }
103
108
  .brand .logo {
104
- width: 26px;
109
+ min-width: 26px;
105
110
  height: 26px;
111
+ padding: 0 8px;
106
112
  border: 1px solid var(--border);
107
113
  border-radius: 8px;
108
- display: grid;
109
- place-items: center;
110
- color: var(--accent);
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ color: var(--text);
111
118
  font: 700 14px var(--serif);
119
+ letter-spacing: -0.03em;
112
120
  flex: 0 0 auto;
113
121
  }
122
+ .brand .logo .logo-t { color: var(--text); -webkit-text-stroke: 0.3px var(--text); }
123
+ .brand .logo .logo-h { color: var(--accent); -webkit-text-stroke: 0.3px var(--accent); }
114
124
  .brand .ver { color: var(--muted); font-family: var(--mono); font-weight: 400; font-size: 10px; letter-spacing: 0.04em; }
115
- .tabs { display: flex; gap: 2px; }
125
+ .tabs { grid-column: 2; justify-self: center; display: flex; gap: 2px; }
116
126
  .tab {
117
127
  background: transparent;
118
128
  color: var(--muted);
@@ -125,9 +135,13 @@ pre, code, kbd, samp { font-family: var(--mono); }
125
135
  .tab:hover { color: var(--text); }
126
136
  .tab.active { color: var(--text); background: var(--theirs); border-color: var(--border); }
127
137
 
128
- .menu { position: relative; }
129
- #more-tab { display: grid; place-items: center; padding: 6px 10px; }
138
+ .menu { position: relative; display: block; }
139
+ #more-tab { display: inline-flex; align-items: center; gap: 6px; }
130
140
  #more-tab svg { width: 18px; height: 18px; display: block; }
141
+ #more-tab-label { display: none; font: 600 12.5px var(--sans); }
142
+ @media (min-width: 821px) {
143
+ #more-menu button.menu-primary { display: none; }
144
+ }
131
145
 
132
146
  #theme-toggle .ico-moon { display: none; }
133
147
  html[data-theme='dark'] #theme-toggle .ico-sun { display: none; }
@@ -136,7 +150,7 @@ html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
136
150
  position: absolute;
137
151
  top: calc(100% + 4px);
138
152
  left: 0;
139
- z-index: 20;
153
+ z-index: 60;
140
154
  min-width: 130px;
141
155
  display: flex;
142
156
  flex-direction: column;
@@ -159,7 +173,8 @@ html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
159
173
  font: inherit;
160
174
  }
161
175
  .menu-pop button:hover { background: var(--theirs); }
162
- .settings { margin-left: auto; display: flex; align-items: center; gap: 8px; }
176
+ .menu-pop button.active { background: var(--theirs); color: var(--accent); }
177
+ .settings { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 8px; }
163
178
  .settings select { max-width: 220px; }
164
179
  .check { color: var(--muted); display: flex; align-items: center; gap: 5px; cursor: pointer; }
165
180
 
@@ -176,7 +191,7 @@ select:focus, input:focus, textarea:focus { border-color: var(--accent); box-sha
176
191
 
177
192
  /* layout ------------------------------------------------------------------ */
178
193
  main { flex: 1; min-height: 0; min-width: 0; display: flex; }
179
- .view { display: none; flex: 1; min-height: 0; min-width: 0; }
194
+ .view { display: none; flex: 1; min-height: 0; min-width: 0; position: relative; }
180
195
  .view.active { display: flex; min-width: 0; }
181
196
 
182
197
  .btn {
@@ -599,9 +614,12 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
599
614
  }
600
615
  .statusbar .dot { opacity: 0.5; }
601
616
  .statusbar .spacer { flex: 1; }
617
+ .ctx { display: inline-flex; align-items: center; gap: 8px; }
618
+ .ctx-bar { width: 76px; height: 6px; border-radius: 999px; background: var(--border); overflow: hidden; }
619
+ .ctx-fill { display: block; height: 100%; width: 0; border-radius: 999px; background: currentColor; transition: width 0.35s ease; }
602
620
 
603
621
  /* providers / eval / runs ------------------------------------------------- */
604
- .panel { flex: 1; overflow-y: auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; }
622
+ .panel { flex: 1; overflow-y: auto; width: 100%; max-width: 920px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; }
605
623
  .panel h2 { margin: 0; font: 700 20px var(--serif); letter-spacing: -0.02em; }
606
624
  .field { display: flex; flex-direction: column; gap: 5px; color: var(--muted); }
607
625
  .field.grow { flex: 1; }
@@ -766,7 +784,34 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
766
784
  .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
767
785
  .xchip { border: 1px solid var(--border); background: var(--panel); color: var(--text); border-radius: 999px; padding: 4px 10px; font: 500 11px var(--mono); cursor: pointer; }
768
786
  .xchip:hover { border-color: var(--accent); color: var(--accent); }
769
- .preview-frame { flex: 1; width: 100%; border: 0; background: #fff; min-height: 0; }
787
+ .preview-tools { display: flex; align-items: center; gap: 6px; padding: 0 12px 8px; }
788
+ .preview-tools .spacer { flex: 1; }
789
+ .preview-tools select { flex: 0 0 auto; }
790
+ .preview-stage {
791
+ flex: 1;
792
+ min-height: 0;
793
+ display: flex;
794
+ align-items: stretch;
795
+ justify-content: center;
796
+ overflow: auto;
797
+ background: var(--theirs);
798
+ }
799
+ .preview-frame { border: 0; background: #fff; height: 100%; width: 100%; min-height: 0; }
800
+ .preview-stage[data-device]:not([data-device='auto']) .preview-frame {
801
+ height: 100%;
802
+ border: 1px solid var(--border);
803
+ border-radius: 8px;
804
+ box-shadow: 0 14px 36px rgba(40, 36, 26, 0.18);
805
+ }
806
+ #xp-preview.preview-maximized {
807
+ position: fixed;
808
+ inset: 0;
809
+ z-index: 70;
810
+ padding: 10px;
811
+ background: var(--panel);
812
+ border: 0;
813
+ }
814
+ #xp-preview.preview-maximized .preview-stage { border-radius: 10px; }
770
815
  #procs-list { flex: 1; min-height: 0; overflow-y: auto; }
771
816
  .procs-log { margin: 0; padding: 8px 12px; height: 150px; overflow: auto; background: var(--theirs); border-top: 1px solid var(--border); font: 500 11px var(--mono); white-space: pre-wrap; word-break: break-word; }
772
817
  .import-bar { display: flex; gap: 6px; margin: 4px 0 10px; }
@@ -786,6 +831,72 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
786
831
  white-space: pre;
787
832
  }
788
833
 
834
+ .file-render {
835
+ margin: 0;
836
+ padding: 14px 16px;
837
+ max-height: 62vh;
838
+ overflow: auto;
839
+ background: var(--panel);
840
+ border: 1px solid var(--border);
841
+ border-radius: 10px;
842
+ line-height: 1.65;
843
+ }
844
+
845
+ .file-stage {
846
+ height: 62vh;
847
+ display: flex;
848
+ align-items: stretch;
849
+ justify-content: center;
850
+ overflow: auto;
851
+ background: var(--theirs);
852
+ border: 1px solid var(--border);
853
+ border-radius: 10px;
854
+ }
855
+
856
+ .file-frame { border: 0; background: #fff; height: 100%; width: 100%; }
857
+
858
+ dialog.file-modal.preview-maximized {
859
+ width: 100vw;
860
+ max-width: 100vw;
861
+ height: 100vh;
862
+ max-height: 100vh;
863
+ margin: 0;
864
+ border: 0;
865
+ border-radius: 0;
866
+ }
867
+ .file-modal.preview-maximized .modal-card { height: 100vh; }
868
+ .file-modal.preview-maximized .file-stage,
869
+ .file-modal.preview-maximized .file-render,
870
+ .file-modal.preview-maximized .file-preview {
871
+ flex: 1;
872
+ height: auto;
873
+ max-height: none;
874
+ }
875
+
876
+ /* panel heads + collapsed handles ----------------------------------------- */
877
+ .panel-head { display: flex; align-items: center; gap: 8px; padding: 2px 2px 6px; }
878
+ .panel-head-title { flex: 1; font: 700 13px var(--serif); letter-spacing: -0.02em; color: var(--muted); text-transform: lowercase; }
879
+ .panel-handle {
880
+ position: absolute;
881
+ top: 50%;
882
+ transform: translateY(-50%);
883
+ z-index: 30;
884
+ width: 22px;
885
+ height: 54px;
886
+ display: grid;
887
+ place-items: center;
888
+ padding: 0;
889
+ border: 1px solid var(--border);
890
+ background: var(--panel);
891
+ color: var(--muted);
892
+ cursor: pointer;
893
+ transition: color 0.15s ease, background 0.15s ease;
894
+ }
895
+ .panel-handle:hover { color: var(--accent); background: var(--theirs); }
896
+ .panel-handle svg { width: 16px; height: 16px; }
897
+ .panel-handle-left { left: 0; border-left: 0; border-radius: 0 10px 10px 0; }
898
+ .panel-handle-right { right: 0; border-right: 0; border-radius: 10px 0 0 10px; }
899
+
789
900
  .drawer-backdrop {
790
901
  position: fixed;
791
902
  inset: 0;
@@ -794,6 +905,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
794
905
  backdrop-filter: blur(2px);
795
906
  }
796
907
 
908
+
797
909
  @media (max-width: 900px) {
798
910
  .explorer {
799
911
  position: fixed;
@@ -809,6 +921,8 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
809
921
  /* mobile / angosto -------------------------------------------------------- */
810
922
  @media (max-width: 820px) {
811
923
  .topbar {
924
+ position: relative;
925
+ display: flex;
812
926
  flex-wrap: wrap;
813
927
  gap: 8px 10px;
814
928
  padding: 8px 12px;
@@ -817,10 +931,27 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
817
931
  display: none;
818
932
  }
819
933
  .tabs {
820
- overflow-x: auto;
934
+ margin-left: auto;
821
935
  max-width: 100%;
822
936
  }
937
+ .tabs > .tab[data-tab] {
938
+ display: none;
939
+ }
940
+ #more-tab-label {
941
+ display: inline;
942
+ }
943
+ .menu-pop {
944
+ top: calc(100% + 2px);
945
+ left: auto;
946
+ right: 0;
947
+ min-width: 200px;
948
+ padding: 6px;
949
+ }
950
+ .menu-pop button {
951
+ padding: 11px 12px;
952
+ }
823
953
  .settings {
954
+ grid-column: auto;
824
955
  margin-left: 0;
825
956
  flex: 1 1 100%;
826
957
  flex-wrap: wrap;
@@ -915,6 +1046,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 12.5px; }
915
1046
  text-transform: none;
916
1047
  }
917
1048
  .settings-actions { margin-top: 10px; }
1049
+ .default-cwd-row { align-items: center; }
1050
+ .default-cwd-row #default-cwd { flex: 1; min-width: 0; }
1051
+ .default-cwd-row .btn { flex: 0 0 auto; }
918
1052
 
919
1053
  /* command palette --------------------------------------------------------- */
920
1054
  .palette-modal { width: min(560px, calc(100vw - 24px)); }
@@ -1,7 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { sendJson, sendError } from '../lib/http.js';
4
+ import { readJson, sendJson, sendError } from '../lib/http.js';
5
+ import { staticServerChip } from '../lib/core.js';
5
6
  import { isLoopback, resolveExistingDir } from '../lib/workspace.js';
6
7
 
7
8
  const MAX_PREVIEW = 200 * 1024;
@@ -114,4 +115,37 @@ export function registerFiles(route, ctx) {
114
115
  content: buffer.toString('utf8'),
115
116
  });
116
117
  });
118
+
119
+ route('POST', '/api/files/preview', async ({ req, res }) => {
120
+ if (!isLoopback(req.socket.localAddress)) {
121
+ return sendError(res, 403, 'La vista previa solo está disponible en conexiones locales.');
122
+ }
123
+ if (!ctx.procsEnabled) {
124
+ return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
125
+ }
126
+ const body = await readJson(req);
127
+ let root;
128
+ try {
129
+ root = resolveExistingDir(body.root || ctx.cwd);
130
+ } catch (error) {
131
+ return sendError(res, 400, error.message);
132
+ }
133
+ const rel = String(body.path ?? '').replace(/^[/\\]+/, '');
134
+ const target = path.resolve(root, rel);
135
+ if (!isInside(root, target)) return sendError(res, 400, 'La ruta queda fuera del workspace.');
136
+
137
+ let proc = ctx.procs.list().find((p) => p.running && p.port && path.resolve(p.cwd) === root);
138
+ if (!proc) {
139
+ const chip = staticServerChip(root);
140
+ try {
141
+ proc = ctx.procs.start({ cwd: root, command: chip.command, args: chip.args, port: chip.port });
142
+ } catch (error) {
143
+ return sendError(res, 400, error.message);
144
+ }
145
+ }
146
+ sendJson(res, 200, {
147
+ url: `http://127.0.0.1:${proc.port}/${toPosix(path.relative(root, target))}`,
148
+ port: proc.port,
149
+ });
150
+ });
117
151
  }
@@ -531,6 +531,32 @@ test('lists and previews workspace files, confining to the root', async () => {
531
531
  }
532
532
  });
533
533
 
534
+ test('previews an HTML file through a static server', async () => {
535
+ const root = await mkdtemp(path.join(tmpdir(), 'tamnora-html-'));
536
+ await writeFile(path.join(root, 'index.html'), '<h1>hola</h1>');
537
+ const { instance, base } = await startServer();
538
+ const json = { 'Content-Type': 'application/json' };
539
+ try {
540
+ const res = await fetch(`${base}/api/files/preview`, {
541
+ method: 'POST',
542
+ headers: json,
543
+ body: JSON.stringify({ root, path: 'index.html' }),
544
+ });
545
+ assert.equal(res.status, 200);
546
+ const data = await res.json();
547
+ assert.match(data.url, /^http:\/\/127\.0\.0\.1:\d+\/index\.html$/);
548
+
549
+ const outside = await fetch(`${base}/api/files/preview`, {
550
+ method: 'POST',
551
+ headers: json,
552
+ body: JSON.stringify({ root, path: '../secret.txt' }),
553
+ });
554
+ assert.equal(outside.status, 400);
555
+ } finally {
556
+ await instance.close();
557
+ }
558
+ });
559
+
534
560
  test('lists workspace folders and rejects invalid paths', async () => {
535
561
  const root = await mkdtemp(path.join(tmpdir(), 'tamnora-folders-'));
536
562
  await mkdir(path.join(root, 'beta'));
@@ -0,0 +1,94 @@
1
+ // Servidor estatico minimo (sin dependencias) para previsualizar sitios hechos
2
+ // con HTML/CSS/JS planos desde la consola y exponerlos con un tunel.
3
+ //
4
+ // Uso: node static-server.mjs <dir> [puerto]
5
+
6
+ import http from 'node:http';
7
+ import { createReadStream, existsSync, statSync } from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const root = path.resolve(process.argv[2] || '.');
11
+ const port = Number(process.argv[3] || 8000);
12
+ const host = '127.0.0.1';
13
+
14
+ const TYPES = {
15
+ '.html': 'text/html; charset=utf-8',
16
+ '.htm': 'text/html; charset=utf-8',
17
+ '.css': 'text/css; charset=utf-8',
18
+ '.js': 'text/javascript; charset=utf-8',
19
+ '.mjs': 'text/javascript; charset=utf-8',
20
+ '.json': 'application/json; charset=utf-8',
21
+ '.map': 'application/json; charset=utf-8',
22
+ '.txt': 'text/plain; charset=utf-8',
23
+ '.svg': 'image/svg+xml',
24
+ '.png': 'image/png',
25
+ '.jpg': 'image/jpeg',
26
+ '.jpeg': 'image/jpeg',
27
+ '.gif': 'image/gif',
28
+ '.webp': 'image/webp',
29
+ '.avif': 'image/avif',
30
+ '.ico': 'image/x-icon',
31
+ '.woff': 'font/woff',
32
+ '.woff2': 'font/woff2',
33
+ '.ttf': 'font/ttf',
34
+ '.otf': 'font/otf',
35
+ '.pdf': 'application/pdf',
36
+ '.mp4': 'video/mp4',
37
+ '.webm': 'video/webm',
38
+ '.mp3': 'audio/mpeg',
39
+ };
40
+
41
+ function resolveRequest(urlPath) {
42
+ let decoded;
43
+ try {
44
+ decoded = decodeURIComponent(String(urlPath || '/').split('?')[0].split('#')[0]);
45
+ } catch {
46
+ return null;
47
+ }
48
+ const abs = path.resolve(root, decoded.replace(/^\/+/, ''));
49
+ if (abs !== root && !abs.startsWith(root + path.sep)) return null;
50
+ return abs;
51
+ }
52
+
53
+ const server = http.createServer((req, res) => {
54
+ let abs = resolveRequest(req.url);
55
+ if (abs === null) {
56
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
57
+ res.end('403');
58
+ return;
59
+ }
60
+ try {
61
+ if (existsSync(abs) && statSync(abs).isDirectory()) abs = path.join(abs, 'index.html');
62
+ if (!existsSync(abs) || !statSync(abs).isFile()) {
63
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
64
+ res.end('404');
65
+ return;
66
+ }
67
+ } catch {
68
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
69
+ res.end('500');
70
+ return;
71
+ }
72
+ res.writeHead(200, {
73
+ 'content-type': TYPES[path.extname(abs).toLowerCase()] || 'application/octet-stream',
74
+ 'cache-control': 'no-cache',
75
+ });
76
+ createReadStream(abs).on('error', () => res.destroy()).pipe(res);
77
+ });
78
+
79
+ server.on('error', (error) => {
80
+ if (error.code === 'EADDRINUSE') {
81
+ console.error(`[static] el puerto ${port} esta ocupado; pasá otro: node static-server.mjs <dir> <puerto>`);
82
+ } else {
83
+ console.error(`[static] ${error.message}`);
84
+ }
85
+ process.exit(1);
86
+ });
87
+
88
+ server.listen(port, host, () => {
89
+ console.log(`[static] http://${host}:${port} → ${root}`);
90
+ });
91
+
92
+ const shutdown = () => server.close(() => process.exit(0));
93
+ process.on('SIGTERM', shutdown);
94
+ process.on('SIGINT', shutdown);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",