@danieltmn/tamnora 0.24.2 → 0.25.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 +14 -0
- package/harness/packages/server/public/app.js +831 -834
- package/harness/packages/server/public/appearance.js +4 -0
- package/harness/packages/server/public/boot.js +49 -0
- package/harness/packages/server/public/chrome.js +853 -852
- package/harness/packages/server/public/commands.js +448 -405
- package/harness/packages/server/public/core.js +229 -216
- package/harness/packages/server/public/favicon.ico +0 -0
- package/harness/packages/server/public/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/icons/tamnora.ico +0 -0
- package/harness/packages/server/public/index.html +72 -101
- package/harness/packages/server/public/landing/boot.js +16 -0
- package/harness/packages/server/public/landing/index.html +123 -59
- package/harness/packages/server/public/landing/landing.js +11 -9
- package/harness/packages/server/public/logo.png +0 -0
- package/harness/packages/server/public/markdown.js +1 -1
- package/harness/packages/server/public/settings.js +1155 -1080
- package/harness/packages/server/public/settingsNav.js +81 -0
- package/harness/packages/server/public/styles.css +2807 -2525
- package/harness/packages/server/public/system.js +2 -2
- package/harness/packages/server/src/lib/appearance.js +4 -0
- package/harness/packages/server/src/lib/restart.js +20 -0
- package/harness/packages/server/src/lib/securityHeaders.js +53 -0
- package/harness/packages/server/src/routes/reload.js +46 -0
- package/harness/packages/server/src/server.js +6 -1
- package/harness/packages/server/test/accessibility.test.js +72 -0
- package/harness/packages/server/test/appearance.test.js +4 -0
- package/harness/packages/server/test/consoleModules.test.js +133 -57
- package/harness/packages/server/test/consoleTabs.test.js +228 -0
- package/harness/packages/server/test/docsSync.test.js +73 -0
- package/harness/packages/server/test/landingLogin.test.js +89 -0
- package/harness/packages/server/test/markdownSecurity.test.js +80 -0
- package/harness/packages/server/test/reload.test.js +95 -0
- package/harness/packages/server/test/securityHeaders.test.js +109 -0
- package/harness/packages/server/test/settingsNav.test.js +90 -0
- package/harness/packages/server/test/themeCss.js +110 -0
- package/harness/packages/server/test/themes.test.js +72 -0
- package/harness/scripts/restart.mjs +101 -0
- package/package.json +1 -1
|
@@ -1,855 +1,856 @@
|
|
|
1
|
-
import {
|
|
2
|
-
DEFAULTS,
|
|
3
|
-
FONT_PRESETS,
|
|
4
|
-
THEMES,
|
|
5
|
-
applyAppearance,
|
|
6
|
-
clampChatFont,
|
|
7
|
-
clampScale,
|
|
8
|
-
nextTheme,
|
|
9
|
-
normalizeAppearance,
|
|
10
|
-
readLocalAppearance,
|
|
11
|
-
} from './appearance.js';
|
|
12
|
-
import { basename, chatInput, scheduleScrollNav, sendMessage } from './chat.js';
|
|
13
|
-
import { $, api, clear, el, state, storage } from './core.js';
|
|
1
|
+
import {
|
|
2
|
+
DEFAULTS,
|
|
3
|
+
FONT_PRESETS,
|
|
4
|
+
THEMES,
|
|
5
|
+
applyAppearance,
|
|
6
|
+
clampChatFont,
|
|
7
|
+
clampScale,
|
|
8
|
+
nextTheme,
|
|
9
|
+
normalizeAppearance,
|
|
10
|
+
readLocalAppearance,
|
|
11
|
+
} from './appearance.js';
|
|
12
|
+
import { basename, chatInput, scheduleScrollNav, sendMessage } from './chat.js';
|
|
13
|
+
import { $, api, clear, el, state, storage } from './core.js';
|
|
14
14
|
import { refreshExplorerMode } from './explorer.js';
|
|
15
|
-
import { pickVisible } from './modelsQuery.js';
|
|
16
|
-
import { activateSession, registerSession } from './sessions.js';
|
|
17
|
-
import {
|
|
18
|
-
activateAjustesPanel,
|
|
19
|
-
refreshAsesor,
|
|
20
|
-
refreshChannels,
|
|
21
|
-
refreshLimits,
|
|
22
|
-
refreshMcp,
|
|
23
|
-
refreshMcpCatalog,
|
|
24
|
-
refreshMemory,
|
|
25
|
-
refreshProfile,
|
|
26
|
-
refreshProviders,
|
|
27
|
-
refreshSchedules,
|
|
28
|
-
refreshSkillCatalog,
|
|
29
|
-
refreshSkills,
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
export const
|
|
15
|
+
import { pickVisible } from './modelsQuery.js';
|
|
16
|
+
import { activateSession, registerSession } from './sessions.js';
|
|
17
|
+
import {
|
|
18
|
+
activateAjustesPanel,
|
|
19
|
+
refreshAsesor,
|
|
20
|
+
refreshChannels,
|
|
21
|
+
refreshLimits,
|
|
22
|
+
refreshMcp,
|
|
23
|
+
refreshMcpCatalog,
|
|
24
|
+
refreshMemory,
|
|
25
|
+
refreshProfile,
|
|
26
|
+
refreshProviders,
|
|
27
|
+
refreshSchedules,
|
|
28
|
+
refreshSkillCatalog,
|
|
29
|
+
refreshSkills,
|
|
30
|
+
setAjustesLevel,
|
|
31
|
+
setStatus,
|
|
32
|
+
} from './settings.js';
|
|
33
|
+
import { refreshRuns } from './system.js';
|
|
34
|
+
import { refreshTranscribe, refreshVoz, setHandsFree } from './voice.js';
|
|
35
|
+
|
|
36
|
+
export function setCwd(value) {
|
|
37
|
+
if (!value) return;
|
|
38
|
+
state.cwd = value;
|
|
39
|
+
$('#rail-cwd').textContent = value;
|
|
40
|
+
renderCwdPill();
|
|
41
|
+
if (state.explorerOpen) refreshExplorerMode();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* --- tabs ---------------------------------------------------------------- */
|
|
45
|
+
|
|
46
|
+
export const moreTab = $('#more-tab');
|
|
47
|
+
export const moreMenu = $('#more-menu');
|
|
47
48
|
export const topbarSection = $('#topbar-section');
|
|
48
|
-
export const backChat = $('#back-chat');
|
|
49
|
-
|
|
50
|
-
export const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
|
|
51
|
-
|
|
52
|
-
export function hideMoreMenu() {
|
|
53
|
-
if (moreMenu.hidden) return;
|
|
54
|
-
const hadFocus = moreMenu.contains(document.activeElement);
|
|
55
|
-
moreMenu.hidden = true;
|
|
56
|
-
moreTab.setAttribute('aria-expanded', 'false');
|
|
57
|
-
if (hadFocus) moreTab.focus();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function activateTab(name) {
|
|
61
|
-
document
|
|
62
|
-
.querySelectorAll('#more-menu button[data-tab]')
|
|
63
|
-
.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
64
|
-
if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
|
|
49
|
+
export const backChat = $('#back-chat');
|
|
50
|
+
|
|
51
|
+
export const TAB_LABELS = { chat: 'chat', providers: 'proveedores', ajustes: 'ajustes', eval: 'eval', runs: 'runs' };
|
|
52
|
+
|
|
53
|
+
export function hideMoreMenu() {
|
|
54
|
+
if (moreMenu.hidden) return;
|
|
55
|
+
const hadFocus = moreMenu.contains(document.activeElement);
|
|
56
|
+
moreMenu.hidden = true;
|
|
57
|
+
moreTab.setAttribute('aria-expanded', 'false');
|
|
58
|
+
if (hadFocus) moreTab.focus();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function activateTab(name) {
|
|
62
|
+
document
|
|
63
|
+
.querySelectorAll('#more-menu button[data-tab]')
|
|
64
|
+
.forEach((t) => t.classList.toggle('active', t.dataset.tab === name));
|
|
65
|
+
if (topbarSection) topbarSection.textContent = TAB_LABELS[name] || name;
|
|
65
66
|
if (backChat) backChat.hidden = name === 'chat';
|
|
66
|
-
moreTab.classList.toggle('active', name !== 'chat');
|
|
67
|
-
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
|
|
68
|
-
hideMoreMenu();
|
|
69
|
-
if (name === 'runs') refreshRuns();
|
|
70
|
-
if (name === 'providers') refreshProviders();
|
|
71
|
-
if (name === 'ajustes') { refreshMcp(); refreshMcpCatalog(); refreshSkills(); refreshSkillCatalog(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
|
|
75
|
-
|
|
76
|
-
export const settingsModal = $('#settings-modal');
|
|
77
|
-
export const agentControls = $('#agent-settings-controls');
|
|
78
|
-
export const agentControlsSlot = $('#agent-controls-slot');
|
|
79
|
-
export const settingsControlsSlot = $('#settings-controls-slot');
|
|
80
|
-
|
|
81
|
-
/** Los controles modo/proveedor/modelo/auto se comparten entre el tab Agente
|
|
82
|
-
* y el modal: se mueven de un slot al otro para no duplicar IDs ni estado. */
|
|
83
|
-
export function moveAgentControls(target) {
|
|
84
|
-
if (agentControls && target) target.appendChild(agentControls);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function openSettingsModal() {
|
|
88
|
-
if (!settingsModal) return;
|
|
89
|
-
moveAgentControls(settingsControlsSlot);
|
|
90
|
-
syncDefaultCwd();
|
|
91
|
-
settingsModal.showModal();
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function closeSettingsModal() {
|
|
95
|
-
moveAgentControls(agentControlsSlot);
|
|
96
|
-
settingsModal?.close();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// El footer (modo · proveedor · modelo · estado) abre el modal de ajustes.
|
|
100
|
-
export const statusbarEl = $('#statusbar');
|
|
101
|
-
|
|
102
|
-
/* --- topbar: provider + model + status ----------------------------------- */
|
|
103
|
-
|
|
104
|
-
export const providerSelect = $('#provider');
|
|
105
|
-
export const modelSelect = $('#model');
|
|
106
|
-
export const modeSelect = $('#mode');
|
|
107
|
-
export const yesInput = $('#yes');
|
|
108
|
-
export const strictInput = $('#strict-scope');
|
|
109
|
-
|
|
110
|
-
export async function loadModes() {
|
|
111
|
-
const data = await api('/api/modes').catch(() => ({ modes: [] }));
|
|
112
|
-
clear(modeSelect);
|
|
113
|
-
for (const mode of data.modes) {
|
|
114
|
-
const option = document.createElement('option');
|
|
115
|
-
option.value = mode.id;
|
|
116
|
-
option.textContent = mode.label;
|
|
117
|
-
option.title = mode.description ?? '';
|
|
118
|
-
modeSelect.appendChild(option);
|
|
119
|
-
}
|
|
120
|
-
const settings = await api('/api/settings').catch(() => null);
|
|
121
|
-
const active = settings?.settings?.mode || 'agent';
|
|
122
|
-
if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
|
|
123
|
-
if (strictInput) strictInput.checked = settings?.settings?.strictScope === true;
|
|
124
|
-
defaultCwdCache = settings?.settings?.defaultCwd || '';
|
|
125
|
-
if (!defaultCwdCache) {
|
|
126
|
-
// Migración: la carpeta predeterminada vivía en localStorage; la subimos al
|
|
127
|
-
// server una vez para que Telegram también la vea.
|
|
128
|
-
const legacy = storage.get('tamnora-default-cwd', '');
|
|
129
|
-
if (legacy) writeDefaultCwd(legacy);
|
|
130
|
-
}
|
|
131
|
-
syncDefaultCwd();
|
|
132
|
-
if (settings?.settings?.appearance) {
|
|
133
|
-
appearance = normalizeAppearance(settings.settings.appearance);
|
|
134
|
-
applyAppearance(appearance, { persist: true });
|
|
135
|
-
renderAppearanceControls();
|
|
136
|
-
}
|
|
137
|
-
updateStatus();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export async function setMode(id, { persist = true } = {}) {
|
|
141
|
-
if (![...modeSelect.options].some((o) => o.value === id)) return;
|
|
142
|
-
modeSelect.value = id;
|
|
143
|
-
if (persist) await persistAgent({ mode: id });
|
|
144
|
-
// Modo Voz = manos libres (conversación); al salir, se apaga.
|
|
145
|
-
setHandsFree(id === 'voice');
|
|
146
|
-
updateStatus();
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export async function toggleMode() {
|
|
150
|
-
const next = modeSelect.value === 'plan' ? 'agent' : 'plan';
|
|
151
|
-
await setMode(next);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/* --- pills del composer (carpeta, permisos, modelo) ----------------------- */
|
|
155
|
-
|
|
156
|
-
export const cwdPill = $('#cwd-pill');
|
|
157
|
-
export const cwdPillLabel = $('#cwd-pill-label');
|
|
158
|
-
export const modePill = $('#mode-pill');
|
|
159
|
-
export const modePillLabel = $('#mode-pill-label');
|
|
160
|
-
export const modeMenu = $('#mode-menu');
|
|
161
|
-
export const modelPill = $('#model-pill');
|
|
162
|
-
export const modelPillLabel = $('#model-pill-label');
|
|
163
|
-
|
|
164
|
-
export function renderCwdPill() {
|
|
165
|
-
if (!cwdPillLabel) return;
|
|
166
|
-
cwdPillLabel.textContent = state.cwd ? basename(state.cwd) : 'sin carpeta';
|
|
167
|
-
if (cwdPill) cwdPill.title = state.cwd || 'sin carpeta de trabajo';
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// El pill de permisos resume modo + auto-aprobación en un control estilo Codex:
|
|
171
|
-
// Read Only, Workspace Write, Full access y Voz (notas .md/.json).
|
|
172
|
-
export function renderModePill() {
|
|
173
|
-
const plan = modeSelect.value === 'plan';
|
|
174
|
-
const voice = modeSelect.value === 'voice';
|
|
175
|
-
const full = !plan && !voice && Boolean(yesInput?.checked);
|
|
176
|
-
if (modePillLabel) {
|
|
177
|
-
modePillLabel.textContent = voice ? 'Voz' : plan ? 'Read Only' : full ? 'Full access' : 'Workspace Write';
|
|
178
|
-
}
|
|
179
|
-
if (!modeMenu) return;
|
|
180
|
-
for (const item of modeMenu.querySelectorAll('.ctrl-menu-item')) {
|
|
181
|
-
const kind = item.dataset.kind;
|
|
182
|
-
const active =
|
|
183
|
-
kind === 'read'
|
|
184
|
-
? plan
|
|
185
|
-
: kind === 'voice'
|
|
186
|
-
? voice
|
|
187
|
-
: kind === 'full'
|
|
188
|
-
? full
|
|
189
|
-
: !plan && !voice && !full;
|
|
190
|
-
item.classList.toggle('active', active);
|
|
191
|
-
item.setAttribute('aria-checked', active ? 'true' : 'false');
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function renderModelPill() {
|
|
196
|
-
if (!modelPillLabel) return;
|
|
197
|
-
const record = state.panes.get(state.sessionId);
|
|
198
|
-
const provider = record?.provider || state.active.provider;
|
|
199
|
-
const model = record?.model || state.active.model;
|
|
200
|
-
modelPillLabel.textContent = model || 'elegí un modelo';
|
|
201
|
-
if (modelPill) modelPill.title = `${provider || '—'} / ${model || '—'}`;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export function closeModeMenu() {
|
|
205
|
-
if (!modeMenu || modeMenu.hidden) return;
|
|
206
|
-
modeMenu.hidden = true;
|
|
207
|
-
modePill?.setAttribute('aria-expanded', 'false');
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
export function updateStatus(extra = {}) {
|
|
211
|
-
renderModePill();
|
|
212
|
-
renderModelPill();
|
|
213
|
-
if (extra.rounds !== undefined) $('#sb-rounds').textContent = `${extra.rounds} rondas`;
|
|
214
|
-
if (extra.stopReason) $('#sb-rounds').textContent = extra.stopReason;
|
|
215
|
-
if (extra.running) $('#sb-rounds').textContent = 'pensando…';
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/* --- apariencia (tema, tipografía, tamaño) -------------------------------- */
|
|
219
|
-
|
|
220
|
-
export let appearance = normalizeAppearance(readLocalAppearance());
|
|
221
|
-
export const themeToggle = $('#theme-toggle');
|
|
222
|
-
export const themeGrid = $('#apariencia-themes');
|
|
223
|
-
export const fontSelect = $('#apariencia-font');
|
|
224
|
-
export const chatFontRange = $('#apariencia-chat-font');
|
|
225
|
-
export const chatFontOut = $('#apariencia-chat-font-out');
|
|
226
|
-
export const scaleRange = $('#apariencia-scale');
|
|
227
|
-
export const scaleOut = $('#apariencia-scale-out');
|
|
228
|
-
|
|
229
|
-
export function renderAppearanceControls() {
|
|
230
|
-
if (themeGrid) {
|
|
231
|
-
themeGrid
|
|
232
|
-
.querySelectorAll('.theme-swatch')
|
|
233
|
-
.forEach((btn) => btn.classList.toggle('active', btn.dataset.themePick === appearance.theme));
|
|
234
|
-
}
|
|
235
|
-
if (fontSelect) fontSelect.value = appearance.font;
|
|
236
|
-
if (chatFontRange) chatFontRange.value = appearance.chatFont;
|
|
237
|
-
if (chatFontOut) chatFontOut.textContent = `${appearance.chatFont}px`;
|
|
238
|
-
if (scaleRange) scaleRange.value = Math.round(appearance.scale * 100);
|
|
239
|
-
if (scaleOut) scaleOut.textContent = `${Math.round(appearance.scale * 100)}%`;
|
|
240
|
-
if (themeToggle) {
|
|
241
|
-
const label = THEMES.find((t) => t.id === appearance.theme)?.label ?? appearance.theme;
|
|
242
|
-
themeToggle.title = `tema: ${label}`;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
export function persistAppearance() {
|
|
247
|
-
applyAppearance(appearance, { persist: true });
|
|
248
|
-
renderAppearanceControls();
|
|
249
|
-
api('/api/settings', { method: 'PUT', body: { appearance } }).catch(() => null);
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export function buildAppearanceControls() {
|
|
253
|
-
if (themeGrid) {
|
|
254
|
-
themeGrid.replaceChildren();
|
|
255
|
-
for (const theme of THEMES) {
|
|
256
|
-
const swatch = document.createElement('button');
|
|
257
|
-
swatch.type = 'button';
|
|
258
|
-
swatch.className = 'theme-swatch';
|
|
259
|
-
swatch.dataset.themePick = theme.id;
|
|
260
|
-
swatch.title = theme.label;
|
|
261
|
-
swatch.setAttribute('aria-label', `tema ${theme.label}`);
|
|
262
|
-
const dot = document.createElement('span');
|
|
263
|
-
dot.className = 'theme-swatch-dot';
|
|
264
|
-
dot.style.background = theme.metaColor;
|
|
265
|
-
const label = document.createElement('span');
|
|
266
|
-
label.className = 'theme-swatch-label';
|
|
267
|
-
label.textContent = theme.label;
|
|
268
|
-
swatch.append(dot, label);
|
|
269
|
-
swatch.addEventListener('click', () => {
|
|
270
|
-
appearance.theme = theme.id;
|
|
271
|
-
persistAppearance();
|
|
272
|
-
});
|
|
273
|
-
themeGrid.appendChild(swatch);
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
if (fontSelect) {
|
|
277
|
-
fontSelect.replaceChildren();
|
|
278
|
-
for (const font of FONT_PRESETS) {
|
|
279
|
-
const option = document.createElement('option');
|
|
280
|
-
option.value = font.id;
|
|
281
|
-
option.textContent = font.label;
|
|
282
|
-
fontSelect.appendChild(option);
|
|
283
|
-
}
|
|
284
|
-
fontSelect.addEventListener('change', () => {
|
|
285
|
-
appearance.font = fontSelect.value;
|
|
286
|
-
persistAppearance();
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
chatFontRange?.addEventListener('input', () => {
|
|
290
|
-
appearance.chatFont = clampChatFont(Number(chatFontRange.value));
|
|
291
|
-
applyAppearance(appearance, { persist: false });
|
|
292
|
-
if (chatFontOut) chatFontOut.textContent = `${appearance.chatFont}px`;
|
|
293
|
-
});
|
|
294
|
-
chatFontRange?.addEventListener('change', persistAppearance);
|
|
295
|
-
scaleRange?.addEventListener('input', () => {
|
|
296
|
-
appearance.scale = clampScale(Number(scaleRange.value) / 100);
|
|
297
|
-
applyAppearance(appearance, { persist: false });
|
|
298
|
-
if (scaleOut) scaleOut.textContent = `${Math.round(appearance.scale * 100)}%`;
|
|
299
|
-
});
|
|
300
|
-
scaleRange?.addEventListener('change', persistAppearance);
|
|
301
|
-
$('#apariencia-reset')?.addEventListener('click', () => {
|
|
302
|
-
appearance = normalizeAppearance(DEFAULTS);
|
|
303
|
-
persistAppearance();
|
|
304
|
-
setStatus('#apariencia-msg', 'restablecido', 'ok');
|
|
305
|
-
});
|
|
306
|
-
renderAppearanceControls();
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
export function cycleTheme() {
|
|
310
|
-
appearance.theme = nextTheme(appearance.theme);
|
|
311
|
-
persistAppearance();
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
export function
|
|
315
|
-
activateTab('ajustes');
|
|
316
|
-
activateAjustesPanel(
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
appearance = applyAppearance(appearance, { persist: false });
|
|
320
|
-
|
|
321
|
-
export const logout = $('#logout');
|
|
322
|
-
|
|
323
|
-
/* --- layout: resizable + collapsible panels ------------------------------- */
|
|
324
|
-
|
|
325
|
-
export const panels = {
|
|
326
|
-
sidebar: { el: $('#sidebar'), resizer: $('#resizer-sidebar'), min: 180, max: 460, width: 260, side: 'left' },
|
|
327
|
-
explorer: { el: $('#explorer'), resizer: $('#resizer-explorer'), min: 200, max: 560, width: 300, side: 'right' },
|
|
328
|
-
};
|
|
329
|
-
export const sidebarBackdrop = $('#sidebar-backdrop');
|
|
330
|
-
export const mobileQuery = window.matchMedia('(max-width: 820px)');
|
|
331
|
-
|
|
332
|
-
export function isMobile() {
|
|
333
|
-
return mobileQuery.matches;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
export function readSidebarPref() {
|
|
337
|
-
return storage.get('tamnora-sidebar-open', '0') !== '0';
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export function persistLayout() {
|
|
341
|
-
storage.setJson('tamnora-panel-widths', {
|
|
342
|
-
sidebar: panels.sidebar.width,
|
|
343
|
-
explorer: panels.explorer.width,
|
|
344
|
-
});
|
|
345
|
-
scheduleScrollNav();
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function applyPanelWidths() {
|
|
349
|
-
panels.sidebar.el.style.width = `${panels.sidebar.width}px`;
|
|
350
|
-
panels.explorer.el.style.width = `${panels.explorer.width}px`;
|
|
351
|
-
scheduleScrollNav();
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
export function attachResizer(panel) {
|
|
355
|
-
panel.resizer.addEventListener('pointerdown', (event) => {
|
|
356
|
-
if (panel.resizer.hidden) return;
|
|
357
|
-
event.preventDefault();
|
|
358
|
-
const startX = event.clientX;
|
|
359
|
-
const startWidth = panel.width;
|
|
360
|
-
panel.resizer.classList.add('dragging');
|
|
361
|
-
document.body.classList.add('resizing');
|
|
362
|
-
panel.resizer.setPointerCapture(event.pointerId);
|
|
363
|
-
const move = (moveEvent) => {
|
|
364
|
-
const delta = panel.side === 'left' ? moveEvent.clientX - startX : startX - moveEvent.clientX;
|
|
365
|
-
panel.width = Math.min(panel.max, Math.max(panel.min, startWidth + delta));
|
|
366
|
-
panel.el.style.width = `${panel.width}px`;
|
|
367
|
-
};
|
|
368
|
-
const up = () => {
|
|
369
|
-
panel.resizer.classList.remove('dragging');
|
|
370
|
-
document.body.classList.remove('resizing');
|
|
371
|
-
panel.resizer.removeEventListener('pointermove', move);
|
|
372
|
-
panel.resizer.removeEventListener('pointerup', up);
|
|
373
|
-
persistLayout();
|
|
374
|
-
};
|
|
375
|
-
panel.resizer.addEventListener('pointermove', move);
|
|
376
|
-
panel.resizer.addEventListener('pointerup', up);
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
export function setSidebarOpen(open) {
|
|
381
|
-
state.sidebarOpen = open;
|
|
382
|
-
if (isMobile()) {
|
|
383
|
-
// Drawer: siempre en el DOM; la visibilidad la maneja la clase + backdrop.
|
|
384
|
-
panels.sidebar.el.hidden = false;
|
|
385
|
-
panels.sidebar.el.classList.toggle('open', open);
|
|
386
|
-
panels.sidebar.resizer.hidden = true;
|
|
387
|
-
if (sidebarBackdrop) sidebarBackdrop.hidden = !open;
|
|
388
|
-
} else {
|
|
389
|
-
panels.sidebar.el.classList.remove('open');
|
|
390
|
-
panels.sidebar.el.hidden = !open;
|
|
391
|
-
panels.sidebar.resizer.hidden = !open;
|
|
392
|
-
if (sidebarBackdrop) sidebarBackdrop.hidden = true;
|
|
393
|
-
}
|
|
394
|
-
const handle = $('#sidebar-handle');
|
|
395
|
-
if (handle) {
|
|
396
|
-
handle.hidden = open;
|
|
397
|
-
handle.setAttribute('aria-pressed', String(open));
|
|
398
|
-
}
|
|
399
|
-
if (!isMobile()) {
|
|
400
|
-
storage.set('tamnora-sidebar-open', open ? '1' : '0');
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
export function initLayout() {
|
|
405
|
-
const widths = storage.getJson('tamnora-panel-widths', {}) || {};
|
|
406
|
-
if (Number(widths.sidebar)) panels.sidebar.width = Math.min(panels.sidebar.max, Math.max(panels.sidebar.min, widths.sidebar));
|
|
407
|
-
if (Number(widths.explorer)) panels.explorer.width = Math.min(panels.explorer.max, Math.max(panels.explorer.min, widths.explorer));
|
|
408
|
-
// En movil el panel arranca cerrado (drawer); en desktop, la preferencia guardada.
|
|
409
|
-
applyPanelWidths();
|
|
410
|
-
attachResizer(panels.sidebar);
|
|
411
|
-
attachResizer(panels.explorer);
|
|
412
|
-
setSidebarOpen(isMobile() ? false : readSidebarPref());
|
|
413
|
-
$('#sidebar-collapse')?.addEventListener('click', () => setSidebarOpen(false));
|
|
414
|
-
$('#sidebar-handle')?.addEventListener('click', () => setSidebarOpen(true));
|
|
415
|
-
if (sidebarBackdrop) sidebarBackdrop.addEventListener('click', () => setSidebarOpen(false));
|
|
416
|
-
mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function formatK(n) {
|
|
420
|
-
const value = Number(n) || 0;
|
|
421
|
-
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
|
422
|
-
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
|
423
|
-
return String(value);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
export function renderTokens() {
|
|
427
|
-
const wrap = $('#sb-context');
|
|
428
|
-
const node = $('#sb-tokens');
|
|
429
|
-
const fill = $('#sb-context-fill');
|
|
430
|
-
const record = state.panes.get(state.sessionId);
|
|
431
|
-
const tokens = record?.tokens ?? { in: 0, out: 0 };
|
|
432
|
-
const lastTurn = record?.lastTurn ?? { prompt: 0, completion: 0 };
|
|
433
|
-
const total = tokens.in + tokens.out;
|
|
434
|
-
const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
|
|
435
|
-
// El contexto es la ultima ronda (prompt+completion), no el acumulado del run.
|
|
436
|
-
const used = lastTurn.prompt + lastTurn.completion;
|
|
437
|
-
const hasContext = context > 0 && used > 0;
|
|
438
|
-
const pct = hasContext ? Math.min(100, Math.round((used / context) * 100)) : 0;
|
|
439
|
-
const tone = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
|
|
440
|
-
|
|
441
|
-
if (!total && !used) {
|
|
442
|
-
if (wrap) wrap.className = 'ctx muted';
|
|
443
|
-
node.textContent = 'tokens —';
|
|
444
|
-
node.title = '';
|
|
445
|
-
if (fill) fill.style.width = '0%';
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
const parts =
|
|
449
|
-
`entrada ${formatK(tokens.in)} · salida ${formatK(tokens.out)}` +
|
|
450
|
-
(hasContext ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
|
|
451
|
-
if (hasContext) {
|
|
452
|
-
node.textContent = `contexto ${pct}% · ${formatK(used)}/${formatK(context)}`;
|
|
453
|
-
if (wrap) wrap.className = `ctx ${tone}`;
|
|
454
|
-
} else if (context) {
|
|
455
|
-
node.textContent = `contexto
|
|
456
|
-
if (wrap) wrap.className = 'ctx muted';
|
|
457
|
-
} else {
|
|
458
|
-
node.textContent = `tokens ${formatK(total)}`;
|
|
459
|
-
if (wrap) wrap.className = 'ctx muted';
|
|
460
|
-
}
|
|
461
|
-
node.title = parts;
|
|
462
|
-
if (fill) fill.style.width = `${hasContext ? pct : 0}%`;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
export function setContextUsage(tokens) {
|
|
466
|
-
const record = state.panes.get(state.sessionId);
|
|
467
|
-
if (record && tokens) {
|
|
468
|
-
record.lastTurn = {
|
|
469
|
-
prompt: tokens.last_prompt_tokens ?? 0,
|
|
470
|
-
completion: tokens.last_completion_tokens ?? 0,
|
|
471
|
-
};
|
|
472
|
-
renderTokens();
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
export function addTokens(tokens) {
|
|
477
|
-
const record = state.panes.get(state.sessionId);
|
|
478
|
-
if (record && tokens) {
|
|
479
|
-
record.tokens.in += tokens.prompt_tokens ?? 0;
|
|
480
|
-
record.tokens.out += tokens.completion_tokens ?? 0;
|
|
481
|
-
record.lastTurn = {
|
|
482
|
-
prompt: tokens.last_prompt_tokens ?? 0,
|
|
483
|
-
completion: tokens.last_completion_tokens ?? 0,
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
renderTokens();
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
export async function saveSettings(patch) {
|
|
490
|
-
const out = await api('/api/settings', { method: 'PUT', body: patch }).catch(() => null);
|
|
491
|
-
if (out?.settings) {
|
|
492
|
-
state.active.provider = out.settings.provider;
|
|
493
|
-
state.active.model = out.settings.model;
|
|
494
|
-
}
|
|
495
|
-
if (patch.provider) providerSelect.value = patch.provider;
|
|
496
|
-
updateStatus();
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* Persiste el tipo de agente elegido. Con una sesion abierta, ese tipo es de la
|
|
501
|
-
* sesion (asi dos pestañas pueden correr con proveedor/modelo/modo distintos);
|
|
502
|
-
* ademas se actualiza el default global para que las sesiones nuevas hereden.
|
|
503
|
-
*/
|
|
504
|
-
export async function persistAgent(patch) {
|
|
505
|
-
const record = state.panes.get(state.sessionId);
|
|
506
|
-
if (record) {
|
|
507
|
-
if (patch.provider) record.provider = patch.provider;
|
|
508
|
-
if (patch.model) record.model = patch.model;
|
|
509
|
-
if (patch.mode) record.mode = patch.mode;
|
|
510
|
-
api(`/api/sessions/${record.id}`, { method: 'PUT', body: patch }).catch(() => null);
|
|
511
|
-
}
|
|
512
|
-
await saveSettings(patch);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
/** Aplica a los controles compartidos el tipo de agente guardado en una sesion. */
|
|
516
|
-
export async function applySessionAgent(record) {
|
|
517
|
-
if (!record) return;
|
|
518
|
-
const id = record.id;
|
|
519
|
-
const provider = record.provider || state.active.provider;
|
|
520
|
-
if (provider && [...providerSelect.options].some((o) => o.value === provider)) {
|
|
521
|
-
providerSelect.value = provider;
|
|
522
|
-
}
|
|
523
|
-
await loadModels({ prefer: record.model || null });
|
|
524
|
-
if (state.sessionId !== id) return;
|
|
525
|
-
const model = record.model || state.active.model;
|
|
526
|
-
if (model && [...modelSelect.options].some((o) => o.value === model)) modelSelect.value = model;
|
|
527
|
-
const mode = record.mode || modeSelect.value || 'agent';
|
|
528
|
-
if ([...modeSelect.options].some((o) => o.value === mode)) modeSelect.value = mode;
|
|
529
|
-
// No auto-encendemos manos libres al abrir (evita pedir el micro al recargar);
|
|
530
|
-
// si la sesión no es de voz, sí apagamos.
|
|
531
|
-
if (mode !== 'voice') setHandsFree(false);
|
|
532
|
-
updateStatus();
|
|
533
|
-
renderModePill();
|
|
534
|
-
renderModelPill();
|
|
535
|
-
}
|
|
536
|
-
|
|
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 } = {}) {
|
|
550
|
-
const provider = providerSelect.value;
|
|
551
|
-
const cached = !force && state.modelCatalogProvider === provider && state.modelCatalog?.length;
|
|
552
|
-
modelSelect.disabled = true;
|
|
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
|
-
}
|
|
571
|
-
|
|
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
|
-
}
|
|
587
|
-
|
|
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;
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/* --- folder picker -------------------------------------------------------- */
|
|
617
|
-
|
|
618
|
-
export const folderModal = $('#folder-modal');
|
|
619
|
-
export const folderList = $('#folder-list');
|
|
620
|
-
export const folderCrumbs = $('#folder-crumbs');
|
|
621
|
-
export const folderStatusEl = $('#folder-status');
|
|
622
|
-
export const folderGoto = $('#folder-goto');
|
|
623
|
-
export const folderCreate = $('#folder-create');
|
|
624
|
-
export const folderNewName = $('#folder-new-name');
|
|
625
|
-
export const folderUseBtn = $('#folder-use');
|
|
626
|
-
export let folderListPath = null;
|
|
627
|
-
export let folderParent = null;
|
|
628
|
-
export let folderDirs = [];
|
|
629
|
-
|
|
630
|
-
export let folderPickerMode = 'session';
|
|
631
|
-
// La carpeta predeterminada vive en el server (la usan la consola y Telegram),
|
|
632
|
-
// no en localStorage; acá se cachea para poder leerla sin await.
|
|
633
|
-
export let defaultCwdCache = '';
|
|
634
|
-
|
|
635
|
-
export function readDefaultCwd() {
|
|
636
|
-
return defaultCwdCache || '';
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
export function writeDefaultCwd(value) {
|
|
640
|
-
defaultCwdCache = value || '';
|
|
641
|
-
api('/api/settings', { method: 'PUT', body: { defaultCwd: defaultCwdCache } }).catch(() => {});
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
export function syncDefaultCwd() {
|
|
645
|
-
const input = $('#default-cwd');
|
|
646
|
-
if (input) input.value = readDefaultCwd();
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
export 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';
|
|
650
|
-
|
|
651
|
-
export function folderIcon() {
|
|
652
|
-
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
653
|
-
svg.setAttribute('viewBox', '0 0 22 22');
|
|
654
|
-
svg.setAttribute('class', 'folder-ico');
|
|
655
|
-
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
656
|
-
path.setAttribute('d', FOLDER_PATH);
|
|
657
|
-
path.setAttribute('fill', 'none');
|
|
658
|
-
path.setAttribute('stroke', 'currentColor');
|
|
659
|
-
path.setAttribute('stroke-width', '1.4');
|
|
660
|
-
path.setAttribute('stroke-linejoin', 'round');
|
|
661
|
-
svg.appendChild(path);
|
|
662
|
-
return svg;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
export function setSummaryText(node, text, cls = '') {
|
|
666
|
-
node.className = `summary ${cls}`.trim();
|
|
667
|
-
node.textContent = text;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/** Variante para una linea de estado propia (explorer/preview/git). */
|
|
671
|
-
export function setStatusText(node, text, isError = false) {
|
|
672
|
-
node.textContent = text;
|
|
673
|
-
node.classList.toggle('err', isError);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
export function setFolderStatus(text, cls = '') {
|
|
677
|
-
setSummaryText(folderStatusEl, text, cls);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
export function closeFolderPicker() {
|
|
681
|
-
if (folderModal.open) folderModal.close();
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
export function openFolderPicker(mode = 'session') {
|
|
685
|
-
folderPickerMode = mode === 'default' ? 'default' : 'session';
|
|
686
|
-
folderGoto.value = '';
|
|
687
|
-
setFolderStatus('');
|
|
688
|
-
folderModal.showModal();
|
|
689
|
-
const def = readDefaultCwd();
|
|
690
|
-
if (!def) return loadFolders(state.home || '');
|
|
691
|
-
api(`/api/folders?path=${encodeURIComponent(def)}`)
|
|
692
|
-
.then(() => loadFolders(def))
|
|
693
|
-
.catch(() => {
|
|
694
|
-
writeDefaultCwd('');
|
|
695
|
-
syncDefaultCwd();
|
|
696
|
-
loadFolders(state.home || '');
|
|
697
|
-
});
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
export function renderCrumbs(pathStr) {
|
|
701
|
-
clear(folderCrumbs);
|
|
702
|
-
if (!pathStr) {
|
|
703
|
-
folderCrumbs.appendChild(el('span', 'crumb current', 'unidades'));
|
|
704
|
-
return;
|
|
705
|
-
}
|
|
706
|
-
const win = pathStr.includes('\\');
|
|
707
|
-
const parts = pathStr.split(/[\\/]+/).filter(Boolean);
|
|
708
|
-
const segments = parts.map((part, index) =>
|
|
709
|
-
win
|
|
710
|
-
? { label: part, path: index === 0 ? `${part}\\` : parts.slice(0, index + 1).join('\\') }
|
|
711
|
-
: { label: part, path: `/${parts.slice(0, index + 1).join('/')}` }
|
|
712
|
-
);
|
|
713
|
-
segments.forEach((segment, index) => {
|
|
714
|
-
if (index) folderCrumbs.appendChild(el('span', 'crumb-sep', '/'));
|
|
715
|
-
const last = index === segments.length - 1;
|
|
716
|
-
const node = el('span', `crumb${last ? ' current' : ''}`, segment.label);
|
|
717
|
-
if (!last) node.addEventListener('click', () => loadFolders(segment.path));
|
|
718
|
-
folderCrumbs.appendChild(node);
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
export function renderFolderList(dirs) {
|
|
723
|
-
clear(folderList);
|
|
724
|
-
if (!dirs.length) {
|
|
725
|
-
folderList.appendChild(
|
|
726
|
-
el('li', 'folders-empty', folderListPath ? 'sin subcarpetas (podés usar esta carpeta igual)' : 'sin unidades')
|
|
727
|
-
);
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
for (const dir of dirs) {
|
|
731
|
-
const li = el('li', 'folder-item');
|
|
732
|
-
li.title = dir.path;
|
|
733
|
-
li.tabIndex = 0;
|
|
734
|
-
li.append(folderIcon(), el('span', 'folder-name', dir.name), el('span', 'folder-enter', '↵'));
|
|
735
|
-
const enter = () => loadFolders(dir.path);
|
|
736
|
-
li.addEventListener('click', enter);
|
|
737
|
-
li.addEventListener('keydown', (event) => {
|
|
738
|
-
if (event.key === 'Enter' || event.key === ' ') {
|
|
739
|
-
event.preventDefault();
|
|
740
|
-
enter();
|
|
741
|
-
}
|
|
742
|
-
});
|
|
743
|
-
folderList.appendChild(li);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
export async function loadFolders(target = '') {
|
|
748
|
-
folderGoto.value = '';
|
|
749
|
-
closeCreateFolder();
|
|
750
|
-
setFolderStatus('cargando…');
|
|
751
|
-
const data = await api(`/api/folders?path=${encodeURIComponent(target)}`).catch((error) => {
|
|
752
|
-
setFolderStatus(error.message, 'err');
|
|
753
|
-
return null;
|
|
754
|
-
});
|
|
755
|
-
if (!data) return;
|
|
756
|
-
folderListPath = data.path;
|
|
757
|
-
folderParent = data.parent;
|
|
758
|
-
folderDirs = [...data.dirs].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
759
|
-
renderCrumbs(data.path);
|
|
760
|
-
$('#folder-up').disabled = !data.path;
|
|
761
|
-
$('#folder-new').disabled = !data.path;
|
|
762
|
-
folderUseBtn.disabled = !data.path;
|
|
763
|
-
if (folderPickerMode === 'default') {
|
|
764
|
-
folderUseBtn.textContent = data.path ? `predeterminada: «${basename(data.path)}»` : 'usar como predeterminada';
|
|
765
|
-
} else {
|
|
766
|
-
folderUseBtn.textContent = data.path ? `usar «${basename(data.path)}»` : 'usar esta carpeta';
|
|
767
|
-
}
|
|
768
|
-
renderFolderList(folderDirs);
|
|
769
|
-
const count = data.path ? `${folderDirs.length} subcarpetas` : `${folderDirs.length} unidades`;
|
|
770
|
-
setFolderStatus(data.path ? `${count} · clic para entrar` : 'elegí una unidad');
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
export async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
|
|
774
|
-
const current = state.panes.get(state.sessionId);
|
|
775
|
-
const created = await api('/api/sessions', {
|
|
776
|
-
method: 'POST',
|
|
777
|
-
body: {
|
|
778
|
-
title,
|
|
779
|
-
cwd,
|
|
780
|
-
provider: current?.provider || providerSelect.value || undefined,
|
|
781
|
-
model: current?.model || modelSelect.value || undefined,
|
|
782
|
-
mode: current?.mode || modeSelect.value || undefined,
|
|
783
|
-
},
|
|
784
|
-
}).catch((error) => ({
|
|
785
|
-
error: error.message,
|
|
786
|
-
}));
|
|
787
|
-
if (created?.error) return created;
|
|
788
|
-
registerSession(created.session, []);
|
|
789
|
-
activateSession(created.session.id);
|
|
790
|
-
const pending = state.pendingInput;
|
|
791
|
-
if (pending !== null && pending !== undefined) {
|
|
792
|
-
state.pendingInput = null;
|
|
793
|
-
chatInput.value = pending;
|
|
794
|
-
sendMessage();
|
|
795
|
-
}
|
|
796
|
-
return created;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
export async function useFolder() {
|
|
800
|
-
const cwd = folderListPath;
|
|
801
|
-
if (!cwd) return setFolderStatus('Entrá a una carpeta primero (clic para navegar).', 'err');
|
|
802
|
-
if (folderPickerMode === 'default') {
|
|
803
|
-
writeDefaultCwd(cwd);
|
|
804
|
-
syncDefaultCwd();
|
|
805
|
-
closeFolderPicker();
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
const created = await createSessionIn(cwd);
|
|
809
|
-
if (created?.error) return setFolderStatus(created.error, 'err');
|
|
810
|
-
closeFolderPicker();
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
export function filterFolders() {
|
|
814
|
-
const query = folderGoto.value.trim().toLowerCase();
|
|
815
|
-
if (!query) return renderFolderList(folderDirs);
|
|
816
|
-
renderFolderList(folderDirs.filter((dir) => dir.name.toLowerCase().includes(query)));
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
export function gotoFolder() {
|
|
820
|
-
const value = folderGoto.value.trim();
|
|
821
|
-
if (!value) return;
|
|
822
|
-
if (/[\\/]/.test(value) || /^[a-z]:$/i.test(value)) return loadFolders(value);
|
|
823
|
-
const matches = folderDirs.filter((dir) => dir.name.toLowerCase().includes(value.toLowerCase()));
|
|
824
|
-
if (matches.length === 1) loadFolders(matches[0].path);
|
|
825
|
-
else if (!matches.length) setFolderStatus('sin coincidencias', 'err');
|
|
826
|
-
else setFolderStatus(`${matches.length} coincidencias`, '');
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
export function openCreateFolder() {
|
|
830
|
-
if (!folderListPath) return;
|
|
831
|
-
folderCreate.hidden = false;
|
|
832
|
-
folderNewName.value = '';
|
|
833
|
-
folderNewName.focus();
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
export function closeCreateFolder() {
|
|
837
|
-
folderCreate.hidden = true;
|
|
838
|
-
folderNewName.value = '';
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
export async function createFolder() {
|
|
842
|
-
const name = folderNewName.value.trim();
|
|
843
|
-
if (!name || !folderListPath) return;
|
|
844
|
-
const created = await api('/api/folders', {
|
|
845
|
-
method: 'POST',
|
|
846
|
-
body: { parent: folderListPath, name },
|
|
847
|
-
}).catch((error) => {
|
|
848
|
-
setFolderStatus(error.message, 'err');
|
|
849
|
-
return null;
|
|
850
|
-
});
|
|
851
|
-
if (!created) return;
|
|
852
|
-
await loadFolders(created.path);
|
|
853
|
-
setFolderStatus(`carpeta «${name}» creada · usala para el nuevo proyecto`, 'ok');
|
|
854
|
-
}
|
|
855
|
-
|
|
67
|
+
moreTab.classList.toggle('active', name !== 'chat');
|
|
68
|
+
document.querySelectorAll('.view').forEach((v) => v.classList.toggle('active', v.id === `view-${name}`));
|
|
69
|
+
hideMoreMenu();
|
|
70
|
+
if (name === 'runs') refreshRuns();
|
|
71
|
+
if (name === 'providers') refreshProviders();
|
|
72
|
+
if (name === 'ajustes') { setAjustesLevel('list'); refreshMcp(); refreshMcpCatalog(); refreshSkills(); refreshSkillCatalog(); refreshMemory(); refreshSchedules(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* --- settings modal (modo / proveedor / modelo / auto) -------------------- */
|
|
76
|
+
|
|
77
|
+
export const settingsModal = $('#settings-modal');
|
|
78
|
+
export const agentControls = $('#agent-settings-controls');
|
|
79
|
+
export const agentControlsSlot = $('#agent-controls-slot');
|
|
80
|
+
export const settingsControlsSlot = $('#settings-controls-slot');
|
|
81
|
+
|
|
82
|
+
/** Los controles modo/proveedor/modelo/auto se comparten entre el tab Agente
|
|
83
|
+
* y el modal: se mueven de un slot al otro para no duplicar IDs ni estado. */
|
|
84
|
+
export function moveAgentControls(target) {
|
|
85
|
+
if (agentControls && target) target.appendChild(agentControls);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function openSettingsModal() {
|
|
89
|
+
if (!settingsModal) return;
|
|
90
|
+
moveAgentControls(settingsControlsSlot);
|
|
91
|
+
syncDefaultCwd();
|
|
92
|
+
settingsModal.showModal();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function closeSettingsModal() {
|
|
96
|
+
moveAgentControls(agentControlsSlot);
|
|
97
|
+
settingsModal?.close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// El footer (modo · proveedor · modelo · estado) abre el modal de ajustes.
|
|
101
|
+
export const statusbarEl = $('#statusbar');
|
|
102
|
+
|
|
103
|
+
/* --- topbar: provider + model + status ----------------------------------- */
|
|
104
|
+
|
|
105
|
+
export const providerSelect = $('#provider');
|
|
106
|
+
export const modelSelect = $('#model');
|
|
107
|
+
export const modeSelect = $('#mode');
|
|
108
|
+
export const yesInput = $('#yes');
|
|
109
|
+
export const strictInput = $('#strict-scope');
|
|
110
|
+
|
|
111
|
+
export async function loadModes() {
|
|
112
|
+
const data = await api('/api/modes').catch(() => ({ modes: [] }));
|
|
113
|
+
clear(modeSelect);
|
|
114
|
+
for (const mode of data.modes) {
|
|
115
|
+
const option = document.createElement('option');
|
|
116
|
+
option.value = mode.id;
|
|
117
|
+
option.textContent = mode.label;
|
|
118
|
+
option.title = mode.description ?? '';
|
|
119
|
+
modeSelect.appendChild(option);
|
|
120
|
+
}
|
|
121
|
+
const settings = await api('/api/settings').catch(() => null);
|
|
122
|
+
const active = settings?.settings?.mode || 'agent';
|
|
123
|
+
if ([...modeSelect.options].some((o) => o.value === active)) modeSelect.value = active;
|
|
124
|
+
if (strictInput) strictInput.checked = settings?.settings?.strictScope === true;
|
|
125
|
+
defaultCwdCache = settings?.settings?.defaultCwd || '';
|
|
126
|
+
if (!defaultCwdCache) {
|
|
127
|
+
// Migración: la carpeta predeterminada vivía en localStorage; la subimos al
|
|
128
|
+
// server una vez para que Telegram también la vea.
|
|
129
|
+
const legacy = storage.get('tamnora-default-cwd', '');
|
|
130
|
+
if (legacy) writeDefaultCwd(legacy);
|
|
131
|
+
}
|
|
132
|
+
syncDefaultCwd();
|
|
133
|
+
if (settings?.settings?.appearance) {
|
|
134
|
+
appearance = normalizeAppearance(settings.settings.appearance);
|
|
135
|
+
applyAppearance(appearance, { persist: true });
|
|
136
|
+
renderAppearanceControls();
|
|
137
|
+
}
|
|
138
|
+
updateStatus();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function setMode(id, { persist = true } = {}) {
|
|
142
|
+
if (![...modeSelect.options].some((o) => o.value === id)) return;
|
|
143
|
+
modeSelect.value = id;
|
|
144
|
+
if (persist) await persistAgent({ mode: id });
|
|
145
|
+
// Modo Voz = manos libres (conversación); al salir, se apaga.
|
|
146
|
+
setHandsFree(id === 'voice');
|
|
147
|
+
updateStatus();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function toggleMode() {
|
|
151
|
+
const next = modeSelect.value === 'plan' ? 'agent' : 'plan';
|
|
152
|
+
await setMode(next);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* --- pills del composer (carpeta, permisos, modelo) ----------------------- */
|
|
156
|
+
|
|
157
|
+
export const cwdPill = $('#cwd-pill');
|
|
158
|
+
export const cwdPillLabel = $('#cwd-pill-label');
|
|
159
|
+
export const modePill = $('#mode-pill');
|
|
160
|
+
export const modePillLabel = $('#mode-pill-label');
|
|
161
|
+
export const modeMenu = $('#mode-menu');
|
|
162
|
+
export const modelPill = $('#model-pill');
|
|
163
|
+
export const modelPillLabel = $('#model-pill-label');
|
|
164
|
+
|
|
165
|
+
export function renderCwdPill() {
|
|
166
|
+
if (!cwdPillLabel) return;
|
|
167
|
+
cwdPillLabel.textContent = state.cwd ? basename(state.cwd) : 'sin carpeta';
|
|
168
|
+
if (cwdPill) cwdPill.title = state.cwd || 'sin carpeta de trabajo';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// El pill de permisos resume modo + auto-aprobación en un control estilo Codex:
|
|
172
|
+
// Read Only, Workspace Write, Full access y Voz (notas .md/.json).
|
|
173
|
+
export function renderModePill() {
|
|
174
|
+
const plan = modeSelect.value === 'plan';
|
|
175
|
+
const voice = modeSelect.value === 'voice';
|
|
176
|
+
const full = !plan && !voice && Boolean(yesInput?.checked);
|
|
177
|
+
if (modePillLabel) {
|
|
178
|
+
modePillLabel.textContent = voice ? 'Voz' : plan ? 'Read Only' : full ? 'Full access' : 'Workspace Write';
|
|
179
|
+
}
|
|
180
|
+
if (!modeMenu) return;
|
|
181
|
+
for (const item of modeMenu.querySelectorAll('.ctrl-menu-item')) {
|
|
182
|
+
const kind = item.dataset.kind;
|
|
183
|
+
const active =
|
|
184
|
+
kind === 'read'
|
|
185
|
+
? plan
|
|
186
|
+
: kind === 'voice'
|
|
187
|
+
? voice
|
|
188
|
+
: kind === 'full'
|
|
189
|
+
? full
|
|
190
|
+
: !plan && !voice && !full;
|
|
191
|
+
item.classList.toggle('active', active);
|
|
192
|
+
item.setAttribute('aria-checked', active ? 'true' : 'false');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function renderModelPill() {
|
|
197
|
+
if (!modelPillLabel) return;
|
|
198
|
+
const record = state.panes.get(state.sessionId);
|
|
199
|
+
const provider = record?.provider || state.active.provider;
|
|
200
|
+
const model = record?.model || state.active.model;
|
|
201
|
+
modelPillLabel.textContent = model || 'elegí un modelo';
|
|
202
|
+
if (modelPill) modelPill.title = `${provider || '—'} / ${model || '—'}`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function closeModeMenu() {
|
|
206
|
+
if (!modeMenu || modeMenu.hidden) return;
|
|
207
|
+
modeMenu.hidden = true;
|
|
208
|
+
modePill?.setAttribute('aria-expanded', 'false');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function updateStatus(extra = {}) {
|
|
212
|
+
renderModePill();
|
|
213
|
+
renderModelPill();
|
|
214
|
+
if (extra.rounds !== undefined) $('#sb-rounds').textContent = `${extra.rounds} rondas`;
|
|
215
|
+
if (extra.stopReason) $('#sb-rounds').textContent = extra.stopReason;
|
|
216
|
+
if (extra.running) $('#sb-rounds').textContent = 'pensando…';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/* --- apariencia (tema, tipografía, tamaño) -------------------------------- */
|
|
220
|
+
|
|
221
|
+
export let appearance = normalizeAppearance(readLocalAppearance());
|
|
222
|
+
export const themeToggle = $('#theme-toggle');
|
|
223
|
+
export const themeGrid = $('#apariencia-themes');
|
|
224
|
+
export const fontSelect = $('#apariencia-font');
|
|
225
|
+
export const chatFontRange = $('#apariencia-chat-font');
|
|
226
|
+
export const chatFontOut = $('#apariencia-chat-font-out');
|
|
227
|
+
export const scaleRange = $('#apariencia-scale');
|
|
228
|
+
export const scaleOut = $('#apariencia-scale-out');
|
|
229
|
+
|
|
230
|
+
export function renderAppearanceControls() {
|
|
231
|
+
if (themeGrid) {
|
|
232
|
+
themeGrid
|
|
233
|
+
.querySelectorAll('.theme-swatch')
|
|
234
|
+
.forEach((btn) => btn.classList.toggle('active', btn.dataset.themePick === appearance.theme));
|
|
235
|
+
}
|
|
236
|
+
if (fontSelect) fontSelect.value = appearance.font;
|
|
237
|
+
if (chatFontRange) chatFontRange.value = appearance.chatFont;
|
|
238
|
+
if (chatFontOut) chatFontOut.textContent = `${appearance.chatFont}px`;
|
|
239
|
+
if (scaleRange) scaleRange.value = Math.round(appearance.scale * 100);
|
|
240
|
+
if (scaleOut) scaleOut.textContent = `${Math.round(appearance.scale * 100)}%`;
|
|
241
|
+
if (themeToggle) {
|
|
242
|
+
const label = THEMES.find((t) => t.id === appearance.theme)?.label ?? appearance.theme;
|
|
243
|
+
themeToggle.title = `tema: ${label}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function persistAppearance() {
|
|
248
|
+
applyAppearance(appearance, { persist: true });
|
|
249
|
+
renderAppearanceControls();
|
|
250
|
+
api('/api/settings', { method: 'PUT', body: { appearance } }).catch(() => null);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function buildAppearanceControls() {
|
|
254
|
+
if (themeGrid) {
|
|
255
|
+
themeGrid.replaceChildren();
|
|
256
|
+
for (const theme of THEMES) {
|
|
257
|
+
const swatch = document.createElement('button');
|
|
258
|
+
swatch.type = 'button';
|
|
259
|
+
swatch.className = 'theme-swatch';
|
|
260
|
+
swatch.dataset.themePick = theme.id;
|
|
261
|
+
swatch.title = theme.label;
|
|
262
|
+
swatch.setAttribute('aria-label', `tema ${theme.label}`);
|
|
263
|
+
const dot = document.createElement('span');
|
|
264
|
+
dot.className = 'theme-swatch-dot';
|
|
265
|
+
dot.style.background = theme.metaColor;
|
|
266
|
+
const label = document.createElement('span');
|
|
267
|
+
label.className = 'theme-swatch-label';
|
|
268
|
+
label.textContent = theme.label;
|
|
269
|
+
swatch.append(dot, label);
|
|
270
|
+
swatch.addEventListener('click', () => {
|
|
271
|
+
appearance.theme = theme.id;
|
|
272
|
+
persistAppearance();
|
|
273
|
+
});
|
|
274
|
+
themeGrid.appendChild(swatch);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (fontSelect) {
|
|
278
|
+
fontSelect.replaceChildren();
|
|
279
|
+
for (const font of FONT_PRESETS) {
|
|
280
|
+
const option = document.createElement('option');
|
|
281
|
+
option.value = font.id;
|
|
282
|
+
option.textContent = font.label;
|
|
283
|
+
fontSelect.appendChild(option);
|
|
284
|
+
}
|
|
285
|
+
fontSelect.addEventListener('change', () => {
|
|
286
|
+
appearance.font = fontSelect.value;
|
|
287
|
+
persistAppearance();
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
chatFontRange?.addEventListener('input', () => {
|
|
291
|
+
appearance.chatFont = clampChatFont(Number(chatFontRange.value));
|
|
292
|
+
applyAppearance(appearance, { persist: false });
|
|
293
|
+
if (chatFontOut) chatFontOut.textContent = `${appearance.chatFont}px`;
|
|
294
|
+
});
|
|
295
|
+
chatFontRange?.addEventListener('change', persistAppearance);
|
|
296
|
+
scaleRange?.addEventListener('input', () => {
|
|
297
|
+
appearance.scale = clampScale(Number(scaleRange.value) / 100);
|
|
298
|
+
applyAppearance(appearance, { persist: false });
|
|
299
|
+
if (scaleOut) scaleOut.textContent = `${Math.round(appearance.scale * 100)}%`;
|
|
300
|
+
});
|
|
301
|
+
scaleRange?.addEventListener('change', persistAppearance);
|
|
302
|
+
$('#apariencia-reset')?.addEventListener('click', () => {
|
|
303
|
+
appearance = normalizeAppearance(DEFAULTS);
|
|
304
|
+
persistAppearance();
|
|
305
|
+
setStatus('#apariencia-msg', 'restablecido', 'ok');
|
|
306
|
+
});
|
|
307
|
+
renderAppearanceControls();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function cycleTheme() {
|
|
311
|
+
appearance.theme = nextTheme(appearance.theme);
|
|
312
|
+
persistAppearance();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function openSettingsPanel(name) {
|
|
316
|
+
activateTab('ajustes');
|
|
317
|
+
activateAjustesPanel(name);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
appearance = applyAppearance(appearance, { persist: false });
|
|
321
|
+
|
|
322
|
+
export const logout = $('#logout');
|
|
323
|
+
|
|
324
|
+
/* --- layout: resizable + collapsible panels ------------------------------- */
|
|
325
|
+
|
|
326
|
+
export const panels = {
|
|
327
|
+
sidebar: { el: $('#sidebar'), resizer: $('#resizer-sidebar'), min: 180, max: 460, width: 260, side: 'left' },
|
|
328
|
+
explorer: { el: $('#explorer'), resizer: $('#resizer-explorer'), min: 200, max: 560, width: 300, side: 'right' },
|
|
329
|
+
};
|
|
330
|
+
export const sidebarBackdrop = $('#sidebar-backdrop');
|
|
331
|
+
export const mobileQuery = window.matchMedia('(max-width: 820px)');
|
|
332
|
+
|
|
333
|
+
export function isMobile() {
|
|
334
|
+
return mobileQuery.matches;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function readSidebarPref() {
|
|
338
|
+
return storage.get('tamnora-sidebar-open', '0') !== '0';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export function persistLayout() {
|
|
342
|
+
storage.setJson('tamnora-panel-widths', {
|
|
343
|
+
sidebar: panels.sidebar.width,
|
|
344
|
+
explorer: panels.explorer.width,
|
|
345
|
+
});
|
|
346
|
+
scheduleScrollNav();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function applyPanelWidths() {
|
|
350
|
+
panels.sidebar.el.style.width = `${panels.sidebar.width}px`;
|
|
351
|
+
panels.explorer.el.style.width = `${panels.explorer.width}px`;
|
|
352
|
+
scheduleScrollNav();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function attachResizer(panel) {
|
|
356
|
+
panel.resizer.addEventListener('pointerdown', (event) => {
|
|
357
|
+
if (panel.resizer.hidden) return;
|
|
358
|
+
event.preventDefault();
|
|
359
|
+
const startX = event.clientX;
|
|
360
|
+
const startWidth = panel.width;
|
|
361
|
+
panel.resizer.classList.add('dragging');
|
|
362
|
+
document.body.classList.add('resizing');
|
|
363
|
+
panel.resizer.setPointerCapture(event.pointerId);
|
|
364
|
+
const move = (moveEvent) => {
|
|
365
|
+
const delta = panel.side === 'left' ? moveEvent.clientX - startX : startX - moveEvent.clientX;
|
|
366
|
+
panel.width = Math.min(panel.max, Math.max(panel.min, startWidth + delta));
|
|
367
|
+
panel.el.style.width = `${panel.width}px`;
|
|
368
|
+
};
|
|
369
|
+
const up = () => {
|
|
370
|
+
panel.resizer.classList.remove('dragging');
|
|
371
|
+
document.body.classList.remove('resizing');
|
|
372
|
+
panel.resizer.removeEventListener('pointermove', move);
|
|
373
|
+
panel.resizer.removeEventListener('pointerup', up);
|
|
374
|
+
persistLayout();
|
|
375
|
+
};
|
|
376
|
+
panel.resizer.addEventListener('pointermove', move);
|
|
377
|
+
panel.resizer.addEventListener('pointerup', up);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function setSidebarOpen(open) {
|
|
382
|
+
state.sidebarOpen = open;
|
|
383
|
+
if (isMobile()) {
|
|
384
|
+
// Drawer: siempre en el DOM; la visibilidad la maneja la clase + backdrop.
|
|
385
|
+
panels.sidebar.el.hidden = false;
|
|
386
|
+
panels.sidebar.el.classList.toggle('open', open);
|
|
387
|
+
panels.sidebar.resizer.hidden = true;
|
|
388
|
+
if (sidebarBackdrop) sidebarBackdrop.hidden = !open;
|
|
389
|
+
} else {
|
|
390
|
+
panels.sidebar.el.classList.remove('open');
|
|
391
|
+
panels.sidebar.el.hidden = !open;
|
|
392
|
+
panels.sidebar.resizer.hidden = !open;
|
|
393
|
+
if (sidebarBackdrop) sidebarBackdrop.hidden = true;
|
|
394
|
+
}
|
|
395
|
+
const handle = $('#sidebar-handle');
|
|
396
|
+
if (handle) {
|
|
397
|
+
handle.hidden = open;
|
|
398
|
+
handle.setAttribute('aria-pressed', String(open));
|
|
399
|
+
}
|
|
400
|
+
if (!isMobile()) {
|
|
401
|
+
storage.set('tamnora-sidebar-open', open ? '1' : '0');
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function initLayout() {
|
|
406
|
+
const widths = storage.getJson('tamnora-panel-widths', {}) || {};
|
|
407
|
+
if (Number(widths.sidebar)) panels.sidebar.width = Math.min(panels.sidebar.max, Math.max(panels.sidebar.min, widths.sidebar));
|
|
408
|
+
if (Number(widths.explorer)) panels.explorer.width = Math.min(panels.explorer.max, Math.max(panels.explorer.min, widths.explorer));
|
|
409
|
+
// En movil el panel arranca cerrado (drawer); en desktop, la preferencia guardada.
|
|
410
|
+
applyPanelWidths();
|
|
411
|
+
attachResizer(panels.sidebar);
|
|
412
|
+
attachResizer(panels.explorer);
|
|
413
|
+
setSidebarOpen(isMobile() ? false : readSidebarPref());
|
|
414
|
+
$('#sidebar-collapse')?.addEventListener('click', () => setSidebarOpen(false));
|
|
415
|
+
$('#sidebar-handle')?.addEventListener('click', () => setSidebarOpen(true));
|
|
416
|
+
if (sidebarBackdrop) sidebarBackdrop.addEventListener('click', () => setSidebarOpen(false));
|
|
417
|
+
mobileQuery.addEventListener('change', () => setSidebarOpen(isMobile() ? false : readSidebarPref()));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export function formatK(n) {
|
|
421
|
+
const value = Number(n) || 0;
|
|
422
|
+
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
|
|
423
|
+
if (value >= 1000) return `${(value / 1000).toFixed(1)}k`;
|
|
424
|
+
return String(value);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function renderTokens() {
|
|
428
|
+
const wrap = $('#sb-context');
|
|
429
|
+
const node = $('#sb-tokens');
|
|
430
|
+
const fill = $('#sb-context-fill');
|
|
431
|
+
const record = state.panes.get(state.sessionId);
|
|
432
|
+
const tokens = record?.tokens ?? { in: 0, out: 0 };
|
|
433
|
+
const lastTurn = record?.lastTurn ?? { prompt: 0, completion: 0 };
|
|
434
|
+
const total = tokens.in + tokens.out;
|
|
435
|
+
const context = Number(modelSelect.selectedOptions[0]?.dataset.context) || 0;
|
|
436
|
+
// El contexto es la ultima ronda (prompt+completion), no el acumulado del run.
|
|
437
|
+
const used = lastTurn.prompt + lastTurn.completion;
|
|
438
|
+
const hasContext = context > 0 && used > 0;
|
|
439
|
+
const pct = hasContext ? Math.min(100, Math.round((used / context) * 100)) : 0;
|
|
440
|
+
const tone = pct >= 75 ? 'ctx-danger' : pct >= 50 ? 'ctx-warn' : pct >= 25 ? 'ctx-mid' : 'ctx-ok';
|
|
441
|
+
|
|
442
|
+
if (!total && !used) {
|
|
443
|
+
if (wrap) wrap.className = 'ctx muted';
|
|
444
|
+
node.textContent = 'tokens —';
|
|
445
|
+
node.title = '';
|
|
446
|
+
if (fill) fill.style.width = '0%';
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const parts =
|
|
450
|
+
`entrada ${formatK(tokens.in)} · salida ${formatK(tokens.out)}` +
|
|
451
|
+
(hasContext ? ` · contexto ${formatK(used)}/${formatK(context)}` : '');
|
|
452
|
+
if (hasContext) {
|
|
453
|
+
node.textContent = `contexto ${pct}% · ${formatK(used)}/${formatK(context)}`;
|
|
454
|
+
if (wrap) wrap.className = `ctx ${tone}`;
|
|
455
|
+
} else if (context) {
|
|
456
|
+
node.textContent = `contexto —/${formatK(context)}`;
|
|
457
|
+
if (wrap) wrap.className = 'ctx muted';
|
|
458
|
+
} else {
|
|
459
|
+
node.textContent = `tokens ${formatK(total)}`;
|
|
460
|
+
if (wrap) wrap.className = 'ctx muted';
|
|
461
|
+
}
|
|
462
|
+
node.title = parts;
|
|
463
|
+
if (fill) fill.style.width = `${hasContext ? pct : 0}%`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export function setContextUsage(tokens) {
|
|
467
|
+
const record = state.panes.get(state.sessionId);
|
|
468
|
+
if (record && tokens) {
|
|
469
|
+
record.lastTurn = {
|
|
470
|
+
prompt: tokens.last_prompt_tokens ?? 0,
|
|
471
|
+
completion: tokens.last_completion_tokens ?? 0,
|
|
472
|
+
};
|
|
473
|
+
renderTokens();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function addTokens(tokens) {
|
|
478
|
+
const record = state.panes.get(state.sessionId);
|
|
479
|
+
if (record && tokens) {
|
|
480
|
+
record.tokens.in += tokens.prompt_tokens ?? 0;
|
|
481
|
+
record.tokens.out += tokens.completion_tokens ?? 0;
|
|
482
|
+
record.lastTurn = {
|
|
483
|
+
prompt: tokens.last_prompt_tokens ?? 0,
|
|
484
|
+
completion: tokens.last_completion_tokens ?? 0,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
renderTokens();
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export async function saveSettings(patch) {
|
|
491
|
+
const out = await api('/api/settings', { method: 'PUT', body: patch }).catch(() => null);
|
|
492
|
+
if (out?.settings) {
|
|
493
|
+
state.active.provider = out.settings.provider;
|
|
494
|
+
state.active.model = out.settings.model;
|
|
495
|
+
}
|
|
496
|
+
if (patch.provider) providerSelect.value = patch.provider;
|
|
497
|
+
updateStatus();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Persiste el tipo de agente elegido. Con una sesion abierta, ese tipo es de la
|
|
502
|
+
* sesion (asi dos pestañas pueden correr con proveedor/modelo/modo distintos);
|
|
503
|
+
* ademas se actualiza el default global para que las sesiones nuevas hereden.
|
|
504
|
+
*/
|
|
505
|
+
export async function persistAgent(patch) {
|
|
506
|
+
const record = state.panes.get(state.sessionId);
|
|
507
|
+
if (record) {
|
|
508
|
+
if (patch.provider) record.provider = patch.provider;
|
|
509
|
+
if (patch.model) record.model = patch.model;
|
|
510
|
+
if (patch.mode) record.mode = patch.mode;
|
|
511
|
+
api(`/api/sessions/${record.id}`, { method: 'PUT', body: patch }).catch(() => null);
|
|
512
|
+
}
|
|
513
|
+
await saveSettings(patch);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Aplica a los controles compartidos el tipo de agente guardado en una sesion. */
|
|
517
|
+
export async function applySessionAgent(record) {
|
|
518
|
+
if (!record) return;
|
|
519
|
+
const id = record.id;
|
|
520
|
+
const provider = record.provider || state.active.provider;
|
|
521
|
+
if (provider && [...providerSelect.options].some((o) => o.value === provider)) {
|
|
522
|
+
providerSelect.value = provider;
|
|
523
|
+
}
|
|
524
|
+
await loadModels({ prefer: record.model || null });
|
|
525
|
+
if (state.sessionId !== id) return;
|
|
526
|
+
const model = record.model || state.active.model;
|
|
527
|
+
if (model && [...modelSelect.options].some((o) => o.value === model)) modelSelect.value = model;
|
|
528
|
+
const mode = record.mode || modeSelect.value || 'agent';
|
|
529
|
+
if ([...modeSelect.options].some((o) => o.value === mode)) modeSelect.value = mode;
|
|
530
|
+
// No auto-encendemos manos libres al abrir (evita pedir el micro al recargar);
|
|
531
|
+
// si la sesión no es de voz, sí apagamos.
|
|
532
|
+
if (mode !== 'voice') setHandsFree(false);
|
|
533
|
+
updateStatus();
|
|
534
|
+
renderModePill();
|
|
535
|
+
renderModelPill();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Firma de la lista dibujada: si no cambia, no hay que repintar el select. */
|
|
539
|
+
function modelsSignature(shown) {
|
|
540
|
+
return shown.map((m) => `${m.id}\u0000${m.label ?? ''}\u0000${m.vision ? 1 : 0}\u0000${m.context ?? ''}`).join('\u0001');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Carga el catalogo del proveedor y dibuja el select.
|
|
545
|
+
*
|
|
546
|
+
* `force` vuelve a pedirlo al server; sin el se reusa el de `state` (son ~374
|
|
547
|
+
* modelos / 591 KB, y esto corre en cada cambio de pestana: refetchearlo hacia
|
|
548
|
+
* parpadear la consola).
|
|
549
|
+
*/
|
|
550
|
+
export async function loadModels({ save = false, prefer = null, force = false } = {}) {
|
|
551
|
+
const provider = providerSelect.value;
|
|
552
|
+
const cached = !force && state.modelCatalogProvider === provider && state.modelCatalog?.length;
|
|
553
|
+
modelSelect.disabled = true;
|
|
554
|
+
try {
|
|
555
|
+
if (!cached) {
|
|
556
|
+
const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
|
|
557
|
+
models: [],
|
|
558
|
+
error: error.message,
|
|
559
|
+
}));
|
|
560
|
+
state.modelCatalog = data?.models ?? [];
|
|
561
|
+
state.visibleModels = data?.visibleModels ?? [];
|
|
562
|
+
state.modelCatalogProvider = provider;
|
|
563
|
+
if (!state.modelCatalog.length) {
|
|
564
|
+
clear(modelSelect);
|
|
565
|
+
const option = document.createElement('option');
|
|
566
|
+
option.value = '';
|
|
567
|
+
option.textContent = data?.error ? `sin modelos - ${data.error}` : 'sin modelos (configura el proveedor)';
|
|
568
|
+
modelSelect.appendChild(option);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const catalog = state.modelCatalog;
|
|
574
|
+
const models = pickVisible(catalog, state.visibleModels);
|
|
575
|
+
const activeModel = prefer || state.active.model;
|
|
576
|
+
// Si el modelo activo quedo fuera de la seleccion, lo mostramos igual para no
|
|
577
|
+
// cambiarlo en silencio: sigue siendo el usado aunque no este tildado.
|
|
578
|
+
const hidden = activeModel && catalog.some((m) => m.id === activeModel) && !models.some((m) => m.id === activeModel);
|
|
579
|
+
const shown = hidden ? [catalog.find((m) => m.id === activeModel), ...models] : models;
|
|
580
|
+
if (!shown.length) {
|
|
581
|
+
clear(modelSelect);
|
|
582
|
+
const option = document.createElement('option');
|
|
583
|
+
option.value = '';
|
|
584
|
+
option.textContent = 'sin modelos (configura el proveedor)';
|
|
585
|
+
modelSelect.appendChild(option);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Repintar el select con cientos de opciones es lo que se ve como parpadeo:
|
|
590
|
+
// solo se hace cuando la lista realmente cambio.
|
|
591
|
+
const signature = modelsSignature(shown);
|
|
592
|
+
if (signature !== state.modelSignature) {
|
|
593
|
+
state.modelSignature = signature;
|
|
594
|
+
clear(modelSelect);
|
|
595
|
+
for (const model of shown) {
|
|
596
|
+
const option = document.createElement('option');
|
|
597
|
+
option.value = model.id;
|
|
598
|
+
option.textContent = model.label && model.label !== model.id ? model.label : model.id;
|
|
599
|
+
if (model.vision) option.textContent += ' - vision';
|
|
600
|
+
if (model.context) option.dataset.context = String(model.context);
|
|
601
|
+
modelSelect.appendChild(option);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const wanted = prefer || state.active.model;
|
|
606
|
+
if (wanted && shown.some((m) => m.id === wanted)) {
|
|
607
|
+
modelSelect.value = wanted;
|
|
608
|
+
} else {
|
|
609
|
+
modelSelect.value = models[0].id;
|
|
610
|
+
if (save) await saveSettings({ model: modelSelect.value });
|
|
611
|
+
}
|
|
612
|
+
} finally {
|
|
613
|
+
modelSelect.disabled = false;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/* --- folder picker -------------------------------------------------------- */
|
|
618
|
+
|
|
619
|
+
export const folderModal = $('#folder-modal');
|
|
620
|
+
export const folderList = $('#folder-list');
|
|
621
|
+
export const folderCrumbs = $('#folder-crumbs');
|
|
622
|
+
export const folderStatusEl = $('#folder-status');
|
|
623
|
+
export const folderGoto = $('#folder-goto');
|
|
624
|
+
export const folderCreate = $('#folder-create');
|
|
625
|
+
export const folderNewName = $('#folder-new-name');
|
|
626
|
+
export const folderUseBtn = $('#folder-use');
|
|
627
|
+
export let folderListPath = null;
|
|
628
|
+
export let folderParent = null;
|
|
629
|
+
export let folderDirs = [];
|
|
630
|
+
|
|
631
|
+
export let folderPickerMode = 'session';
|
|
632
|
+
// La carpeta predeterminada vive en el server (la usan la consola y Telegram),
|
|
633
|
+
// no en localStorage; acá se cachea para poder leerla sin await.
|
|
634
|
+
export let defaultCwdCache = '';
|
|
635
|
+
|
|
636
|
+
export function readDefaultCwd() {
|
|
637
|
+
return defaultCwdCache || '';
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export function writeDefaultCwd(value) {
|
|
641
|
+
defaultCwdCache = value || '';
|
|
642
|
+
api('/api/settings', { method: 'PUT', body: { defaultCwd: defaultCwdCache } }).catch(() => {});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export function syncDefaultCwd() {
|
|
646
|
+
const input = $('#default-cwd');
|
|
647
|
+
if (input) input.value = readDefaultCwd();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
export 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';
|
|
651
|
+
|
|
652
|
+
export function folderIcon() {
|
|
653
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
654
|
+
svg.setAttribute('viewBox', '0 0 22 22');
|
|
655
|
+
svg.setAttribute('class', 'folder-ico');
|
|
656
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
657
|
+
path.setAttribute('d', FOLDER_PATH);
|
|
658
|
+
path.setAttribute('fill', 'none');
|
|
659
|
+
path.setAttribute('stroke', 'currentColor');
|
|
660
|
+
path.setAttribute('stroke-width', '1.4');
|
|
661
|
+
path.setAttribute('stroke-linejoin', 'round');
|
|
662
|
+
svg.appendChild(path);
|
|
663
|
+
return svg;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export function setSummaryText(node, text, cls = '') {
|
|
667
|
+
node.className = `summary ${cls}`.trim();
|
|
668
|
+
node.textContent = text;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/** Variante para una linea de estado propia (explorer/preview/git). */
|
|
672
|
+
export function setStatusText(node, text, isError = false) {
|
|
673
|
+
node.textContent = text;
|
|
674
|
+
node.classList.toggle('err', isError);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
export function setFolderStatus(text, cls = '') {
|
|
678
|
+
setSummaryText(folderStatusEl, text, cls);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export function closeFolderPicker() {
|
|
682
|
+
if (folderModal.open) folderModal.close();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
export function openFolderPicker(mode = 'session') {
|
|
686
|
+
folderPickerMode = mode === 'default' ? 'default' : 'session';
|
|
687
|
+
folderGoto.value = '';
|
|
688
|
+
setFolderStatus('');
|
|
689
|
+
folderModal.showModal();
|
|
690
|
+
const def = readDefaultCwd();
|
|
691
|
+
if (!def) return loadFolders(state.home || '');
|
|
692
|
+
api(`/api/folders?path=${encodeURIComponent(def)}`)
|
|
693
|
+
.then(() => loadFolders(def))
|
|
694
|
+
.catch(() => {
|
|
695
|
+
writeDefaultCwd('');
|
|
696
|
+
syncDefaultCwd();
|
|
697
|
+
loadFolders(state.home || '');
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export function renderCrumbs(pathStr) {
|
|
702
|
+
clear(folderCrumbs);
|
|
703
|
+
if (!pathStr) {
|
|
704
|
+
folderCrumbs.appendChild(el('span', 'crumb current', 'unidades'));
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
const win = pathStr.includes('\\');
|
|
708
|
+
const parts = pathStr.split(/[\\/]+/).filter(Boolean);
|
|
709
|
+
const segments = parts.map((part, index) =>
|
|
710
|
+
win
|
|
711
|
+
? { label: part, path: index === 0 ? `${part}\\` : parts.slice(0, index + 1).join('\\') }
|
|
712
|
+
: { label: part, path: `/${parts.slice(0, index + 1).join('/')}` }
|
|
713
|
+
);
|
|
714
|
+
segments.forEach((segment, index) => {
|
|
715
|
+
if (index) folderCrumbs.appendChild(el('span', 'crumb-sep', '/'));
|
|
716
|
+
const last = index === segments.length - 1;
|
|
717
|
+
const node = el('span', `crumb${last ? ' current' : ''}`, segment.label);
|
|
718
|
+
if (!last) node.addEventListener('click', () => loadFolders(segment.path));
|
|
719
|
+
folderCrumbs.appendChild(node);
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export function renderFolderList(dirs) {
|
|
724
|
+
clear(folderList);
|
|
725
|
+
if (!dirs.length) {
|
|
726
|
+
folderList.appendChild(
|
|
727
|
+
el('li', 'folders-empty', folderListPath ? 'sin subcarpetas (podés usar esta carpeta igual)' : 'sin unidades')
|
|
728
|
+
);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
for (const dir of dirs) {
|
|
732
|
+
const li = el('li', 'folder-item');
|
|
733
|
+
li.title = dir.path;
|
|
734
|
+
li.tabIndex = 0;
|
|
735
|
+
li.append(folderIcon(), el('span', 'folder-name', dir.name), el('span', 'folder-enter', '↵'));
|
|
736
|
+
const enter = () => loadFolders(dir.path);
|
|
737
|
+
li.addEventListener('click', enter);
|
|
738
|
+
li.addEventListener('keydown', (event) => {
|
|
739
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
740
|
+
event.preventDefault();
|
|
741
|
+
enter();
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
folderList.appendChild(li);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
export async function loadFolders(target = '') {
|
|
749
|
+
folderGoto.value = '';
|
|
750
|
+
closeCreateFolder();
|
|
751
|
+
setFolderStatus('cargando…');
|
|
752
|
+
const data = await api(`/api/folders?path=${encodeURIComponent(target)}`).catch((error) => {
|
|
753
|
+
setFolderStatus(error.message, 'err');
|
|
754
|
+
return null;
|
|
755
|
+
});
|
|
756
|
+
if (!data) return;
|
|
757
|
+
folderListPath = data.path;
|
|
758
|
+
folderParent = data.parent;
|
|
759
|
+
folderDirs = [...data.dirs].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
|
760
|
+
renderCrumbs(data.path);
|
|
761
|
+
$('#folder-up').disabled = !data.path;
|
|
762
|
+
$('#folder-new').disabled = !data.path;
|
|
763
|
+
folderUseBtn.disabled = !data.path;
|
|
764
|
+
if (folderPickerMode === 'default') {
|
|
765
|
+
folderUseBtn.textContent = data.path ? `predeterminada: «${basename(data.path)}»` : 'usar como predeterminada';
|
|
766
|
+
} else {
|
|
767
|
+
folderUseBtn.textContent = data.path ? `usar «${basename(data.path)}»` : 'usar esta carpeta';
|
|
768
|
+
}
|
|
769
|
+
renderFolderList(folderDirs);
|
|
770
|
+
const count = data.path ? `${folderDirs.length} subcarpetas` : `${folderDirs.length} unidades`;
|
|
771
|
+
setFolderStatus(data.path ? `${count} · clic para entrar` : 'elegí una unidad');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
|
|
775
|
+
const current = state.panes.get(state.sessionId);
|
|
776
|
+
const created = await api('/api/sessions', {
|
|
777
|
+
method: 'POST',
|
|
778
|
+
body: {
|
|
779
|
+
title,
|
|
780
|
+
cwd,
|
|
781
|
+
provider: current?.provider || providerSelect.value || undefined,
|
|
782
|
+
model: current?.model || modelSelect.value || undefined,
|
|
783
|
+
mode: current?.mode || modeSelect.value || undefined,
|
|
784
|
+
},
|
|
785
|
+
}).catch((error) => ({
|
|
786
|
+
error: error.message,
|
|
787
|
+
}));
|
|
788
|
+
if (created?.error) return created;
|
|
789
|
+
registerSession(created.session, []);
|
|
790
|
+
activateSession(created.session.id);
|
|
791
|
+
const pending = state.pendingInput;
|
|
792
|
+
if (pending !== null && pending !== undefined) {
|
|
793
|
+
state.pendingInput = null;
|
|
794
|
+
chatInput.value = pending;
|
|
795
|
+
sendMessage();
|
|
796
|
+
}
|
|
797
|
+
return created;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
export async function useFolder() {
|
|
801
|
+
const cwd = folderListPath;
|
|
802
|
+
if (!cwd) return setFolderStatus('Entrá a una carpeta primero (clic para navegar).', 'err');
|
|
803
|
+
if (folderPickerMode === 'default') {
|
|
804
|
+
writeDefaultCwd(cwd);
|
|
805
|
+
syncDefaultCwd();
|
|
806
|
+
closeFolderPicker();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const created = await createSessionIn(cwd);
|
|
810
|
+
if (created?.error) return setFolderStatus(created.error, 'err');
|
|
811
|
+
closeFolderPicker();
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
export function filterFolders() {
|
|
815
|
+
const query = folderGoto.value.trim().toLowerCase();
|
|
816
|
+
if (!query) return renderFolderList(folderDirs);
|
|
817
|
+
renderFolderList(folderDirs.filter((dir) => dir.name.toLowerCase().includes(query)));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function gotoFolder() {
|
|
821
|
+
const value = folderGoto.value.trim();
|
|
822
|
+
if (!value) return;
|
|
823
|
+
if (/[\\/]/.test(value) || /^[a-z]:$/i.test(value)) return loadFolders(value);
|
|
824
|
+
const matches = folderDirs.filter((dir) => dir.name.toLowerCase().includes(value.toLowerCase()));
|
|
825
|
+
if (matches.length === 1) loadFolders(matches[0].path);
|
|
826
|
+
else if (!matches.length) setFolderStatus('sin coincidencias', 'err');
|
|
827
|
+
else setFolderStatus(`${matches.length} coincidencias`, '');
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export function openCreateFolder() {
|
|
831
|
+
if (!folderListPath) return;
|
|
832
|
+
folderCreate.hidden = false;
|
|
833
|
+
folderNewName.value = '';
|
|
834
|
+
folderNewName.focus();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function closeCreateFolder() {
|
|
838
|
+
folderCreate.hidden = true;
|
|
839
|
+
folderNewName.value = '';
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
export async function createFolder() {
|
|
843
|
+
const name = folderNewName.value.trim();
|
|
844
|
+
if (!name || !folderListPath) return;
|
|
845
|
+
const created = await api('/api/folders', {
|
|
846
|
+
method: 'POST',
|
|
847
|
+
body: { parent: folderListPath, name },
|
|
848
|
+
}).catch((error) => {
|
|
849
|
+
setFolderStatus(error.message, 'err');
|
|
850
|
+
return null;
|
|
851
|
+
});
|
|
852
|
+
if (!created) return;
|
|
853
|
+
await loadFolders(created.path);
|
|
854
|
+
setFolderStatus(`carpeta «${name}» creada · usala para el nuevo proyecto`, 'ok');
|
|
855
|
+
}
|
|
856
|
+
|