pi-tau-web-server 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1,478 @@
1
+ export function setupModelPicker(options) {
2
+ const { getActiveLiveSessionId, isViewingActiveSession, rpcCommand, flashStatusError, escapeHtml, setContextWindowSize, updateContextPill } = options;
3
+ // ═══════════════════════════════════════
4
+ // Model Picker
5
+ // ═══════════════════════════════════════
6
+ // All element lookups below query the app's static index.html shell, which is
7
+ // present before this setup function runs; assert non-null at the query site.
8
+ const modelInput = document.getElementById('model-input');
9
+ // Inner label span: the scrollable text container on mobile (buttons are
10
+ // unreliable scroll containers, so the text lives in its own element).
11
+ const modelInputLabel = document.getElementById('model-input-label');
12
+ const modelPickerOverlay = document.getElementById('model-picker-overlay');
13
+ const modelPicker = document.getElementById('model-picker');
14
+ const modelPickerInput = document.getElementById('model-picker-input');
15
+ const modelPickerList = document.getElementById('model-picker-list');
16
+ const modelPickerMessage = document.getElementById('model-picker-message');
17
+ const modelPickerClose = document.getElementById('model-picker-close');
18
+ const modelPickerCancel = document.getElementById('model-picker-cancel');
19
+ const modelPickerSave = document.getElementById('model-picker-save');
20
+ const VALID_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
21
+ const MODEL_PICKER_HELP = 'Type provider or model name; optional :off|minimal|low|medium|high|xhigh|max';
22
+ let currentModelId = '';
23
+ let availableModels = [];
24
+ let currentThinkingLevel = 'off';
25
+ let modelPickerMatches = [];
26
+ let modelPickerActiveIndex = -1;
27
+ let modelPickerJustSelected = false;
28
+ function modelDisplayString() {
29
+ if (!currentModelId)
30
+ return '';
31
+ let provider, modelId;
32
+ if (typeof currentModelId === 'object' && currentModelId) {
33
+ provider = currentModelId.provider || '';
34
+ modelId = currentModelId.id || '';
35
+ }
36
+ else {
37
+ // Legacy fallback: a bare string is ambiguous when model names contain
38
+ // slashes (e.g. openrouter/z-ai/glm-5.2). Split ONCE on the first slash
39
+ // to separate provider from the rest, because the server normalizes
40
+ // everything into {provider, id} objects before it reaches us.
41
+ const str = String(currentModelId);
42
+ const slashIdx = str.indexOf('/');
43
+ if (slashIdx === -1) {
44
+ provider = '';
45
+ modelId = str;
46
+ }
47
+ else {
48
+ provider = str.slice(0, slashIdx);
49
+ modelId = str.slice(slashIdx + 1);
50
+ }
51
+ }
52
+ const level = currentThinkingLevel || 'off';
53
+ if (provider && modelId)
54
+ return `${provider}/${modelId}:${level}`;
55
+ if (modelId)
56
+ return `${modelId}:${level}`;
57
+ return '';
58
+ }
59
+ function updateModelDisplay() {
60
+ const display = modelDisplayString() || 'Model';
61
+ modelInputLabel.textContent = display;
62
+ modelInput.title = display === 'Model' ? 'Choose model and (optionally) thinking level for this session' : display;
63
+ modelInput.classList.remove('invalid');
64
+ }
65
+ function parseModelSpec(raw) {
66
+ const trimmed = String(raw || '').trim();
67
+ if (!trimmed) {
68
+ return { error: 'Use format provider/model[:thinking], e.g. opencode-go/deepseek-v4-pro:xhigh' };
69
+ }
70
+ // Model IDs can contain slashes (e.g. OpenRouter "z-ai/glm-5.2"), so the
71
+ // input format is provider/<rest...>[:level]. Split on the FIRST slash to
72
+ // separate provider, then split off the optional :level suffix from the end.
73
+ const firstSlash = trimmed.indexOf('/');
74
+ if (firstSlash === -1) {
75
+ return { error: 'Use format provider/model[:thinking], e.g. opencode-go/deepseek-v4-pro:xhigh' };
76
+ }
77
+ const provider = trimmed.slice(0, firstSlash);
78
+ const rest = trimmed.slice(firstSlash + 1);
79
+ if (!provider || !rest) {
80
+ return { error: 'Use format provider/model[:thinking], e.g. opencode-go/deepseek-v4-pro:xhigh' };
81
+ }
82
+ let modelId = rest;
83
+ let thinking = null;
84
+ const lastColon = rest.lastIndexOf(':');
85
+ if (lastColon !== -1) {
86
+ const candidate = rest.slice(lastColon + 1).toLowerCase();
87
+ if (VALID_THINKING_LEVELS.has(candidate)) {
88
+ thinking = candidate;
89
+ modelId = rest.slice(0, lastColon);
90
+ }
91
+ }
92
+ if (!modelId) {
93
+ return { error: 'Use format provider/model[:thinking], e.g. opencode-go/deepseek-v4-pro:xhigh' };
94
+ }
95
+ return { provider, modelId, thinking };
96
+ }
97
+ function normalizeAvailableModel(model) {
98
+ if (!model)
99
+ return null;
100
+ if (typeof model === 'string') {
101
+ const slashIdx = model.indexOf('/');
102
+ if (slashIdx === -1)
103
+ return null;
104
+ return { provider: model.slice(0, slashIdx), id: model.slice(slashIdx + 1), label: model, contextWindow: '', maxOutput: '' };
105
+ }
106
+ const provider = model.provider || '';
107
+ const id = model.id || model.model || model.name || '';
108
+ if (!provider || !id)
109
+ return null;
110
+ return {
111
+ provider,
112
+ id,
113
+ label: `${provider}/${id}`,
114
+ contextWindow: model.contextWindow || model.context || model.context_window || '',
115
+ maxOutput: model.maxOutput || model.max_output || model.maxOut || '',
116
+ thinking: model.thinking,
117
+ images: model.images,
118
+ };
119
+ }
120
+ function normalizedAvailableModels() {
121
+ const seen = new Set();
122
+ const out = [];
123
+ for (const item of availableModels || []) {
124
+ const normalized = normalizeAvailableModel(item);
125
+ if (!normalized)
126
+ continue;
127
+ const key = `${normalized.provider}/${normalized.id}`;
128
+ if (seen.has(key))
129
+ continue;
130
+ seen.add(key);
131
+ out.push(normalized);
132
+ }
133
+ return out;
134
+ }
135
+ function modelRef(model) {
136
+ return `${model.provider}/${model.id}`;
137
+ }
138
+ function fuzzyCharsEquivalent(a, b) {
139
+ if (a === b)
140
+ return true;
141
+ const groups = ['o0', 'i1l', 's5', 'b8', 'g9', 'z2'];
142
+ return groups.some((group) => group.includes(a) && group.includes(b));
143
+ }
144
+ function fuzzyMatch(query, text) {
145
+ const q = String(query || '').toLowerCase();
146
+ const t = String(text || '').toLowerCase();
147
+ if (!q)
148
+ return { score: 0 };
149
+ if (!t)
150
+ return null;
151
+ const compactQ = q.replace(/[\W_]+/g, '');
152
+ const compactT = t.replace(/[\W_]+/g, '');
153
+ if (compactQ && compactT.includes(compactQ)) {
154
+ return { score: 1200 - compactT.indexOf(compactQ) };
155
+ }
156
+ let qi = 0;
157
+ let lastMatch = -1;
158
+ let score = 0;
159
+ for (let ti = 0; ti < t.length && qi < q.length; ti++) {
160
+ const qc = q[qi];
161
+ const tc = t[ti];
162
+ const direct = qc === tc;
163
+ const swap = fuzzyCharsEquivalent(qc, tc);
164
+ if (!direct && !swap)
165
+ continue;
166
+ score += direct ? 20 : 8;
167
+ if (ti === 0 || /[\s/_:.-]/.test(t[ti - 1]))
168
+ score += 12;
169
+ if (lastMatch === ti - 1)
170
+ score += 18;
171
+ if (lastMatch !== -1)
172
+ score -= Math.max(0, ti - lastMatch - 1);
173
+ lastMatch = ti;
174
+ qi++;
175
+ }
176
+ if (qi !== q.length)
177
+ return null;
178
+ if (t === q)
179
+ score += 500;
180
+ if (t.startsWith(q))
181
+ score += 250;
182
+ return { score };
183
+ }
184
+ function fuzzyFilter(items, query, getText) {
185
+ const tokens = String(query || '').trim().split(/\s+/).filter(Boolean);
186
+ if (!tokens.length)
187
+ return items.map((item, index) => ({ item, score: -index }));
188
+ const scored = [];
189
+ items.forEach((item, index) => {
190
+ const text = getText(item);
191
+ let total = 0;
192
+ for (const token of tokens) {
193
+ const match = fuzzyMatch(token, text);
194
+ if (!match)
195
+ return;
196
+ total += match.score;
197
+ }
198
+ scored.push({ item, score: total - index * 0.01 });
199
+ });
200
+ return scored.sort((a, b) => b.score - a.score);
201
+ }
202
+ function validThinkingSuffix(raw) {
203
+ const text = String(raw || '').trim();
204
+ const colonIdx = text.lastIndexOf(':');
205
+ if (colonIdx === -1)
206
+ return '';
207
+ const candidate = text.slice(colonIdx + 1).toLowerCase();
208
+ return VALID_THINKING_LEVELS.has(candidate) ? `:${candidate}` : '';
209
+ }
210
+ function setModelPickerMessage(message = MODEL_PICKER_HELP, isError = false) {
211
+ modelPickerMessage.textContent = message;
212
+ modelPickerMessage.classList.toggle('error', isError);
213
+ modelPickerInput.classList.toggle('invalid', isError);
214
+ }
215
+ function updateModelPickerActiveItem() {
216
+ modelPickerList.querySelectorAll('.model-item').forEach((item, index) => {
217
+ const active = index === modelPickerActiveIndex;
218
+ item.classList.toggle('active', active);
219
+ item.setAttribute('aria-selected', active ? 'true' : 'false');
220
+ });
221
+ }
222
+ function renderModelPickerSuggestions() {
223
+ const raw = modelPickerInput.value || '';
224
+ modelPickerSave.disabled = !raw.trim();
225
+ modelPickerList.innerHTML = '';
226
+ modelPickerJustSelected = false;
227
+ if (raw.includes(':')) {
228
+ modelPickerMatches = [];
229
+ modelPickerActiveIndex = -1;
230
+ setModelPickerMessage(MODEL_PICKER_HELP, false);
231
+ return;
232
+ }
233
+ const models = normalizedAvailableModels();
234
+ const query = raw.trim();
235
+ modelPickerMatches = fuzzyFilter(models, query, (model) => `${model.id} ${model.provider}`).slice(0, 50).map((m) => m.item);
236
+ if (modelPickerActiveIndex >= modelPickerMatches.length)
237
+ modelPickerActiveIndex = modelPickerMatches.length - 1;
238
+ if (modelPickerActiveIndex < 0 && modelPickerMatches.length)
239
+ modelPickerActiveIndex = 0;
240
+ if (!modelPickerMatches.length) {
241
+ const empty = document.createElement('div');
242
+ empty.className = 'model-item-context';
243
+ empty.textContent = models.length ? 'No matching models. You can still save a manual provider/model value.' : 'No model list available. You can still save a manual provider/model value.';
244
+ empty.style.padding = '10px 12px';
245
+ modelPickerList.appendChild(empty);
246
+ return;
247
+ }
248
+ modelPickerMatches.forEach((model, index) => {
249
+ const item = document.createElement('button');
250
+ item.type = 'button';
251
+ item.className = `model-item${index === modelPickerActiveIndex ? ' active' : ''}`;
252
+ item.setAttribute('role', 'option');
253
+ item.setAttribute('aria-selected', index === modelPickerActiveIndex ? 'true' : 'false');
254
+ const meta = [
255
+ model.contextWindow || model.context,
256
+ model.maxOutput ? `out ${model.maxOutput}` : '',
257
+ model.thinking === true ? 'thinking' : '',
258
+ model.images === true ? 'images' : '',
259
+ ].filter(Boolean).join(' · ');
260
+ item.innerHTML = `
261
+ <span class="model-item-name">${escapeHtml(model.id || '')}<span class="model-item-provider">${escapeHtml(model.provider || '')}</span></span>
262
+ <span class="model-item-context">${escapeHtml(meta)}</span>
263
+ `;
264
+ item.addEventListener('mouseenter', () => {
265
+ modelPickerActiveIndex = index;
266
+ updateModelPickerActiveItem();
267
+ });
268
+ item.addEventListener('click', () => selectModelSuggestion(index));
269
+ modelPickerList.appendChild(item);
270
+ });
271
+ }
272
+ function selectModelSuggestion(index) {
273
+ const model = modelPickerMatches[index];
274
+ if (!model)
275
+ return;
276
+ const suffix = validThinkingSuffix(modelPickerInput.value);
277
+ modelPickerInput.value = `${modelRef(model)}${suffix}`;
278
+ modelPickerInput.focus();
279
+ modelPickerInput.setSelectionRange(modelPickerInput.value.length, modelPickerInput.value.length);
280
+ modelPickerMatches = [];
281
+ modelPickerActiveIndex = -1;
282
+ modelPickerList.innerHTML = '';
283
+ modelPickerSave.disabled = false;
284
+ modelPickerJustSelected = true;
285
+ }
286
+ function openModelPicker() {
287
+ // The model button is disabled by updateLiveSessionInputState when there is no
288
+ // active live session, so this handler is only reachable via click when a
289
+ // session exists.
290
+ modelPickerInput.value = modelDisplayString();
291
+ modelPickerActiveIndex = -1;
292
+ modelPickerJustSelected = false;
293
+ setModelPickerMessage(MODEL_PICKER_HELP, false);
294
+ renderModelPickerSuggestions();
295
+ modelPicker.classList.remove('hidden');
296
+ modelPickerOverlay.classList.remove('hidden');
297
+ requestAnimationFrame(() => {
298
+ modelPickerInput.focus();
299
+ modelPickerInput.select();
300
+ });
301
+ fetchModelInfo().then(() => {
302
+ if (!modelPicker.classList.contains('hidden'))
303
+ renderModelPickerSuggestions();
304
+ }).catch(() => { });
305
+ }
306
+ function closeModelPicker() {
307
+ modelPicker.classList.add('hidden');
308
+ modelPickerOverlay.classList.add('hidden');
309
+ modelPickerMatches = [];
310
+ modelPickerActiveIndex = -1;
311
+ modelPickerJustSelected = false;
312
+ modelPickerList.innerHTML = '';
313
+ setModelPickerMessage(MODEL_PICKER_HELP, false);
314
+ }
315
+ async function applyModelSpec(rawSpec) {
316
+ const raw = String(rawSpec || '').trim();
317
+ // No-op when the user didn't actually edit anything. Avoids spurious
318
+ // set_model/set_thinking_level RPCs and false validation errors on the
319
+ // current display string.
320
+ if (raw === modelDisplayString()) {
321
+ modelInput.classList.remove('invalid');
322
+ return { success: true };
323
+ }
324
+ if (!isViewingActiveSession() || !getActiveLiveSessionId()) {
325
+ const error = 'Select a live Tau tab first.';
326
+ flashStatusError(error);
327
+ return { success: false, error };
328
+ }
329
+ const parsed = parseModelSpec(raw);
330
+ if (parsed.error) {
331
+ flashStatusError(parsed.error);
332
+ return { success: false, error: parsed.error };
333
+ }
334
+ const r = await rpcCommand({ type: 'set_model', provider: parsed.provider, modelId: parsed.modelId }, `Switching to ${parsed.provider}/${parsed.modelId}...`);
335
+ if (r && r.success) {
336
+ const data = r.data || {};
337
+ // Always retain the provider so modelDisplayString() can render the
338
+ // full `provider/model:thinking` form. The server sometimes omits
339
+ // `provider` in its response; fall back to the user-typed value.
340
+ const responseModel = (typeof data.model === 'object' && data.model ? data.model : data);
341
+ const provider = responseModel.provider || parsed.provider;
342
+ const id = responseModel.id || parsed.modelId;
343
+ currentModelId = (provider && id) ? { ...responseModel, provider, id } : (id || parsed.modelId || '');
344
+ const responseContextWindow = responseModel.contextWindow || data.contextWindow;
345
+ if (responseContextWindow) {
346
+ setContextWindowSize(Number(responseContextWindow) || 0);
347
+ updateContextPill();
348
+ }
349
+ if (parsed.thinking !== null) {
350
+ const t = await rpcCommand({ type: 'set_thinking_level', level: parsed.thinking }, 'Setting thinking...');
351
+ if (t && t.success) {
352
+ currentThinkingLevel = parsed.thinking ?? 'off';
353
+ }
354
+ else {
355
+ // Non-fatal: the model was already changed on the server.
356
+ // Show the error but still consider the model update successful
357
+ // so the popup closes and the user can retry thinking separately.
358
+ flashStatusError((t && t.error) ? t.error : 'Failed to set thinking level');
359
+ }
360
+ }
361
+ modelInput.classList.remove('invalid');
362
+ updateModelDisplay();
363
+ return { success: true };
364
+ }
365
+ const error = (r && r.error) ? r.error : 'Unknown model';
366
+ flashStatusError(error);
367
+ modelInput.classList.add('invalid');
368
+ setTimeout(() => modelInput.classList.remove('invalid'), 1200);
369
+ return { success: false, error };
370
+ }
371
+ async function saveModelPicker() {
372
+ const result = await applyModelSpec(modelPickerInput.value);
373
+ if (result.success) {
374
+ closeModelPicker();
375
+ }
376
+ else {
377
+ setModelPickerMessage(result.error || 'Failed to update model', true);
378
+ modelPickerInput.focus();
379
+ }
380
+ }
381
+ modelInput.addEventListener('click', openModelPicker);
382
+ modelPickerOverlay.addEventListener('click', closeModelPicker);
383
+ modelPickerClose.addEventListener('click', closeModelPicker);
384
+ modelPickerCancel.addEventListener('click', closeModelPicker);
385
+ modelPickerSave.addEventListener('click', saveModelPicker);
386
+ modelPickerInput.addEventListener('input', () => {
387
+ modelPickerActiveIndex = -1;
388
+ modelPickerJustSelected = false;
389
+ setModelPickerMessage(MODEL_PICKER_HELP, false);
390
+ renderModelPickerSuggestions();
391
+ });
392
+ modelPickerInput.addEventListener('keydown', (e) => {
393
+ if (e.key === 'Escape') {
394
+ e.preventDefault();
395
+ e.stopPropagation();
396
+ closeModelPicker();
397
+ return;
398
+ }
399
+ if (e.key === 'ArrowDown') {
400
+ e.preventDefault();
401
+ if (modelPickerMatches.length) {
402
+ modelPickerActiveIndex = (modelPickerActiveIndex + 1) % modelPickerMatches.length;
403
+ renderModelPickerSuggestions();
404
+ }
405
+ return;
406
+ }
407
+ if (e.key === 'ArrowUp') {
408
+ e.preventDefault();
409
+ if (modelPickerMatches.length) {
410
+ modelPickerActiveIndex = (modelPickerActiveIndex - 1 + modelPickerMatches.length) % modelPickerMatches.length;
411
+ renderModelPickerSuggestions();
412
+ }
413
+ return;
414
+ }
415
+ if (e.key === 'Tab' && modelPickerMatches.length && modelPickerActiveIndex >= 0) {
416
+ e.preventDefault();
417
+ selectModelSuggestion(modelPickerActiveIndex);
418
+ return;
419
+ }
420
+ if (e.key === 'Enter') {
421
+ e.preventDefault();
422
+ if (!modelPickerJustSelected && modelPickerMatches.length && modelPickerActiveIndex >= 0) {
423
+ selectModelSuggestion(modelPickerActiveIndex);
424
+ }
425
+ else {
426
+ saveModelPicker();
427
+ }
428
+ }
429
+ });
430
+ async function fetchModelInfo() {
431
+ try {
432
+ const [modelsResp, stateResp] = await Promise.all([
433
+ fetch('/api/rpc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'get_available_models', sessionId: getActiveLiveSessionId() }) }),
434
+ fetch('/api/rpc', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'get_state', sessionId: getActiveLiveSessionId() }) }),
435
+ ]);
436
+ const modelsData = await modelsResp.json();
437
+ const stateData = await stateResp.json();
438
+ if (modelsData.success && modelsData.data?.models) {
439
+ availableModels = modelsData.data.models;
440
+ }
441
+ if (stateData.success && stateData.data?.model !== undefined) {
442
+ // Server is canonical: stateData.data.model is null or a full
443
+ // {provider,id} object. Assign directly — no string fallback.
444
+ currentModelId = stateData.data.model || '';
445
+ if (stateData.data.model?.contextWindow) {
446
+ setContextWindowSize(Number(stateData.data.model.contextWindow) || 0);
447
+ updateContextPill();
448
+ }
449
+ }
450
+ if (stateData.success && stateData.data?.thinkingLevel) {
451
+ currentThinkingLevel = stateData.data.thinkingLevel || 'off';
452
+ }
453
+ updateModelDisplay();
454
+ }
455
+ catch (e) {
456
+ // ignore
457
+ }
458
+ }
459
+ function setModelState(model, thinkingLevel = 'off') {
460
+ currentModelId = model || '';
461
+ currentThinkingLevel = thinkingLevel || 'off';
462
+ updateModelDisplay();
463
+ }
464
+ function setThinkingLevel(level) {
465
+ currentThinkingLevel = level || 'off';
466
+ updateModelDisplay();
467
+ }
468
+ function setEnabled(enabled) {
469
+ modelInput.disabled = !enabled;
470
+ }
471
+ function closeIfOpen() {
472
+ if (modelPicker.classList.contains('hidden'))
473
+ return false;
474
+ closeModelPicker();
475
+ return true;
476
+ }
477
+ return { applyModelSpec, closeIfOpen, fetchModelInfo, setEnabled, setModelState, setThinkingLevel, updateModelDisplay };
478
+ }