@danieltmn/tamnora 0.24.0 → 0.24.2

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,19 @@ 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.24.2] - 2026-10-30
9
+
10
+ ### Cambios
11
+
12
+ - feat(consola): el input del chat crece hacia arriba al escribir
13
+ - feat: botón para volver al chat desde las demás páginas
14
+
15
+ ## [0.24.1] - 2026-10-28
16
+
17
+ ### Cambios
18
+
19
+ - fix(consola): deja de repintar el selector de modelos en cada cambio de pestaña
20
+
8
21
  ## [0.24.0] - 2026-10-28
9
22
 
10
23
  ### Cambios
@@ -220,6 +220,8 @@ document.addEventListener('keydown', (event) => {
220
220
  if (event.key === 'Escape' && !moreMenu.hidden) hideMoreMenu();
221
221
  });
222
222
 
223
+ $('#back-chat')?.addEventListener('click', () => activateTab('chat'));
224
+
223
225
  if (settingsModal) {
224
226
  $('#settings-toggle')?.addEventListener('click', openSettingsModal);
225
227
  $('#settings-close')?.addEventListener('click', closeSettingsModal);
@@ -103,6 +103,16 @@ export const panesEl = $('#panes');
103
103
  export const tabbarEl = $('#tabbar');
104
104
  export const chatForm = $('#chat-form');
105
105
  export const chatInput = $('#chat-input');
106
+
107
+ /**
108
+ * Ajusta el alto del textarea al contenido: crece hacia arriba a medida que se
109
+ * escribe, hasta el `max-height` del CSS (donde pasa a scrollear por dentro).
110
+ * Hay que llamarlo tambien al vaciarlo por codigo, o quedaria alto y vacio.
111
+ */
112
+ export function autoGrowChatInput() {
113
+ chatInput.style.height = 'auto';
114
+ chatInput.style.height = `${chatInput.scrollHeight}px`;
115
+ }
106
116
  export const submitBtn = chatForm.querySelector('button[type="submit"]');
107
117
  export const stopBtn = $('#stop-btn');
108
118
  export const interruptBtn = $('#interrupt-btn');
@@ -305,6 +315,7 @@ export async function regenerate(message) {
305
315
  await api(`/api/sessions/${id}/truncate`, { method: 'POST', body: { fromId: lastUser.id } }).catch(() => {});
306
316
  await reloadPane(id);
307
317
  chatInput.value = userText(lastUser.content);
318
+ autoGrowChatInput();
308
319
  await sendMessage();
309
320
  }
310
321
 
@@ -338,6 +349,7 @@ export async function undoTurn(message) {
338
349
 
339
350
  export function editMessage(message) { const id = messageId(message);
340
351
  chatInput.value = message.textEl.textContent;
352
+ autoGrowChatInput();
341
353
  chatInput.focus();
342
354
  state.pendingEdit = id ? { fromId: id } : null;
343
355
  }
@@ -753,6 +765,7 @@ export function addContinueButton(turn, data) {
753
765
  btn.addEventListener('click', () => {
754
766
  if (state.panes.get(turn.sessionKey)?.working) return;
755
767
  chatInput.value = 'Continuá donde quedaste.';
768
+ autoGrowChatInput();
756
769
  sendMessage();
757
770
  });
758
771
  turn.assistant().tools.appendChild(btn);
@@ -1195,6 +1208,7 @@ export async function sendMessage() {
1195
1208
  chatInput.value = '';
1196
1209
  state.attachments = [];
1197
1210
  renderAttachments();
1211
+ autoGrowChatInput();
1198
1212
  if (command) runShell(command);
1199
1213
  return;
1200
1214
  }
@@ -1224,6 +1238,7 @@ export async function sendMessage() {
1224
1238
  chatInput.value = '';
1225
1239
  state.attachments = [];
1226
1240
  renderAttachments();
1241
+ autoGrowChatInput();
1227
1242
  syncInterruptBtn();
1228
1243
  return;
1229
1244
  }
@@ -1240,6 +1255,7 @@ export async function sendMessage() {
1240
1255
  chatInput.value = '';
1241
1256
  state.attachments = [];
1242
1257
  renderAttachments();
1258
+ autoGrowChatInput();
1243
1259
  await startTurn(state.sessionId, activePane, input, images);
1244
1260
  }
1245
1261
 
@@ -1275,6 +1291,7 @@ export function sendInterrupt() {
1275
1291
  chatInput.value = '';
1276
1292
  state.attachments = [];
1277
1293
  renderAttachments();
1294
+ autoGrowChatInput();
1278
1295
  syncInterruptBtn();
1279
1296
  interruptAndSend(state.sessionId, input, images);
1280
1297
  }
@@ -11,7 +11,8 @@ import {
11
11
  } from './appearance.js';
12
12
  import { basename, chatInput, scheduleScrollNav, sendMessage } from './chat.js';
13
13
  import { $, api, clear, el, state, storage } from './core.js';
14
- import { refreshExplorerMode } from './explorer.js';
14
+ import { refreshExplorerMode } from './explorer.js';
15
+ import { pickVisible } from './modelsQuery.js';
15
16
  import { activateSession, registerSession } from './sessions.js';
16
17
  import {
17
18
  activateAjustesPanel,
@@ -43,7 +44,8 @@ export function setCwd(value) {
43
44
 
44
45
  export const moreTab = $('#more-tab');
45
46
  export const moreMenu = $('#more-menu');
46
- export const topbarSection = $('#topbar-section');
47
+ export const topbarSection = $('#topbar-section');
48
+ export const backChat = $('#back-chat');
47
49
 
48
50
  export const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
49
51
 
@@ -60,6 +62,7 @@ export function activateTab(name) {
60
62
  .querySelectorAll('#more-menu button[data-tab]')
61
63
  .forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
62
64
  if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
65
+ if (backChat) backChat.hidden = name === 'chat';
63
66
  moreTab.classList.toggle('active', name !== 'chat');
64
67
  document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
65
68
  hideMoreMenu();
@@ -531,52 +534,83 @@ export async function applySessionAgent(record) {
531
534
  renderModelPill();
532
535
  }
533
536
 
534
- export async function loadModels({ save = false, prefer = null } = {}) {
537
+ /** Firma de la lista dibujada: si no cambia, no hay que repintar el select. */
538
+ function modelsSignature(shown) {
539
+ return shown.map((m) => `${m.id}\u0000${m.label ?? ''}\u0000${m.vision ? 1 : 0}\u0000${m.context ?? ''}`).join('\u0001');
540
+ }
541
+
542
+ /**
543
+ * Carga el catalogo del proveedor y dibuja el select.
544
+ *
545
+ * `force` vuelve a pedirlo al server; sin el se reusa el de `state` (son ~374
546
+ * modelos / 591 KB, y esto corre en cada cambio de pestana: refetchearlo hacia
547
+ * parpadear la consola).
548
+ */
549
+ export async function loadModels({ save = false, prefer = null, force = false } = {}) {
535
550
  const provider = providerSelect.value;
551
+ const cached = !force && state.modelCatalogProvider === provider && state.modelCatalog?.length;
536
552
  modelSelect.disabled = true;
537
- const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
538
- models: [],
539
- error: error.message,
540
- }));
541
- const catalog = data?.models ?? [];
542
- const models = data?.visible ?? catalog;
543
- state.modelCatalog = catalog;
544
- state.visibleModels = data?.visibleModels ?? [];
545
- clear(modelSelect);
553
+ try {
554
+ if (!cached) {
555
+ const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
556
+ models: [],
557
+ error: error.message,
558
+ }));
559
+ state.modelCatalog = data?.models ?? [];
560
+ state.visibleModels = data?.visibleModels ?? [];
561
+ state.modelCatalogProvider = provider;
562
+ if (!state.modelCatalog.length) {
563
+ clear(modelSelect);
564
+ const option = document.createElement('option');
565
+ option.value = '';
566
+ option.textContent = data?.error ? `sin modelos - ${data.error}` : 'sin modelos (configura el proveedor)';
567
+ modelSelect.appendChild(option);
568
+ return;
569
+ }
570
+ }
546
571
 
547
- if (!models.length) {
548
- const option = document.createElement('option');
549
- option.value = '';
550
- option.textContent = data?.error ? `sin modelos — ${data.error}` : 'sin modelos (configurá el proveedor)';
551
- modelSelect.appendChild(option);
552
- updateStatus();
553
- renderTokens();
554
- return;
555
- }
572
+ const catalog = state.modelCatalog;
573
+ const models = pickVisible(catalog, state.visibleModels);
574
+ const activeModel = prefer || state.active.model;
575
+ // Si el modelo activo quedo fuera de la seleccion, lo mostramos igual para no
576
+ // cambiarlo en silencio: sigue siendo el usado aunque no este tildado.
577
+ const hidden = activeModel && catalog.some((m) => m.id === activeModel) && !models.some((m) => m.id === activeModel);
578
+ const shown = hidden ? [catalog.find((m) => m.id === activeModel), ...models] : models;
579
+ if (!shown.length) {
580
+ clear(modelSelect);
581
+ const option = document.createElement('option');
582
+ option.value = '';
583
+ option.textContent = 'sin modelos (configura el proveedor)';
584
+ modelSelect.appendChild(option);
585
+ return;
586
+ }
556
587
 
557
- const activeModel = prefer || state.active.model;
558
- // Si el modelo activo quedó fuera de la selección, lo mostramos igual para no
559
- // cambiarlo en silencio: sigue siendo el usado aunque no esté tildado.
560
- const hidden = activeModel && catalog.some((m) => m.id === activeModel) && !models.some((m) => m.id === activeModel);
561
- const shown = hidden ? [catalog.find((m) => m.id === activeModel), ...models] : models;
562
- for (const model of shown) {
563
- const option = document.createElement('option');
564
- option.value = model.id;
565
- option.textContent = model.label && model.label !== model.id ? model.label : model.id;
566
- if (model.vision) option.textContent += ' · visión';
567
- if (model.context) option.dataset.context = String(model.context);
568
- modelSelect.appendChild(option);
569
- }
570
- const wanted = prefer || state.active.model;
571
- if (wanted && shown.some((m) => m.id === wanted)) {
572
- modelSelect.value = wanted;
573
- } else {
574
- modelSelect.value = models[0].id;
575
- if (save) await saveSettings({ model: modelSelect.value });
588
+ // Repintar el select con cientos de opciones es lo que se ve como parpadeo:
589
+ // solo se hace cuando la lista realmente cambio.
590
+ const signature = modelsSignature(shown);
591
+ if (signature !== state.modelSignature) {
592
+ state.modelSignature = signature;
593
+ clear(modelSelect);
594
+ for (const model of shown) {
595
+ const option = document.createElement('option');
596
+ option.value = model.id;
597
+ option.textContent = model.label && model.label !== model.id ? model.label : model.id;
598
+ if (model.vision) option.textContent += ' - vision';
599
+ if (model.context) option.dataset.context = String(model.context);
600
+ modelSelect.appendChild(option);
601
+ }
602
+ }
603
+
604
+ const wanted = prefer || state.active.model;
605
+ if (wanted && shown.some((m) => m.id === wanted)) {
606
+ modelSelect.value = wanted;
607
+ } else {
608
+ modelSelect.value = models[0].id;
609
+ if (save) await saveSettings({ model: modelSelect.value });
610
+ }
611
+ } finally {
612
+ modelSelect.disabled = false;
576
613
  }
577
- modelSelect.disabled = false;
578
- updateStatus();
579
- renderTokens();
580
614
  }
581
615
 
582
616
  /* --- folder picker -------------------------------------------------------- */
@@ -3,6 +3,7 @@ import {
3
3
  activePane,
4
4
  addCompactCard,
5
5
  addMessage,
6
+ autoGrowChatInput,
6
7
  basename,
7
8
  chatInput,
8
9
  finishCompactCard,
@@ -398,6 +399,7 @@ export function handleSlashKey(event) {
398
399
  export function onChatInputChanged() {
399
400
  slashIndex = 0;
400
401
  updateSlashMenu();
402
+ autoGrowChatInput();
401
403
  syncInterruptBtn();
402
404
  }
403
405
 
@@ -14,6 +14,13 @@ export const state = {
14
14
  streaming: false,
15
15
  providers: [],
16
16
  active: { provider: '', model: '' },
17
+ // Catalogo de modelos del proveedor y su seleccion. Se cachea porque el
18
+ // catalogo son cientos de modelos (~591 KB) y `loadModels` corre en cada
19
+ // cambio de pestana: refetchearlo hacia parpadear la consola.
20
+ modelCatalog: [],
21
+ modelCatalogProvider: null,
22
+ modelSignature: null,
23
+ visibleModels: [],
17
24
  pendingInput: null,
18
25
  permissionCards: new Map(),
19
26
  questionCards: new Map(),
@@ -75,7 +75,7 @@
75
75
  </head>
76
76
  <body>
77
77
  <header class="topbar">
78
- <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.0</span></div>
78
+ <div class="brand"><span class="logo"><img src="/icons/logo.png" alt="Tamnora" /></span> <button id="back-chat" type="button" class="back-chat" title="volver al chat" aria-label="volver al chat" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6" /></svg><span>chat</span></button> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.2</span></div>
79
79
  <div class="settings">
80
80
  <button id="new-session-top" type="button" class="btn topbar-new" title="nueva sesión" aria-label="nueva sesión">
81
81
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
@@ -121,6 +121,9 @@ export async function saveModelPicker() {
121
121
  await api(`/api/providers/${encodeURIComponent(provider)}`, { method: 'PUT', body: { visibleModels } });
122
122
  setModelStatus('guardado', 'ok');
123
123
  $('#model-modal')?.close();
124
+ // La seleccion ya la tenemos aca: se aplica al estado y se repinta sin
125
+ // volver a pedir el catalogo (591 KB) al server.
126
+ state.visibleModels = visibleModels;
124
127
  await loadModels();
125
128
  } catch (error) {
126
129
  setModelStatus(error?.message ?? 'no se pudo guardar', 'err');
@@ -18,3 +18,15 @@ export function matchModel(query, model) {
18
18
  export function filterModels(query, models) {
19
19
  return Array.isArray(models) ? models.filter((m) => matchModel(query, m)) : [];
20
20
  }
21
+
22
+ /**
23
+ * Aplica la seleccion guardada a un catalogo. Sin seleccion devuelve todo.
24
+ * Espeja `selectVisibleModels` del core para poder recalcular en el cliente sin
25
+ * volver a pedir el catalogo (591 KB) cada vez que cambia de pestaña.
26
+ */
27
+ export function pickVisible(catalog, selected) {
28
+ if (!Array.isArray(catalog) || !catalog.length) return [];
29
+ if (!Array.isArray(selected) || !selected.length) return catalog;
30
+ const wanted = new Set(selected.map(String));
31
+ return catalog.filter((m) => wanted.has(String(m?.id)));
32
+ }
@@ -93,7 +93,7 @@ export function renderProviderList() {
93
93
 
94
94
  export async function useProvider(id) {
95
95
  providerSelect.value = id;
96
- await loadModels({ save: false });
96
+ await loadModels({ save: false, force: true });
97
97
  await persistAgent({ provider: id, model: modelSelect.value || undefined });
98
98
  await refreshProviders();
99
99
  }
@@ -135,7 +135,7 @@ export async function submitProvider(event) {
135
135
  await api(`/api/providers/${encodeURIComponent(id)}`, { method: 'PUT', body });
136
136
  closeProviderForm();
137
137
  await refreshProviders();
138
- await loadModels();
138
+ await loadModels({ force: true });
139
139
  } catch (error) {
140
140
  setProviderStatus(error.message, 'err');
141
141
  }
@@ -144,7 +144,7 @@ export async function submitProvider(event) {
144
144
  export async function removeProvider(id) {
145
145
  await deleteEntity(`/api/providers/${encodeURIComponent(id)}`, {
146
146
  confirmText: `¿Borrar la configuración de "${id}"?`,
147
- after: loadModels,
147
+ after: () => loadModels({ force: true }),
148
148
  refresh: refreshProviders,
149
149
  });
150
150
  }
@@ -165,7 +165,7 @@ export async function testProvider() {
165
165
  $('#pf-models').value = ids.join(', ');
166
166
  note = ' · modelos autocompletados';
167
167
  }
168
- if (id === state.active.provider) await loadModels();
168
+ if (id === state.active.provider) await loadModels({ force: true });
169
169
  setProviderStatus(`ok · ${out.count} modelos (${out.source})${note}`, 'ok');
170
170
  }
171
171
 
@@ -571,6 +571,27 @@ html[data-scheme='light'] .brand .logo img { content: url('/icons/logo_light.png
571
571
  letter-spacing: 0.08em;
572
572
  white-space: nowrap;
573
573
  }
574
+ /* Botón para volver al chat desde las demás páginas (proveedores, ajustes, eval, runs). */
575
+ .back-chat {
576
+ display: inline-flex;
577
+ align-items: center;
578
+ gap: 3px;
579
+ height: 24px;
580
+ padding: 0 9px 0 5px;
581
+ border: 1px solid var(--border);
582
+ border-radius: 999px;
583
+ background: var(--theirs);
584
+ color: var(--muted);
585
+ cursor: pointer;
586
+ font: 700 10px var(--sans);
587
+ text-transform: uppercase;
588
+ letter-spacing: 0.08em;
589
+ white-space: nowrap;
590
+ transition: 0.15s ease;
591
+ }
592
+ .back-chat[hidden] { display: none; }
593
+ .back-chat:hover { border-color: var(--accent); color: var(--accent); }
594
+ .back-chat svg { width: 15px; height: 15px; display: block; flex: 0 0 auto; }
574
595
  .menu { position: relative; display: block; }
575
596
 
576
597
  /* El icono muestra el tema al que se va a cambiar: claro -> luna, oscuro -> sol. */