feat(ai): section Fournisseur & modele compacte + correctifs menus @/ (#82, BUG-007, BUG-008)
CI / lint (push) Successful in 1m13s
CI / security (push) Successful in 43s
CI / test (push) Successful in 2m33s
CI / build (push) Successful in 39s
CI / e2e (push) Successful in 10m32s

Refonte de la section Fournisseur & modele de l'assistant : capacites regroupees dans une bulle d'information (survol/clic/appui long) au lieu d'une liste permanente, et liste de modeles avec recherche. Correctifs : detection Unicode + filtrage insensible aux accents des commandes / et mentions @, navigation clavier fiable, et prise en compte du contexte ad-hoc en mode General cote backend.
This commit is contained in:
2026-09-12 12:40:03 -04:00
parent f049e208b6
commit d6b7c0e9cb
14 changed files with 643 additions and 93 deletions
+3 -2
View File
@@ -4287,8 +4287,9 @@
</li>
<li data-i18n="help.assistant_images">
Collez une image dans la zone de saisie (ou joignez-la) pour
l'analyser avec un modèle compatible vision ; les capacités du
modèle sélectionné sont affichées.
l'analyser avec un modèle compatible vision. Le bouton ⓘ affiche
les capacités du modèle sélectionné, et la liste des modèles est
filtrable par recherche.
</li>
</ul>
</section>
+239 -66
View File
@@ -77,10 +77,44 @@ function renderCapabilityList(caps) {
return box;
}
/** Accent-insensitive, case-insensitive normalization for model search. */
function _normalizeText(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
// One delegated document listener closes every picker popover when the user
// clicks outside it. Bound once, whatever the number of pickers mounted.
let _pickerOutsideBound = false;
function _bindPickerOutside() {
if (_pickerOutsideBound) return;
_pickerOutsideBound = true;
document.addEventListener('click', (e) => {
document.querySelectorAll('.ai-picker-caps-host').forEach((host) => {
if (host.contains(e.target)) return;
const pop = host.querySelector('.ai-picker-caps-pop');
if (pop) pop.classList.add('hidden');
});
document.querySelectorAll('.ai-picker-model-combo').forEach((combo) => {
if (combo.contains(e.target)) return;
const pop = combo.querySelector('.ai-picker-model-pop');
const trigger = combo.querySelector('.ai-picker-model-trigger');
if (pop) pop.classList.add('hidden');
if (trigger) trigger.setAttribute('aria-expanded', 'false');
});
});
}
/**
* Build a provider+model picker that appears in every section's AI toolbar
* (Forge editor, BooksLM, etc.). The picker reads /api/ai/status to discover
* available providers, then /api/config/ai-models?provider=X to list models.
*
* Discreet layout: a compact provider select, a searchable model combobox and
* an info button (ⓘ) that reveals the model capabilities in a bubble on hover,
* click or long-press (mobile) instead of always listing every endpoint type.
*/
async function _buildPickerUI() {
const pickerState = _readPicker();
@@ -99,36 +133,14 @@ async function _buildPickerUI() {
const wrap = document.createElement('div');
wrap.className = 'ai-picker';
Object.assign(wrap.style, {
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
marginLeft: '8px',
paddingLeft: '8px',
borderLeft: '1px solid var(--border)',
fontSize: '0.7rem',
color: 'var(--text-muted)',
flexShrink: '0',
zIndex: '1',
});
// Provider select
// ── Provider select (compact) ──
const providerLabel = document.createElement('span');
providerLabel.className = 'ai-picker-label';
providerLabel.textContent = t('ai.provider') + ':';
wrap.appendChild(providerLabel);
const providerSelect = document.createElement('select');
providerSelect.className = 'ai-picker-select';
Object.assign(providerSelect.style, {
fontSize: '0.7rem',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: '4px',
padding: '2px 4px',
cursor: 'pointer',
});
const defaultOpt = document.createElement('option');
defaultOpt.value = '';
defaultOpt.textContent = t('ai.provider_default');
@@ -141,31 +153,178 @@ async function _buildPickerUI() {
providerSelect.appendChild(opt);
});
// Model select (sibling of provider)
// Hidden native select kept as the source of truth so the assistant admin
// commands (`/model`) and the stored picker state keep working unchanged.
const modelSelect = document.createElement('select');
modelSelect.className = 'ai-picker-model';
Object.assign(modelSelect.style, {
fontSize: '0.7rem',
background: 'var(--surface)',
color: 'var(--text-primary)',
border: '1px solid var(--border)',
borderRadius: '4px',
padding: '2px 4px',
cursor: 'pointer',
minWidth: '120px',
maxWidth: '220px',
});
const placeholderOpt = document.createElement('option');
placeholderOpt.value = '';
placeholderOpt.textContent = t('ai.model_default');
modelSelect.appendChild(placeholderOpt);
modelSelect.hidden = true;
// Capability checklist, refreshed whenever the selected model changes.
// ── Searchable model combobox ──
const combo = document.createElement('div');
combo.className = 'ai-picker-model-combo';
const modelTrigger = document.createElement('button');
modelTrigger.type = 'button';
modelTrigger.className = 'ai-picker-model-trigger';
modelTrigger.setAttribute('aria-haspopup', 'listbox');
modelTrigger.setAttribute('aria-expanded', 'false');
const modelName = document.createElement('span');
modelName.className = 'ai-picker-model-name';
modelName.textContent = pickerState.model || t('ai.model_default');
const chevron = document.createElement('span');
chevron.className = 'ai-picker-model-chevron';
chevron.textContent = '▾';
modelTrigger.appendChild(modelName);
modelTrigger.appendChild(chevron);
const modelPop = document.createElement('div');
modelPop.className = 'ai-picker-model-pop hidden';
const modelSearch = document.createElement('input');
modelSearch.type = 'text';
modelSearch.className = 'ai-picker-model-search';
modelSearch.autocomplete = 'off';
modelSearch.placeholder = t('ai.model_search');
modelSearch.setAttribute('aria-label', t('ai.model_search'));
const modelList = document.createElement('div');
modelList.className = 'ai-picker-model-list';
modelList.setAttribute('role', 'listbox');
modelPop.appendChild(modelSearch);
modelPop.appendChild(modelList);
combo.appendChild(modelTrigger);
combo.appendChild(modelPop);
// ── Capabilities info button + bubble ──
const capsHost = document.createElement('div');
capsHost.className = 'ai-picker-caps-host';
const infoBtn = document.createElement('button');
infoBtn.type = 'button';
infoBtn.className = 'ai-picker-info hidden';
infoBtn.textContent = 'ℹ️';
infoBtn.setAttribute('aria-label', t('ai.model_info'));
infoBtn.title = t('ai.model_info');
const capsPop = document.createElement('div');
capsPop.className = 'ai-picker-caps-pop hidden';
capsHost.appendChild(infoBtn);
capsHost.appendChild(capsPop);
let _hideCapsTimer = null;
let _longPressTimer = null;
let _longPressFired = false;
const showCaps = () => { clearTimeout(_hideCapsTimer); capsPop.classList.remove('hidden'); };
const hideCaps = () => { _hideCapsTimer = setTimeout(() => capsPop.classList.add('hidden'), 140); };
infoBtn.addEventListener('mouseenter', showCaps);
infoBtn.addEventListener('focus', showCaps);
infoBtn.addEventListener('mouseleave', hideCaps);
infoBtn.addEventListener('blur', hideCaps);
infoBtn.addEventListener('click', (e) => {
e.stopPropagation();
// A long-press already opened the bubble: swallow the synthetic click.
if (_longPressFired) { _longPressFired = false; return; }
capsPop.classList.toggle('hidden');
});
capsPop.addEventListener('mouseenter', showCaps);
capsPop.addEventListener('mouseleave', hideCaps);
infoBtn.addEventListener('touchstart', () => {
_longPressFired = false;
_longPressTimer = setTimeout(() => { _longPressFired = true; showCaps(); }, 450);
}, { passive: true });
const _cancelLongPress = () => clearTimeout(_longPressTimer);
infoBtn.addEventListener('touchend', _cancelLongPress);
infoBtn.addEventListener('touchcancel', () => { _cancelLongPress(); _longPressFired = false; });
_bindPickerOutside();
// ── Model list state ──
let _models = [];
let _activeIndex = -1;
const _visibleOptions = () => Array.from(modelList.querySelectorAll('.ai-picker-model-option'));
function _syncTrigger() {
modelName.textContent = modelSelect.value || t('ai.model_default');
}
function _highlight(index) {
const options = _visibleOptions();
if (!options.length) { _activeIndex = -1; return; }
_activeIndex = ((index % options.length) + options.length) % options.length;
options.forEach((el, i) => el.classList.toggle('active', i === _activeIndex));
const active = options[_activeIndex];
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ block: 'nearest' });
}
}
function _renderList() {
const q = _normalizeText(modelSearch.value);
const matches = _models.filter((m) => !q || _normalizeText(m).includes(q));
modelList.innerHTML = '';
_activeIndex = -1;
if (!matches.length) {
const empty = document.createElement('div');
empty.className = 'ai-picker-model-empty';
empty.textContent = t('ai.no_results');
modelList.appendChild(empty);
return;
}
matches.forEach((model) => {
const option = document.createElement('button');
option.type = 'button';
option.className = 'ai-picker-model-option';
option.dataset.value = model;
option.textContent = model || t('ai.model_default');
if (model === modelSelect.value) option.classList.add('active');
option.addEventListener('mousedown', (e) => { e.preventDefault(); _pick(model); });
modelList.appendChild(option);
});
_activeIndex = _visibleOptions().findIndex((el) => el.dataset.value === modelSelect.value);
}
function _pick(model) {
modelSelect.value = model;
delete pickerState.capabilities;
_writePicker(pickerState);
_syncTrigger();
_closePop();
modelSelect.dispatchEvent(new Event('change'));
}
function _openPop() {
modelPop.classList.remove('hidden');
modelTrigger.setAttribute('aria-expanded', 'true');
modelSearch.value = '';
_renderList();
modelSearch.focus();
}
function _closePop() {
modelPop.classList.add('hidden');
modelTrigger.setAttribute('aria-expanded', 'false');
modelSearch.value = '';
}
modelTrigger.addEventListener('click', (e) => {
e.stopPropagation();
if (modelPop.classList.contains('hidden')) _openPop();
else _closePop();
});
modelSearch.addEventListener('input', _renderList);
modelSearch.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); _highlight(_activeIndex < 0 ? 0 : _activeIndex + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); _highlight(_activeIndex < 0 ? -1 : _activeIndex - 1); }
else if (e.key === 'Enter') {
e.preventDefault();
const options = _visibleOptions();
const chosen = options[_activeIndex] || options[0];
if (chosen) _pick(chosen.dataset.value);
} else if (e.key === 'Escape') { e.preventDefault(); _closePop(); }
});
async function _updateCaps() {
capsHost.innerHTML = '';
capsPop.innerHTML = '';
infoBtn.classList.add('hidden');
if (!pickerState.provider || !pickerState.model) {
delete pickerState.capabilities;
_writePicker(pickerState);
@@ -175,42 +334,58 @@ async function _buildPickerUI() {
if (!caps) return;
pickerState.capabilities = caps;
_writePicker(pickerState);
capsHost.appendChild(renderCapabilityList(caps));
const title = document.createElement('div');
title.className = 'ai-picker-caps-title';
title.textContent = t('ai.model_info');
capsPop.appendChild(title);
capsPop.appendChild(renderCapabilityList(caps));
infoBtn.classList.remove('hidden');
}
async function _loadModels(provider) {
_models = [];
modelSelect.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = t('ai.model_loading');
modelSelect.appendChild(ph);
_syncTrigger();
if (!provider) {
_renderList();
await _updateCaps();
return;
}
try {
const data = await api(`/api/config/ai-models?provider=${encodeURIComponent(provider)}`);
modelSelect.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = t('ai.model_default') + (data.source === 'fallback' ? ` (${t('ai.model_offline')})` : '');
def.textContent = t('ai.model_default');
modelSelect.appendChild(def);
_models = [''];
(data.models || []).forEach((m) => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
if (m === pickerState.model && provider === pickerState.provider) opt.selected = true;
modelSelect.appendChild(opt);
_models.push(m);
});
// Keep an explicitly stored model selectable even if the API omits it.
if (pickerState.model && !_models.includes(pickerState.model)) {
const opt = document.createElement('option');
opt.value = pickerState.model;
opt.textContent = pickerState.model;
modelSelect.appendChild(opt);
_models.push(pickerState.model);
}
// If the provider default model is known, reflect it so capabilities
// are shown even when the user keeps the "default" placeholder.
if (!pickerState.model && data.models && data.models.length) {
pickerState.model = data.models[0];
_writePicker(pickerState);
}
modelSelect.value = pickerState.model || '';
_syncTrigger();
await _updateCaps();
} catch {
_models = [''];
_syncTrigger();
await _updateCaps();
} catch (err) {
modelSelect.innerHTML = '';
const opt = document.createElement('option');
opt.value = '';
opt.textContent = t('ai.model_load_error');
modelSelect.appendChild(opt);
}
}
@@ -220,32 +395,30 @@ async function _buildPickerUI() {
pickerState.model = null;
delete pickerState.capabilities;
_writePicker(pickerState);
capsHost.innerHTML = '';
if (pickerState.provider) {
_loadModels(pickerState.provider);
} else {
modelSelect.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = t('ai.model_default');
modelSelect.appendChild(ph);
}
_closePop();
_loadModels(pickerState.provider);
});
modelSelect.addEventListener('change', () => {
pickerState.model = modelSelect.value || null;
delete pickerState.capabilities;
_writePicker(pickerState);
_syncTrigger();
_updateCaps();
});
// Load models for the initial provider selection
if (pickerState.provider && providerNames.includes(pickerState.provider)) {
_loadModels(pickerState.provider);
} else {
_syncTrigger();
_renderList();
}
wrap.appendChild(providerLabel);
wrap.appendChild(providerSelect);
wrap.appendChild(modelSelect);
wrap.appendChild(combo);
wrap.appendChild(capsHost);
return wrap;
}
+55 -19
View File
@@ -30,6 +30,18 @@ const PANEL_MIN_WIDTH = 320;
const PANEL_MAX_WIDTH = 1000;
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
/**
* Accent- and case-insensitive normalization used to match `/` commands and
* `@` mentions: skill ids/labels and vault paths may contain accented
* characters (é, à, ç…), which the composer regexes and filters must accept.
*/
function normalizeSearch(value) {
return String(value || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
/**
* Gather the files currently open across the singleton TabManager and every
* split-view pane (window globals avoid a circular import with ui.js).
@@ -93,6 +105,8 @@ class BooksLM {
this._menuItems = [];
this._menuIndex = 0;
this._menuKind = null;
// Monotonic token used to drop stale async menu renders (fast typing).
this._menuSeq = 0;
}
_readAgentMode() {
@@ -727,12 +741,13 @@ class BooksLM {
if (!textarea) return;
const pos = textarea.selectionStart;
const before = textarea.value.slice(0, pos);
const command = before.match(/(?:^|\s)\/([a-z0-9-]*)$/i);
// Unicode-aware so accented skill names / paths keep the menu open.
const command = before.match(/(?:^|\s)\/([\p{L}\p{N}_-]*)$/u);
if (command) {
this._showCommandMenu(command[1].toLowerCase());
this._showCommandMenu(command[1]);
return;
}
const mention = before.match(/(?:^|\s)@([\w./-]*)$/);
const mention = before.match(/(?:^|\s)@([\p{L}\p{N}._/-]*)$/u);
if (mention) {
this._showMentionMenu(mention[1]);
return;
@@ -758,10 +773,12 @@ class BooksLM {
async _showCommandMenu(query) {
if (!this._panel) return;
const seq = ++this._menuSeq;
const skills = await this._ensureSkills();
const q = (query || '').toLowerCase();
if (seq !== this._menuSeq) return; // a newer input superseded this render
const q = normalizeSearch(query);
const matches = skills.filter((s) =>
!q || s.id.includes(q) || (s.label || '').toLowerCase().includes(q));
!q || normalizeSearch(s.id).includes(q) || normalizeSearch(s.label).includes(q));
this._renderMenu('command', matches, (skill) => this._selectCommand(skill));
}
@@ -771,18 +788,18 @@ class BooksLM {
if (skill.type === 'admin') {
this._runAdminCommand(skill.id);
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
}
return;
}
if (skill.special === 'create_skill') {
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
if (textarea) textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
this._promptCreateSkill();
return;
}
this._activeSkill = skill.id;
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)\/[a-z0-9-]*$/i, '$1').trim();
textarea.value = textarea.value.replace(/(^|\s)\/[\p{L}\p{N}_-]*$/u, '$1').trim();
textarea.focus();
}
this._renderAttachments();
@@ -790,16 +807,19 @@ class BooksLM {
async _showMentionMenu(query) {
if (!this._panel) return;
const seq = ++this._menuSeq;
const vault = this._resolveVault();
if (!vault) {
if (!vault && !query) {
// Nothing to list without a vault; a typed query can still search all.
this._hideMenus();
return;
}
try {
let results = [];
if (query) {
const searchVault = vault || 'all';
const data = await api(
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(vault)}`
`/api/tree-search?q=${encodeURIComponent(query)}&vault=${encodeURIComponent(searchVault)}`
);
results = (data.results || []).slice(0, 20);
} else {
@@ -808,13 +828,16 @@ class BooksLM {
);
results = (data.files || []).slice(0, 20).map((f) => ({ path: f.path, type: 'file' }));
}
const items = results.map((r) => ({
id: r.path || r,
label: r.path || r,
type: r.type || (this._classifyPath(r.path || r) === 'dir' ? 'dir' : 'file'),
}));
if (seq !== this._menuSeq) return; // superseded while the request ran
const items = results.map((r) => {
const path = r.path || r;
const rawType = r.type || (this._classifyPath(path) === 'dir' ? 'dir' : 'file');
const type = rawType === 'directory' ? 'dir' : rawType;
return { id: path, label: path, type };
});
this._renderMenu('mention', items, (item) => this._selectMention(item));
} catch (e) {
if (seq !== this._menuSeq) return;
console.warn('AI assistant: mention search failed', e);
this._hideMenus();
}
@@ -824,7 +847,7 @@ class BooksLM {
const textarea = this._panel && this._panel.querySelector('textarea');
this._hideMenus();
if (textarea) {
textarea.value = textarea.value.replace(/(^|\s)@[\w./-]*$/, '$1').trim();
textarea.value = textarea.value.replace(/(^|\s)@[\p{L}\p{N}._/-]*$/u, '$1').trim();
textarea.focus();
}
const path = item.id;
@@ -864,6 +887,8 @@ class BooksLM {
el.type = 'button';
el.className = 'bookslm-menu-item' + (index === 0 ? ' active' : '');
el.dataset.index = String(index);
el.setAttribute('role', 'option');
el.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
const icon = document.createElement('span');
icon.className = 'bookslm-menu-icon';
icon.textContent = item.icon || (item.type === 'dir' ? '📁' : item.type === 'admin' ? '⚙️' : '📄');
@@ -901,10 +926,19 @@ class BooksLM {
if (!this._panel || !this._menuKind) return;
const menu = this._panel.querySelector(`.bookslm-${this._menuKind}-menu`);
if (!menu) return;
this._menuIndex = index;
menu.querySelectorAll('.bookslm-menu-item').forEach((el, i) => {
el.classList.toggle('active', i === index);
const options = menu.querySelectorAll('.bookslm-menu-item');
if (!options.length) return;
const next = ((index % options.length) + options.length) % options.length;
this._menuIndex = next;
options.forEach((el, i) => {
const on = i === next;
el.classList.toggle('active', on);
el.setAttribute('aria-selected', on ? 'true' : 'false');
});
const active = options[next];
if (active && typeof active.scrollIntoView === 'function') {
active.scrollIntoView({ block: 'nearest' });
}
}
_handleMenuKey(e) {
@@ -938,6 +972,8 @@ class BooksLM {
_hideMenus() {
if (!this._panel) return;
// Invalidate any in-flight async menu render.
this._menuSeq++;
this._panel.querySelectorAll('.bookslm-command-menu, .bookslm-mention-menu').forEach((m) => {
m.classList.add('hidden');
});
+3 -1
View File
@@ -128,6 +128,8 @@
"ai.model_offline": "offline",
"ai.model_load_error": "Load error",
"ai.model_settings": "Provider & model",
"ai.model_search": "Search a model…",
"ai.model_info": "Model capabilities",
"ai.add_context": "Add context",
"ai.attach_image": "Attach an image",
"ai.cap_audio_speech": "Audio Speech",
@@ -1269,7 +1271,7 @@
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model; the selected model's capabilities are displayed.",
"help.assistant_images": "Paste an image into the composer (or attach one) to analyse it with a vision-capable model. The ⓘ button shows the selected model's capabilities, and the model list is filterable by search.",
"help.toolbar_section": "AI toolbar",
"help.tree_section": "Vault tree",
"help.use_cases": "Use cases",
+3 -1
View File
@@ -128,6 +128,8 @@
"ai.model_offline": "hors ligne",
"ai.model_load_error": "Erreur de chargement",
"ai.model_settings": "Fournisseur & modèle",
"ai.model_search": "Rechercher un modèle…",
"ai.model_info": "Capacités du modèle",
"ai.add_context": "Ajouter un contexte",
"ai.attach_image": "Joindre une image",
"ai.cap_audio_speech": "Audio Speech",
@@ -1269,7 +1271,7 @@
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision ; les capacités du modèle sélectionné sont affichées.",
"help.assistant_images": "Collez une image dans la zone de saisie (ou joignez-la) pour l'analyser avec un modèle compatible vision. Le bouton ⓘ affiche les capacités du modèle sélectionné, et la liste des modèles est filtrable par recherche.",
"help.toolbar_section": "Barre d'outils AI",
"help.tree_section": "Arborescence des vaults",
"help.use_cases": "Cas d'usage",
+41 -2
View File
@@ -9362,8 +9362,47 @@ body.bookslm-resizing { cursor: ew-resize; user-select: none; }
.bookslm-modal-actions button { padding: 7px 14px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface); color: var(--text-primary); cursor: pointer; font-size: 13px; }
.bookslm-modal-actions button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
/* Model capability checklist in the picker. */
.ai-picker-caps-host { flex-basis: 100%; }
/* ── Provider/model picker (compact, shared by assistant + editor) ── */
.ai-picker { display: inline-flex; align-items: center; gap: 6px;
font-size: 0.7rem; color: var(--text-secondary); flex-shrink: 0; position: relative; }
.ai-picker-label { white-space: nowrap; }
.ai-picker select { font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 4px; padding: 2px 4px; cursor: pointer; }
/* Searchable model combobox. */
.ai-picker-model-combo { position: relative; display: inline-flex; }
.ai-picker-model-trigger { display: inline-flex; align-items: center; gap: 4px; max-width: 220px;
font-size: 0.7rem; background: var(--surface); color: var(--text-primary);
border: 1px solid var(--border); border-radius: 4px; padding: 3px 6px; cursor: pointer; }
.ai-picker-model-trigger:hover { border-color: var(--accent); }
.ai-picker-model-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-picker-model-chevron { opacity: 0.6; font-size: 0.6rem; }
.ai-picker-model-pop { position: absolute; top: calc(100% + 4px); left: 0; z-index: 60;
min-width: 220px; max-width: 320px; background: var(--bg-primary);
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
padding: 6px; }
.ai-picker-model-pop.hidden { display: none; }
.ai-picker-model-search { width: 100%; box-sizing: border-box; font-size: 0.72rem;
background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 8px; margin-bottom: 4px; }
.ai-picker-model-list { max-height: 200px; overflow-y: auto; display: flex; flex-direction: column; }
.ai-picker-model-option { text-align: left; font-size: 0.72rem; padding: 5px 8px; border-radius: 5px;
background: none; border: none; color: var(--text-primary); cursor: pointer;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ai-picker-model-option:hover, .ai-picker-model-option.active { background: var(--surface2); }
.ai-picker-model-empty { font-size: 0.7rem; color: var(--text-secondary); padding: 6px 8px; text-align: center; }
/* Capability info button (ⓘ) + informational bubble. */
.ai-picker-caps-host { position: relative; display: inline-flex; }
.ai-picker-info { background: none; border: none; cursor: pointer; color: var(--text-secondary);
font-size: 0.85rem; line-height: 1; padding: 2px 4px; border-radius: 4px; }
.ai-picker-info:hover, .ai-picker-info:focus { color: var(--accent); }
.ai-picker-info.hidden { display: none; }
.ai-picker-caps-pop { position: absolute; top: calc(100% + 4px); right: 0; z-index: 60;
min-width: 220px; max-width: 300px; background: var(--bg-primary);
border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.35);
padding: 8px 10px; }
.ai-picker-caps-pop.hidden { display: none; }
.ai-picker-caps-title { font-size: 0.7rem; font-weight: 600; color: var(--text-secondary);
text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; }
.ai-picker-caps { display: flex; flex-wrap: wrap; gap: 4px 10px; margin-top: 4px; font-size: 10px;
color: var(--text-secondary); }
.ai-cap-item { white-space: nowrap; opacity: 0.65; }