// Model selector plus: pinned + drag-reorderable models and brand icons.
// client.js is generated by build.mjs (this file + ./icons/*.svg). Edit this file, then run `node build.mjs`.
window.__ModuleLoader__.load({
id: '@local/model-selector-plus',
factory(require) {
const React = require('react');
const ReactDOM = require('react-dom');
const P = require('@deepseek-ai/dsh-client-ui-primitives');
const h = React.createElement;
const NS = 'modelPlus';
/* ------------------------------------------------------------------ icons */
const ICONS = {"ai21":"","anthropic":"","aws":"","azure":"","baichuan":"","baidu":"","bedrock":"","bytedance":"","cerebras":"","cohere":"","deepseek":"","doubao":"","fireworks":"","gemini":"","gemma":"","githubcopilot":"","google":"","grok":"","groq":"","huggingface":"","hunyuan":"","internlm":"","liquid":"","lmstudio":"","longcat":"","meta":"","microsoft":"","minimax":"","mistral":"","moonshot":"","nousresearch":"","nova":"","nvidia":"","ollama":"","openai":"","openrouter":"","perplexity":"","qwen":"","stepfun":"","together":"","xiaomimimo":"","yi":"","zai":"","zhipu":""};
// [icon, regexp]. Tested against the model id/name first, then the provider id/name.
const BRAND_RULES = [
['anthropic', /claude|anthropic|(^|[^a-z])(sonnet|opus|haiku)([^a-z]|$)/],
['openai', /chatgpt|(^|[^a-z0-9])gpt|openai|(^|[\/\-_: ])o[1-9](-|$|\s)|davinci|codex|dall-?e|whisper-1|text-embedding-3/],
['gemma', /gemma/],
['gemini', /gemini/],
['google', /google|palm|bard|(^|[^a-z])imagen|(^|[^a-z])veo[-\d]/],
['deepseek', /deepseek/],
['moonshot', /moonshot|kimi/],
['xiaomimimo', /xiaomi|(^|[^a-z])mimo/],
['qwen', /qwen|qwq|tongyi|alibaba|dashscope/],
['zai', /(^|[^a-z])glm|zhipu|chatglm|z-ai|(^|[^a-z])zai([^a-z]|$)|z\.ai/],
['meta', /llama|(^|[^a-z])meta([^a-z]|$)/],
['mistral', /mistral|mixtral|codestral|ministral|devstral|magistral|pixtral/],
['grok', /grok|(^|[^a-z])x-?ai([^a-z]|$)/],
['minimax', /minimax|(^|[^a-z])abab/],
['cohere', /cohere|command-?(r|a)([^a-z]|$)/],
['perplexity', /perplexity|(^|[^a-z])sonar|pplx/],
['nvidia', /nvidia|nemotron/],
['microsoft', /microsoft|(^|[^a-z])phi-?\d|(^|[^a-z])mai-/],
['nova', /(^|[^a-z])amazon|(^|[^a-z])nova-|(^|[^a-z])titan-/],
['doubao', /doubao|(^|[^a-z])seed-|volcengine|volcano/],
['bytedance', /bytedance|(^|[^a-z])ui-tars/],
['hunyuan', /hunyuan|tencent/],
['baidu', /baidu|(^|[^a-z])ernie/],
['yi', /(^|[^a-z])yi-|01-ai|01\.ai|lingyiwanwu/],
['stepfun', /stepfun|(^|[^a-z])step-\d|(^|[^a-z])step\d/],
['longcat', /longcat|meituan/],
['internlm', /internlm|(^|[^a-z])intern-/],
['baichuan', /baichuan/],
['ai21', /ai21|jamba/],
['nousresearch', /nousresearch|(^|[^a-z])nous([^a-z]|$)|(^|[^a-z])hermes/],
['liquid', /(^|[^a-z])lfm|liquid/],
['cerebras', /cerebras/],
];
// Serving platforms: only meaningful when the model name itself says nothing.
const PROVIDER_RULES = [
['openrouter', /openrouter/],
['ollama', /ollama/],
['lmstudio', /lm-?studio/],
['groq', /(^|[^a-z])groq/],
['together', /together/],
['fireworks', /fireworks/],
['huggingface', /hugging-?face|(^|[^a-z])hf([^a-z]|$)/],
['githubcopilot', /copilot/],
['azure', /azure/],
['bedrock', /bedrock/],
['aws', /(^|[^a-z])aws([^a-z]|$)/],
];
const brandCache = new Map();
function brandOf(providerId, providerName, modelId, modelName) {
const ck = providerId + '\u0000' + modelId;
if (brandCache.has(ck)) return brandCache.get(ck);
const modelText = ((modelId || '') + ' ' + (modelName || '')).toLowerCase();
const providerText = ((providerId || '') + ' ' + (providerName || '')).toLowerCase();
let found;
for (const [icon, re] of BRAND_RULES) if (re.test(modelText)) { found = icon; break; }
if (!found) for (const [icon, re] of BRAND_RULES) if (re.test(providerText)) { found = icon; break; }
if (!found) for (const [icon, re] of PROVIDER_RULES) if (re.test(providerText)) { found = icon; break; }
if (found && !ICONS[found]) found = undefined;
brandCache.set(ck, found);
return found;
}
function BrandIcon({ brand, size = 16 }) {
if (!brand) return h('span', { className: 'msp-icon msp-icon-empty', style: { width: size, height: size }, 'aria-hidden': true });
return h('svg', {
className: 'msp-icon', 'aria-hidden': true, focusable: false,
width: size, height: size, viewBox: '0 0 24 24', fill: 'currentColor', fillRule: 'evenodd',
'data-brand': brand, dangerouslySetInnerHTML: { __html: ICONS[brand] },
});
}
const Grip = () => h('svg', { width: 10, height: 14, viewBox: '0 0 10 14', fill: 'currentColor', 'aria-hidden': true },
[1.5, 7].flatMap((x) => [2, 7, 12].map((y) => h('circle', { key: x + ':' + y, cx: x + 0.5, cy: y, r: 1.1 }))));
const Ic = (name) => P[name] || (() => null);
const IconCheck = Ic('IconCheckOutlineRegular');
const IconChevronDown = Ic('IconChevronDownOutlineRegular');
const IconChevronRight = Ic('IconChevronRightOutlineRegular');
const IconClose = Ic('IconCloseFillRegular');
const IconPinFill = Ic('IconPinFillRegular');
const IconPinOutline = Ic('IconPinOutlineRegular');
/* ------------------------------------------------------------------ pins */
const PIN_KEY = 'dsh.modelSelectorPlus.pins.v1';
const pinKey = (provider, model) => provider + '\u0000' + model;
function readPins() {
try {
const raw = JSON.parse(localStorage.getItem(PIN_KEY) || '[]');
return Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : [];
} catch { return []; }
}
const pinStore = {
list: readPins(),
listeners: new Set(),
getSnapshot() { return pinStore.list; },
subscribe(fn) { pinStore.listeners.add(fn); return () => pinStore.listeners.delete(fn); },
set(next) {
pinStore.list = next;
try { localStorage.setItem(PIN_KEY, JSON.stringify(next)); } catch { /* storage unavailable: pins stay in memory */ }
pinStore.listeners.forEach((fn) => fn());
},
};
const onStorage = (e) => {
if (e.key !== PIN_KEY) return;
pinStore.list = readPins();
pinStore.listeners.forEach((fn) => fn());
};
function moveKey(order, from, to, pos) {
if (from === to) return order;
const without = order.filter((k) => k !== from);
const at = without.indexOf(to);
if (at < 0) return order;
without.splice(pos === 'after' ? at + 1 : at, 0, from);
return without;
}
/* ------------------------------------------------------------------ copy */
const en = {
'provider.account': 'DeepSeek Account',
'trigger.fallback': 'Select model',
'trigger.loading': 'Loading models…',
'trigger.aria': 'Select model, current {model}',
'trigger.effortAria': 'Reasoning effort, current {effort}',
'menu.aria': 'Model and reasoning effort',
'menu.model': 'Model',
'menu.effort': 'Effort',
'effort.providerDefault': 'Default',
'status.loading': 'Refreshing model list…',
'error.action': 'Model operation failed: {message}',
'error.sessionInUse': 'This session is already in use, possibly by another running DSH instance. Quit other running DSH instances and try again.',
'action.reload': 'Reload',
'warning.groupLoad': '{name} failed to load: {message}',
'search.placeholder': 'Search models…',
'search.clear': 'Clear search',
'search.empty': 'No matching models.',
'empty.models': 'No models available.',
'empty.efforts': 'This model provides no reasoning effort levels.',
pinned: 'Pinned',
pin: 'Pin to top',
unpin: 'Unpin',
reorder: 'Drag to reorder (or Alt+↑/↓)',
addModels: 'Add models',
addModelsHint: 'Open model settings',
};
const zh = {
'provider.account': 'DeepSeek 账号',
'trigger.fallback': '请选择模型',
'trigger.loading': '正在加载模型…',
'trigger.aria': '选择模型,当前 {model}',
'trigger.effortAria': '推理等级,当前 {effort}',
'menu.aria': '模型与推理等级',
'menu.model': '模型',
'menu.effort': '推理等级',
'effort.providerDefault': 'Default',
'status.loading': '正在刷新模型列表…',
'error.action': '模型操作失败:{message}',
'error.sessionInUse': '当前会话已被占用,可能是其他正在运行的 DSH 导致的,请退出其他 DSH 后重试。',
'action.reload': '重新加载',
'warning.groupLoad': '{name} 加载失败:{message}',
'search.placeholder': '搜索模型…',
'search.clear': '清除搜索',
'search.empty': '没有匹配的模型。',
'empty.models': '没有可用的模型。',
'empty.efforts': '当前模型未提供推理等级。',
pinned: '已固定',
pin: '固定到顶部',
unpin: '取消固定',
reorder: '拖动排序(或 Alt+↑/↓)',
addModels: '添加模型',
addModelsHint: '打开模型设置',
};
/* ------------------------------------------------------------------ css */
const CSS = `
.msp-root{min-width:0;position:relative;display:flex;align-items:center;gap:2px}
.msp-trigger{border-radius:var(--dsw-radius-sm);min-width:0;max-width:min(360px,45cqw);height:28px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:0 4px 0 8px;font-size:13px;font-weight:400;line-height:20px;display:flex}
.msp-trigger:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
.msp-trigger:focus-visible:not([data-selection-focus]){box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary))}
.msp-trigger:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}
.msp-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}
.msp-effort{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}
.msp-trigger-effort{color:var(--dsw-alias-label-caption);flex-shrink:1000;padding-left:6px}
.msp-trigger-effort:disabled{opacity:.6}
.msp-chevron{color:var(--dsw-alias-label-caption);flex:none;transition:transform .12s}
.msp-chevron-open{transform:rotate(180deg)}
.msp-icon{flex:none;display:inline-block;line-height:1}
.msp-menu{z-index:1100;--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);width:max-content;min-width:min(260px,100vw - 32px);max-width:min(420px,100vw - 32px);max-height:min(420px,100vh - 96px);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border:0;flex-direction:column;padding:4px;display:flex;position:fixed;overflow:hidden}
.msp-status,.msp-empty{color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;line-height:18px}
.msp-error,.msp-warning{border-radius:var(--dsw-radius-md);background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-state-error-primary);justify-content:space-between;align-items:flex-start;gap:6px;margin-bottom:3px;padding:6px 7px;font-size:11px;line-height:16px;display:flex}
.msp-warning{background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-state-warn-label)}
.msp-retry{color:inherit;cursor:pointer;font:inherit;text-decoration:underline;background:0 0;border:0;padding:0;flex:none}
.msp-search-row{flex-shrink:0;margin:2px 0 3px;position:relative}
.msp-search-row .msp-search{border-radius:var(--dsw-radius-md);background:0 0;border:0 solid #0000;height:auto;padding:5px 7px;display:flex}
.msp-search-row .msp-search-q{padding-right:34px}
.msp-search-row .msp-search:focus-within{border-color:#0000}
.msp-search-row .msp-search input{padding:0;font-size:12px;line-height:normal}
.msp-search-row .msp-search input::placeholder{color:var(--dsw-alias-label-caption)}
.msp-search-clear{width:24px;height:24px;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;border-radius:50%;justify-content:center;align-items:center;padding:0;display:inline-flex;position:absolute;top:50%;right:4px;transform:translateY(-50%)}
.msp-search-clear:hover,.msp-search-clear:focus-visible{background:var(--dsw-alias-interactive-bg-hover);outline:none}
.msp-groups{min-height:0;overflow-y:auto}
.msp-row{box-sizing:border-box;border-radius:var(--dsw-radius-md);min-width:100%;min-height:34px;align-items:center;gap:2px;padding:0 2px 0 0;display:flex;position:relative}
.msp-row:hover,.msp-row-active{background:var(--dsw-alias-interactive-bg-hover)}
.msp-row-selected{background:0 0}
.msp-row-selected:hover,.msp-row-selected.msp-row-active{background:var(--dsw-alias-interactive-bg-hover)}
.msp-row[draggable=true]{cursor:grab}
.msp-row-dragging{opacity:.4}
.msp-row-drop-before::before,.msp-row-drop-after::after{content:"";background:var(--dsw-alias-state-business-primary);pointer-events:none;border-radius:1px;height:2px;position:absolute;left:6px;right:6px;z-index:2}
.msp-row-drop-before::before{top:-1px}
.msp-row-drop-after::after{bottom:-1px}
.msp-grip{width:12px;color:var(--dsw-alias-label-caption);flex:none;justify-content:center;align-items:center;margin-left:2px;display:flex;opacity:.55}
.msp-row:hover .msp-grip{opacity:1}
.msp-option{box-sizing:border-box;color:inherit;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;flex:1;align-items:center;gap:8px;min-width:0;min-height:34px;padding:5px 5px 5px 7px;display:flex;border-radius:var(--dsw-radius-md)}
.msp-option[aria-disabled=true]{color:var(--dsw-alias-label-dimmed);cursor:default}
.msp-option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}
.msp-copy{flex-direction:column;flex:1;min-width:0;display:flex}
.msp-name{color:inherit;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:400;line-height:18px;overflow:hidden}
.msp-provider{color:var(--dsw-alias-label-caption);text-overflow:ellipsis;white-space:nowrap;font-size:11px;line-height:14px;overflow:hidden}
.msp-check{color:var(--dsw-alias-label-primary);flex:0 0 14px;place-items:center;display:grid}
.msp-check svg{width:14px;height:14px}
.msp-pin{width:26px;height:26px;color:var(--dsw-alias-label-caption);cursor:pointer;background:0 0;border:none;border-radius:var(--dsw-radius-sm);justify-content:center;align-items:center;padding:0;display:inline-flex;flex:none;opacity:0;outline:none}
.msp-row:hover .msp-pin,.msp-row-active .msp-pin,.msp-pin:focus-visible,.msp-pin-on{opacity:1}
.msp-pin:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.msp-pin-on{color:var(--dsw-alias-label-secondary)}
.msp-pin:disabled{cursor:default;opacity:0}
.msp-pin svg{width:14px;height:14px}
.msp-title{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:18px;margin:-2px 0}
.msp-title-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.msp-title-row{display:flex;justify-content:flex-end;padding:2px 3px 0}
.msp-add,.msp-title .msp-add{box-sizing:border-box;width:20px;min-width:20px;max-width:20px;height:20px;min-height:20px;margin:0;color:var(--dsw-alias-label-caption);cursor:pointer;background:0 0;border:none;border-radius:var(--dsw-radius-sm);justify-content:center;align-items:center;padding:0;display:inline-flex;flex:none;outline:none}
.msp-add:hover,.msp-add:focus-visible{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
.msp-cell{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:auto;min-width:100%;height:34px;color:var(--dsw-alias-label-primary);cursor:pointer;text-align:left;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:0 8px;font-size:13px;line-height:20px;display:flex}
.msp-cell:hover,.msp-cell:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}
.msp-cell-label{white-space:nowrap;flex:none}
.msp-cell-value{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--dsw-alias-label-tertiary);flex:auto;align-items:center;justify-content:flex-end;gap:6px;display:flex;overflow:hidden}
.msp-cell-chevron{width:12px;height:12px;color:var(--dsw-alias-menu-icon);flex:none}
.msp-effort-option{box-sizing:border-box;border-radius:var(--dsw-radius-md);width:100%;min-height:34px;color:inherit;font-size:13px;text-align:left;cursor:pointer;background:0 0;border:none;outline:none;align-items:center;gap:6px;padding:5px 7px;display:flex}
.msp-effort-option:hover:not(:disabled),.msp-effort-option:focus-visible{background:var(--dsw-alias-interactive-bg-hover)}
.msp-effort-option:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}
`;
function installCss() {
if (typeof document === 'undefined') return;
// Always refresh: after a live plugin reload an older tag may linger with outdated rules.
let tag = document.querySelector('style[data-model-plus-css]');
if (!tag) {
tag = document.createElement('style');
tag.dataset.modelPlusCss = '';
document.head.appendChild(tag);
}
if (tag.textContent !== CSS) tag.textContent = CSS;
}
const cx = (...a) => a.filter(Boolean).join(' ');
const MEASURE_STYLE = { visibility: 'hidden', left: 0, top: 0 };
/* ------------------------------------------------------------------ component */
function orderProviders(groups) {
const rank = (g) => (g.id === 'deepseek-account' ? 0 : g.id === 'deepseek-official' ? 1 : 2);
return groups.toSorted((a, b) => rank(a) - rank(b));
}
function rankByName(items, q) {
if (P.rankByName) return P.rankByName(items, q);
const lq = q.toLowerCase();
return lq === '' ? items : items.filter((i) => i.name.toLowerCase().includes(lq));
}
function ModelSelectPlus({ locked, available, directory, load, select, openSettings, t }) {
const state = React.useSyncExternalStore((fn) => directory.subscribe(fn), () => directory.getSnapshot());
const pins = React.useSyncExternalStore(pinStore.subscribe, pinStore.getSnapshot);
// pane: null (closed) | 'model' | 'effort' — each has its own trigger button.
const [pane, setPane] = React.useState(null);
const open = pane !== null;
const [query, setQuery] = React.useState('');
const [highlightedIndex, setHighlightedIndex] = React.useState(null);
const [selectionFocus, setSelectionFocus] = React.useState(false);
const [actionError, setActionError] = React.useState(null);
const [drag, setDrag] = React.useState(null); // { key, overKey, pos }
const [menuPos, setMenuPos] = React.useState(null);
const lastActionRef = React.useRef('load');
const rootRef = React.useRef(null);
const modelTriggerRef = React.useRef(null);
const effortTriggerRef = React.useRef(null);
const searchRef = React.useRef(null);
const menuRef = React.useRef(null);
const itemRefs = React.useRef([]);
const focusKeyRef = React.useRef(null);
const id = React.useId();
React.useEffect(() => {
installCss();
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const providerLabel = (g) => (g.id === 'deepseek-account' ? t('provider.account') : g.name);
const groups = React.useMemo(() => orderProviders(state.groups), [state.groups]);
const choices = React.useMemo(() => groups.flatMap((group) => group.models.map((model) => ({
key: pinKey(group.id, model.id),
group,
model,
name: model.name,
brand: brandOf(group.id, group.name, model.id, model.name),
selection: {
provider: group.id,
model: model.id,
...(model.reasoning?.defaultEffort === undefined ? {} : { reasoningEffort: model.reasoning.defaultEffort }),
},
}))), [groups]);
const choiceByKey = React.useMemo(() => new Map(choices.map((c) => [c.key, c])), [choices]);
const showSearch = choices.length > 4;
const q = showSearch ? query.trim() : '';
const pinnedAll = React.useMemo(() => pins.map((k) => choiceByKey.get(k)).filter(Boolean), [pins, choiceByKey]);
const pinSet = React.useMemo(() => new Set(pins), [pins]);
const pinnedShown = React.useMemo(() => rankByName(pinnedAll, q), [pinnedAll, q]);
const filteredGroups = React.useMemo(() => groups.map((group) => ({
...group,
models: rankByName(
group.models.filter((m) => !pinSet.has(pinKey(group.id, m.id))).map((m) => ({ name: m.name, model: m })),
q,
).map((x) => x.model),
})).filter((g) => g.models.length > 0), [groups, pinSet, q]);
const visible = React.useMemo(() => [
...pinnedShown.map((c) => ({ key: c.key, provider: c.group.id, model: c.model.id })),
...filteredGroups.flatMap((g) => g.models.map((m) => ({ key: pinKey(g.id, m.id), provider: g.id, model: m.id }))),
], [pinnedShown, filteredGroups]);
const currentVisibleIndex = visible.findIndex((v) => v.provider === state.current?.provider && v.model === state.current.model);
const activeModelIndex = Math.min(highlightedIndex ?? Math.max(0, currentVisibleIndex), visible.length - 1);
const currentChoice = state.current === null ? undefined : choices.find((c) => c.selection.provider === state.current.provider && c.selection.model === state.current.model);
const reasoning = currentChoice?.model.reasoning;
const effectiveEffort = state.current?.reasoningEffort ?? reasoning?.defaultEffort;
const effortLabel = reasoning === undefined
? state.retainedEffort
: effectiveEffort === undefined
? t('effort.providerDefault')
: reasoning.efforts.find((l) => l.id === effectiveEffort)?.name ?? effectiveEffort;
const effortChoices = React.useMemo(() => (reasoning === undefined ? [] : [
...(reasoning.defaultEffort === undefined ? [{ key: 'provider-default', effort: undefined, label: t('effort.providerDefault') }] : []),
...reasoning.efforts.map((e) => ({ key: 'effort:' + e.id, effort: e.id, label: e.name })),
]), [reasoning, t]);
const { pending } = state;
const busy = pending !== null;
const reload = () => { lastActionRef.current = 'load'; load(); };
/* ---- effects */
React.useEffect(() => {
if (!open) return undefined;
const closeOutside = (e) => {
if (rootRef.current?.contains(e.target) === true) return;
if (menuRef.current?.contains(e.target) === true) return;
setPane(null);
};
document.addEventListener('mousedown', closeOutside);
return () => document.removeEventListener('mousedown', closeOutside);
}, [open]);
React.useLayoutEffect(() => {
if (!showSearch) { setQuery(''); setHighlightedIndex(null); }
}, [showSearch]);
// Move keyboard focus into the menu when a pane opens (search box, else the checked row).
React.useEffect(() => {
if (!open) return;
const menu = menuRef.current;
if (pane === 'model' && showSearch) { searchRef.current?.focus(); return; }
(menu?.querySelector('[aria-checked="true"]:not([aria-disabled="true"]):not([disabled])') ?? itemRefs.current.find((i) => i))?.focus();
}, [open, pane, showSearch]);
// Keep keyboard focus on a pinned row after Alt+Arrow reorder.
React.useLayoutEffect(() => {
const key = focusKeyRef.current;
if (key === null) return;
focusKeyRef.current = null;
const rows = menuRef.current?.querySelectorAll('[data-pinned-row] [data-row]') ?? [];
for (const r of rows) if (r.dataset.key === key) { r.focus(); break; }
}, [pins]);
React.useLayoutEffect(() => {
if (!open) { setMenuPos(null); return undefined; }
const place = () => {
const rect = (pane === 'effort' ? effortTriggerRef : modelTriggerRef).current?.getBoundingClientRect();
if (rect === undefined) return;
const MARGIN = 12;
const lw = menuRef.current?.offsetWidth ?? 0;
const lh = menuRef.current?.offsetHeight ?? 0;
let x = rect.left;
let y = rect.top - 8 - lh;
if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN);
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN);
setMenuPos({ left: x, top: y });
};
place();
window.addEventListener('scroll', place, true);
window.addEventListener('resize', place);
return () => {
window.removeEventListener('scroll', place, true);
window.removeEventListener('resize', place);
};
}, [open, pane, state, query, pins]);
if (!available) return null;
/* ---- verbs */
const triggerOf = (which) => (which === 'effort' ? effortTriggerRef : modelTriggerRef);
const show = (which) => {
setSelectionFocus(false);
setQuery('');
setHighlightedIndex(null);
setActionError(null);
setPane(which);
reload();
};
const toggle = (which) => { if (pane === which) close(true); else show(which); };
const changeQuery = (next) => { setQuery(next); setHighlightedIndex(0); };
const close = (restoreFocus = false) => {
const ref = triggerOf(pane);
setPane(null);
setDrag(null);
if (restoreFocus) queueMicrotask(() => ref.current?.focus());
};
const closeAfterSelection = () => { setSelectionFocus(true); close(true); };
const rowEls = () => itemRefs.current.filter((i) => i);
const moveFocus = (offset) => {
const items = rowEls();
if (items.length === 0) return;
const active = items.findIndex((i) => i === document.activeElement);
items[active === -1 ? (offset > 0 ? 0 : items.length - 1) : (active + offset + items.length) % items.length]?.focus();
};
const settleSelection = (result) => {
if (result === undefined) return;
if (result.ok) { setActionError(null); if (rootRef.current !== null) closeAfterSelection(); return; }
const { error } = result;
setActionError(error.code === 'session/writer-held' ? t('error.sessionInUse') : t('error.action', { message: `${error.code}: ${error.message}` }));
};
const submit = (selection) => {
lastActionRef.current = 'select';
setActionError(null);
setSelectionFocus(true);
triggerOf(pane).current?.focus();
select(selection).then(settleSelection);
};
const choose = (selection) => {
if (state.current?.provider === selection.provider && state.current.model === selection.model) { closeAfterSelection(); return; }
const c = choiceByKey.get(pinKey(selection.provider, selection.model));
// Keep the effort the model already defaults to, like the stock selector.
submit(c ? c.selection : selection);
};
const chooseEffort = (effort) => {
if (state.current === null) return;
if (effectiveEffort === effort) { closeAfterSelection(); return; }
submit({ provider: state.current.provider, model: state.current.model, ...(effort === undefined ? {} : { reasoningEffort: effort }) });
};
const togglePin = (key) => {
pinStore.set(pinSet.has(key) ? pins.filter((k) => k !== key) : [...pins, key]);
};
const reorderBy = (key, delta) => {
const at = pins.indexOf(key);
const to = at + delta;
if (at < 0 || to < 0 || to >= pins.length) return;
const next = pins.slice();
[next[at], next[to]] = [next[to], next[at]];
focusKeyRef.current = key;
pinStore.set(next);
};
const onRootKeyDown = (event) => {
if (event.nativeEvent.isComposing) return;
const target = event.target;
const rowEl = target instanceof Element ? target.closest('[data-row]') : null;
if (event.key === 'Escape' && open) {
event.preventDefault();
close(true);
return;
}
if (!open) return;
if (pane === 'model' && showSearch && (event.key === 'ArrowDown' || event.key === 'ArrowUp') && !event.altKey) {
event.preventDefault();
if (!busy && visible.length > 0) {
setHighlightedIndex((activeModelIndex + (event.key === 'ArrowDown' ? 1 : -1) + visible.length) % visible.length);
searchRef.current?.focus();
}
return;
}
if (pane === 'model' && showSearch && target instanceof HTMLInputElement && (event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey))) {
if (event.key === 'Tab' && visible.length === 0) return;
event.preventDefault();
const hl = visible[activeModelIndex];
if (!busy && hl !== undefined) choose(hl);
return;
}
if (rowEl && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
rowEl.click();
return;
}
if (rowEl && !event.altKey && !event.ctrlKey && !event.metaKey && (event.key === 'p' || event.key === 'P') && rowEl.dataset.key) {
event.preventDefault();
togglePin(rowEl.dataset.key);
return;
}
if (rowEl && event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown') && rowEl.closest('[data-pinned-row]') && q === '') {
event.preventDefault();
reorderBy(rowEl.dataset.key, event.key === 'ArrowDown' ? 1 : -1);
return;
}
if (event.key === 'Tab') {
if (event.shiftKey) {
event.preventDefault();
close(true);
return;
}
const focused = document.activeElement;
const rows = rowEls();
if (focused instanceof HTMLElement && rows.includes(focused)) { event.preventDefault(); focused.click(); return; }
if (focused !== modelTriggerRef.current && focused !== effortTriggerRef.current) return;
event.preventDefault();
if (pane === 'model' && showSearch) { setHighlightedIndex(null); searchRef.current?.focus(); return; }
(menuRef.current?.querySelector('[aria-checked="true"]:not([aria-disabled="true"]):not([disabled])') ?? rows[0])?.focus();
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
moveFocus(event.key === 'ArrowDown' ? 1 : -1);
}
};
const onBlur = (event) => {
if (event.relatedTarget instanceof Node && (rootRef.current?.contains(event.relatedTarget) === true || menuRef.current?.contains(event.relatedTarget) === true)) return;
close();
};
/* ---- drag & drop (pinned rows only) */
const dragEnabled = q === '' && !busy;
const dragHandlers = (key) => ({
draggable: dragEnabled,
onDragStart: (e) => {
if (!dragEnabled) { e.preventDefault(); return; }
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', key); } catch { /* some engines refuse */ }
setDrag({ key, overKey: null, pos: 'before' });
},
onDragOver: (e) => {
if (!drag || drag.key === key) { if (drag && drag.overKey !== null) setDrag({ ...drag, overKey: null }); return; }
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const rect = e.currentTarget.getBoundingClientRect();
const pos = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
if (drag.overKey !== key || drag.pos !== pos) setDrag({ ...drag, overKey: key, pos });
},
onDrop: (e) => {
if (!drag) return;
e.preventDefault();
if (drag.overKey !== null) {
const next = moveKey(pins, drag.key, drag.overKey, drag.pos);
if (next !== pins) pinStore.set(next);
}
setDrag(null);
},
onDragEnd: () => setDrag(null),
});
/* ---- render */
const waiting = state.current === null && state.status === 'loading';
const modelLabel = waiting ? t('trigger.loading')
: currentChoice?.model.name ?? (state.current === null ? t('trigger.fallback') : `${state.current.provider}/${state.current.model}`);
const triggerBrand = currentChoice?.brand
?? (state.current === null ? undefined : brandOf(state.current.provider, state.current.provider, state.current.model, state.current.model));
const modelAria = state.current === null ? t('trigger.fallback') : t('trigger.aria', { model: modelLabel });
const effortAria = t('trigger.effortAria', { effort: effortLabel ?? '' });
let modelIndex = 0;
const renderRow = (choice, pinnedRow) => {
const index = modelIndex++;
const { key, group, model } = choice;
const selected = state.current?.provider === group.id && state.current.model === model.id;
const isPending = pending?.provider === group.id && pending.model === model.id;
const dropping = drag && drag.overKey === key;
const pinned = pinSet.has(key);
return h('div', {
key: (pinnedRow ? 'p:' : 'g:') + key,
className: cx('msp-row', selected && 'msp-row-selected', index === activeModelIndex && 'msp-row-active',
drag && drag.key === key && pinnedRow && 'msp-row-dragging',
dropping && pinnedRow && (drag.pos === 'before' ? 'msp-row-drop-before' : 'msp-row-drop-after')),
onMouseMove: busy || index === activeModelIndex || !showSearch ? undefined : () => setHighlightedIndex(index),
...(pinnedRow ? dragHandlers(key) : {}),
},
pinnedRow && h('span', { className: 'msp-grip', title: t('reorder'), 'aria-hidden': true }, h(Grip)),
h('div', {
ref: (el) => { itemRefs.current[index] = el; },
role: 'menuitemradio', 'aria-checked': selected, 'aria-disabled': busy ? 'true' : undefined,
tabIndex: showSearch ? -1 : 0,
id: `${id}-model-${index}`,
'data-row': '', 'data-key': key,
className: 'msp-option',
title: pinnedRow ? `${model.name} — ${providerLabel(group)}` : model.name,
onFocus: () => setHighlightedIndex(index),
onClick: () => { if (!busy) choose({ provider: group.id, model: model.id }); },
},
h(BrandIcon, { brand: choice.brand }),
h('span', { className: 'msp-copy' },
h('span', { className: 'msp-name' }, model.name),
pinnedRow && h('span', { className: 'msp-provider' }, providerLabel(group))),
h('span', { className: 'msp-check' },
isPending ? h(P.StateDot, { state: 'ongoing' }) : selected ? h(IconCheck) : null)),
h('button', {
type: 'button', tabIndex: -1, disabled: busy,
className: cx('msp-pin', pinned && 'msp-pin-on'),
'aria-pressed': pinned, 'aria-label': pinned ? t('unpin') : t('pin'),
title: pinned ? t('unpin') : t('pin'),
onMouseDown: (e) => e.preventDefault(),
onClick: (e) => { e.stopPropagation(); togglePin(key); },
}, pinned ? h(IconPinFill) : h(IconPinOutline)));
};
const loadError = state.error !== null && lastActionRef.current === 'load'
? h('div', { className: 'msp-error' },
h('span', null, t('error.action', { message: state.error })),
h('button', { type: 'button', className: 'msp-retry', onClick: reload }, t('action.reload')))
: null;
const addButton = h('button', {
type: 'button', className: 'msp-add', title: t('addModelsHint'), 'aria-label': t('addModelsHint'), tabIndex: -1,
onMouseDown: (e) => e.preventDefault(),
onClick: (e) => { e.stopPropagation(); setPane(null); setDrag(null); openSettings(); },
}, h('svg', { width: 12, height: 12, viewBox: '0 0 14 14', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', 'aria-hidden': true },
h('path', { d: 'M7 2v10M2 7h10' })));
// A group heading; the first one in the list also carries the "add models" button at its right end.
const titled = (text, first) => (first
? h('span', { className: 'msp-title' }, h('span', { className: 'msp-title-text' }, text), addButton)
: text);
const menu = open && ReactDOM.createPortal(h(P.MenuSurface, {
ref: menuRef, id: `${id}-menu`, className: 'msp-menu', style: menuPos ?? MEASURE_STYLE,
role: pane === 'model' ? 'group' : 'menu', 'aria-label': pane === 'effort' ? t('menu.effort') : t('menu.model'),
'aria-busy': state.status === 'loading' || busy,
},
pane === 'model' && h(React.Fragment, null,
showSearch && h('div', { className: 'msp-search-row' },
h(P.Input, {
ref: searchRef, className: cx('msp-search', query !== '' && 'msp-search-q'), type: 'text', role: 'searchbox',
'aria-label': t('search.placeholder'), 'aria-controls': `${id}-models`,
'aria-activedescendant': activeModelIndex < 0 ? undefined : `${id}-model-${activeModelIndex}`,
placeholder: t('search.placeholder'), value: query, readOnly: busy,
onChange: (e) => changeQuery(e.target.value),
}),
query !== '' && h('button', {
type: 'button', className: 'msp-search-clear', 'aria-label': t('search.clear'), disabled: busy,
onClick: () => { changeQuery(''); searchRef.current?.focus(); },
}, h(IconClose))),
state.status === 'loading' && h('div', { className: 'msp-status' }, t('status.loading')),
loadError,
actionError !== null && h('div', { className: 'msp-error', role: 'alert' }, h('span', null, actionError)),
state.failures.map((f) => h('div', { key: f.id, className: 'msp-warning' },
h('span', null, t('warning.groupLoad', { name: f.id === 'deepseek-account' ? t('provider.account') : f.name, message: f.message })),
h('button', { type: 'button', className: 'msp-retry', onClick: reload }, t('action.reload')))),
h('div', {
id: `${id}-models`, className: 'msp-groups scrollable', role: 'menu', 'aria-label': t('menu.model'),
hidden: pinnedShown.length === 0 && filteredGroups.length === 0,
},
pinnedShown.length > 0 && h('div', { key: 'pinned', 'data-pinned-row': '' },
h(P.MenuGroup, { label: titled(t('pinned'), true) }, pinnedShown.map((c) => renderRow(c, true)))),
filteredGroups.map((g, gi) => h(P.MenuGroup, { key: g.id, label: titled(providerLabel(g), pinnedShown.length === 0 && gi === 0) },
g.models.map((m) => renderRow(choiceByKey.get(pinKey(g.id, m.id)), false))))),
state.status === 'ready' && pinnedShown.length === 0 && filteredGroups.length === 0
&& h('div', { className: 'msp-empty', role: 'status' }, t(choices.length === 0 ? 'empty.models' : 'search.empty')),
pinnedShown.length === 0 && filteredGroups.length === 0 && h('div', { className: 'msp-title-row' }, addButton)),
pane === 'effort' && h(React.Fragment, null,
loadError,
actionError !== null && h('div', { className: 'msp-error', role: 'alert' }, h('span', null, actionError)),
effortChoices.length === 0
? h('div', { className: 'msp-empty', role: 'status' }, t('empty.efforts'))
: h('div', { role: 'menu', 'aria-label': t('menu.effort') }, effortChoices.map((c, i) => h('button', {
key: c.key, ref: (el) => { itemRefs.current[i] = el; },
type: 'button', role: 'menuitemradio', 'aria-checked': c.effort === effectiveEffort,
className: 'msp-effort-option', disabled: busy, onClick: () => chooseEffort(c.effort),
},
h('span', { style: { flex: 1, minWidth: 0 } }, c.label),
h('span', { className: 'msp-check' },
pending !== null && pending.reasoningEffort === c.effort ? h(P.StateDot, { state: 'ongoing' })
: c.effort === effectiveEffort ? h(IconCheck) : null)))))),
document.body);
const busyDot = h(P.StateDot, { state: 'ongoing' });
const chev = (which) => h(IconChevronDown, { className: cx('msp-chevron', pane === which && 'msp-chevron-open') });
return h('div', { ref: rootRef, className: 'msp-root', onKeyDown: onRootKeyDown, onBlur },
h('button', {
ref: modelTriggerRef, type: 'button', className: 'msp-trigger', 'aria-label': modelAria,
'aria-haspopup': 'menu', 'aria-expanded': pane === 'model', 'aria-controls': pane === 'model' ? `${id}-menu` : undefined,
title: modelLabel, 'aria-busy': busy, 'data-selection-focus': selectionFocus ? '' : undefined,
onBlur: () => setSelectionFocus(false), disabled: locked,
onMouseDown: (e) => e.preventDefault(),
onClick: () => toggle('model'),
},
triggerBrand ? h(BrandIcon, { brand: triggerBrand }) : null,
h('span', { className: 'msp-label' }, modelLabel),
busy && !reasoning ? busyDot : chev('model')),
effortLabel !== undefined && h('button', {
ref: effortTriggerRef, type: 'button', className: 'msp-trigger msp-trigger-effort', 'aria-label': effortAria,
'aria-haspopup': 'menu', 'aria-expanded': pane === 'effort', 'aria-controls': pane === 'effort' ? `${id}-menu` : undefined,
title: effortAria, 'aria-busy': busy, 'data-selection-focus': selectionFocus ? '' : undefined,
onBlur: () => setSelectionFocus(false), disabled: locked || reasoning === undefined,
onMouseDown: (e) => e.preventDefault(),
onClick: () => toggle('effort'),
},
h('span', { className: 'msp-effort' }, effortLabel),
busy ? busyDot : chev('effort')),
menu);
}
/* ------------------------------------------------------------------ plugin */
// Open Settings on its Models section. Primary: the settings shell's own store (reached through the
// `sidebar.settings` slot entry); fallback: click the settings trigger and then the Models nav cell.
function openModelSettings(ctx) {
try {
const entry = ctx.slots.entries('sidebar.settings')[0];
const instance = entry?.options?.store?.create?.();
if (instance?.actions?.openSection) { instance.actions.openSection('models'); return; }
} catch { /* fall through to the DOM path */ }
const trigger = document.querySelector('button[aria-haspopup="dialog"][aria-label]');
if (!trigger) return;
trigger.click();
let tries = 0;
const tick = () => {
const dlg = document.querySelector('[role="dialog"][data-shortcut-modal="settings"]');
const cell = dlg && [...dlg.querySelectorAll('nav button')].find((b) => /^(models|模型)$/i.test(b.textContent.trim()));
if (cell) cell.click(); else if (++tries < 20) setTimeout(tick, 50);
};
tick();
}
return {
inject: ['slots', 'locale', 'sessions', 'remote', 'remote.session', 'modelDirectories'],
apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'model-selector-plus: dictionaries');
// Priority -1 shadows the stock occupant (lowest priority renders).
ctx.slots.inject('conversation.input.model', () => ctx.slots.register({
name: 'conversation.input.model',
locale: NS,
priority: -1,
inject: (sessionId) => {
const directory = ctx.modelDirectories.directoryFor(sessionId);
const available = ctx.sessions.subagentAddress(sessionId) === undefined;
return {
available,
directory: directory.store,
load: () => { if (available) directory.load().catch(() => {}); },
select: (selection) => (available ? directory.select(selection) : Promise.resolve(undefined)),
openSettings: () => openModelSettings(ctx),
};
},
}, ModelSelectPlus));
},
};
},
});