@danieltmn/tamnora 0.24.0 → 0.24.1

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,12 @@ 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.1] - 2026-10-28
9
+
10
+ ### Cambios
11
+
12
+ - fix(consola): deja de repintar el selector de modelos en cada cambio de pestaña
13
+
8
14
  ## [0.24.0] - 2026-10-28
9
15
 
10
16
  ### Cambios
@@ -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,
@@ -531,52 +532,83 @@ export async function applySessionAgent(record) {
531
532
  renderModelPill();
532
533
  }
533
534
 
534
- export async function loadModels({ save = false, prefer = null } = {}) {
535
+ /** Firma de la lista dibujada: si no cambia, no hay que repintar el select. */
536
+ function modelsSignature(shown) {
537
+ return shown.map((m) => `${m.id}\u0000${m.label ?? ''}\u0000${m.vision ? 1 : 0}\u0000${m.context ?? ''}`).join('\u0001');
538
+ }
539
+
540
+ /**
541
+ * Carga el catalogo del proveedor y dibuja el select.
542
+ *
543
+ * `force` vuelve a pedirlo al server; sin el se reusa el de `state` (son ~374
544
+ * modelos / 591 KB, y esto corre en cada cambio de pestana: refetchearlo hacia
545
+ * parpadear la consola).
546
+ */
547
+ export async function loadModels({ save = false, prefer = null, force = false } = {}) {
535
548
  const provider = providerSelect.value;
549
+ const cached = !force && state.modelCatalogProvider === provider && state.modelCatalog?.length;
536
550
  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);
551
+ try {
552
+ if (!cached) {
553
+ const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
554
+ models: [],
555
+ error: error.message,
556
+ }));
557
+ state.modelCatalog = data?.models ?? [];
558
+ state.visibleModels = data?.visibleModels ?? [];
559
+ state.modelCatalogProvider = provider;
560
+ if (!state.modelCatalog.length) {
561
+ clear(modelSelect);
562
+ const option = document.createElement('option');
563
+ option.value = '';
564
+ option.textContent = data?.error ? `sin modelos - ${data.error}` : 'sin modelos (configura el proveedor)';
565
+ modelSelect.appendChild(option);
566
+ return;
567
+ }
568
+ }
546
569
 
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
- }
570
+ const catalog = state.modelCatalog;
571
+ const models = pickVisible(catalog, state.visibleModels);
572
+ const activeModel = prefer || state.active.model;
573
+ // Si el modelo activo quedo fuera de la seleccion, lo mostramos igual para no
574
+ // cambiarlo en silencio: sigue siendo el usado aunque no este tildado.
575
+ const hidden = activeModel && catalog.some((m) => m.id === activeModel) && !models.some((m) => m.id === activeModel);
576
+ const shown = hidden ? [catalog.find((m) => m.id === activeModel), ...models] : models;
577
+ if (!shown.length) {
578
+ clear(modelSelect);
579
+ const option = document.createElement('option');
580
+ option.value = '';
581
+ option.textContent = 'sin modelos (configura el proveedor)';
582
+ modelSelect.appendChild(option);
583
+ return;
584
+ }
556
585
 
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 });
586
+ // Repintar el select con cientos de opciones es lo que se ve como parpadeo:
587
+ // solo se hace cuando la lista realmente cambio.
588
+ const signature = modelsSignature(shown);
589
+ if (signature !== state.modelSignature) {
590
+ state.modelSignature = signature;
591
+ clear(modelSelect);
592
+ for (const model of shown) {
593
+ const option = document.createElement('option');
594
+ option.value = model.id;
595
+ option.textContent = model.label && model.label !== model.id ? model.label : model.id;
596
+ if (model.vision) option.textContent += ' - vision';
597
+ if (model.context) option.dataset.context = String(model.context);
598
+ modelSelect.appendChild(option);
599
+ }
600
+ }
601
+
602
+ const wanted = prefer || state.active.model;
603
+ if (wanted && shown.some((m) => m.id === wanted)) {
604
+ modelSelect.value = wanted;
605
+ } else {
606
+ modelSelect.value = models[0].id;
607
+ if (save) await saveSettings({ model: modelSelect.value });
608
+ }
609
+ } finally {
610
+ modelSelect.disabled = false;
576
611
  }
577
- modelSelect.disabled = false;
578
- updateStatus();
579
- renderTokens();
580
612
  }
581
613
 
582
614
  /* --- folder picker -------------------------------------------------------- */
@@ -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> <span id="topbar-section" class="topbar-section">chat</span> <span class="ver">v0.24.1</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
 
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { matchModel, filterModels } from '../public/modelsQuery.js';
3
+ import { matchModel, filterModels, pickVisible } from '../public/modelsQuery.js';
4
4
 
5
5
  const CATALOG = [
6
6
  { id: 'z-ai/glm-5.3', label: 'Z.ai: GLM 5.3' },
@@ -55,3 +55,22 @@ test('filterModels: no-array o ausente → []', () => {
55
55
  assert.deepEqual(filterModels('x', undefined), []);
56
56
  assert.deepEqual(filterModels('x', 'no soy una lista'), []);
57
57
  });
58
+
59
+ test('pickVisible: sin seleccion devuelve el catalogo entero', () => {
60
+ assert.deepEqual(pickVisible(CATALOG, []), CATALOG);
61
+ assert.deepEqual(pickVisible(CATALOG, null), CATALOG);
62
+ assert.deepEqual(pickVisible(CATALOG, undefined), CATALOG);
63
+ });
64
+
65
+ test('pickVisible: acota a los ids seleccionados, en orden del catalogo', () => {
66
+ const ids = pickVisible(CATALOG, ['anthropic/claude-haiku-5.5', 'z-ai/glm-5.3']).map((m) => m.id);
67
+ assert.deepEqual(ids, ['z-ai/glm-5.3', 'anthropic/claude-haiku-5.5']);
68
+ // ids que ya no estan en el catalogo se ignoran sin romper
69
+ assert.deepEqual(pickVisible(CATALOG, ['z-ai/glm-5.3', 'no/existe']).map((m) => m.id), ['z-ai/glm-5.3']);
70
+ });
71
+
72
+ test('pickVisible: catalogo vacio o no-array → []', () => {
73
+ assert.deepEqual(pickVisible([], ['a']), []);
74
+ assert.deepEqual(pickVisible(null, ['a']), []);
75
+ assert.deepEqual(pickVisible(undefined, ['a']), []);
76
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
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",