const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default'};
const INFLIGHT={}; // keyed by session_id while request in-flight
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
// Tracks which session's queue to drain in setBusy(false).
// Set to activeSid just before setBusy(false) in done/error handlers so the
// queue drains the session that *finished*, not the one currently viewed.
// Single-shot: setBusy() reads and clears this on every call. Concurrent
// back-to-back stream completions would overwrite it, but HTTPServer is
// single-threaded so only one done event fires at a time in practice.
let _queueDrainSid=null;
const $=id=>document.getElementById(id);
// Redirect to /login when the server responds with 401 (auth session expired).
// Handles iOS PWA standalone mode where a server-side 302→/login would break
// out of the PWA shell into Safari instead of navigating within it.
function _redirectIfUnauth(res){if(res&&res.status===401){window.location.href='/login?next='+encodeURIComponent(window.location.pathname+window.location.search);return true;}return false;}
function _getSessionQueue(sid, create=false){
if(!sid) return [];
if(!SESSION_QUEUES[sid]&&create) SESSION_QUEUES[sid]=[];
return SESSION_QUEUES[sid]||[];
}
function queueSessionMessage(sid, payload){
if(!sid||!payload) return 0;
const q=_getSessionQueue(sid,true);
// Stamp created_at so the restore path can detect stale entries (agent already responded)
const entry={...payload, _queued_at: Date.now()};
q.push(entry);
// Persist to sessionStorage so the queue survives page refresh
try{ sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q)); }catch(_){}
return q.length;
}
function shiftQueuedSessionMessage(sid){
const q=_getSessionQueue(sid,false);
if(!q.length) return null;
const next=q.shift();
if(!q.length){
delete SESSION_QUEUES[sid];
try{ sessionStorage.removeItem('hermes-queue-'+sid); }catch(_){}
} else {
try{ sessionStorage.setItem('hermes-queue-'+sid, JSON.stringify(q)); }catch(_){}
}
return next;
}
function getQueuedSessionCount(sid){
return _getSessionQueue(sid,false).length;
}
function _compressionSessionLock(){
return window._compressionLockSid||null;
}
function _setCompressionSessionLock(sid){
window._compressionLockSid=sid||null;
}
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
/**
* Render fenced code blocks inside user messages.
* Extracts ```…``` fences, replaces them with placeholders,
* escapes remaining text as plain HTML, then restores code blocks
* with the same
pipeline used by renderMd().
* All non-fenced text stays escaped (no bold/italic/link interpretation).
*/
function _renderUserFencedBlocks(text){
const stash=[];
let s=String(text||'');
// Extract fenced code blocks → stash, replace with null-token placeholder
// CommonMark line-anchored fence (fixes #1438): inner ``` inside content no longer truncates the block.
s=s.replace(/(^|\n)[ ]{0,3}```([a-zA-Z0-9_+-]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)/g,(_,lead,lang,code)=>{
lang=(lang||'').trim().toLowerCase();
code=code||'';
// Remove one trailing newline if present (the fence consumes its own)
if(code.endsWith('\n')) code=code.slice(0,-1);
const h=lang?``:'';
const langAttr=lang?` class="language-${esc(lang)}"`:'';
if(lang==='diff'||lang==='patch'){
const colored=esc(code).split('\n').map(line=>{
if(line.startsWith('@@')) return `${line} `;
if(line.startsWith('+')) return `${line} `;
if(line.startsWith('-')) return `${line} `;
return `${line} `;
}).join('\n');
stash.push(`${h}${colored} `);
} else {
stash.push(`${h}${esc(code)} `);
}
return lead+'\x00UF'+(stash.length-1)+'\x00';
});
// Escape remaining plain text and convert newlines to
s=esc(s).replace(/\n/g,' ');
// Restore stashed code blocks
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
return s;
}
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
function _openImgLightbox(src, alt) {
const lb = document.createElement('div');
lb.className = 'img-lightbox';
lb.setAttribute('role', 'dialog');
lb.setAttribute('aria-label', alt || 'Image');
const img = document.createElement('img');
img.src = src;
img.alt = alt || '';
img.onclick = e => e.stopPropagation();
const cls = document.createElement('button');
cls.className = 'img-lightbox-close';
cls.setAttribute('aria-label', 'Close');
cls.textContent = '×';
cls.onclick = () => _closeImgLightbox(lb);
lb.appendChild(img);
lb.appendChild(cls);
lb.onclick = () => _closeImgLightbox(lb);
document.body.appendChild(lb);
// Close on Escape
lb._escHandler = e => { if(e.key==='Escape') _closeImgLightbox(lb); };
document.addEventListener('keydown', lb._escHandler);
}
function _closeImgLightbox(lb) {
if(!lb || !lb.parentNode) return;
document.removeEventListener('keydown', lb._escHandler);
lb.style.animation = 'lb-in .12s ease reverse';
setTimeout(() => lb.parentNode && lb.parentNode.removeChild(lb), 120);
}
document.addEventListener('click', e => {
const img = e.target && e.target.closest ? e.target.closest('.msg-media-img') : null;
if(!img) return;
_openImgLightbox(img.src, img.alt);
});
const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i;
const _PDF_EXTS=/\.pdf$/i;
const _HTML_EXTS=/\.(html?|htm)$/i;
const _ARCHIVE_EXTS=/\.(zip|tar|tar\.gz|tgz|tar\.bz2|tbz2|tar\.xz|txz)$/i;
const _SVG_EXTS=/\.svg$/i;
const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm|oga)$/i;
const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i;
const _CSV_EXTS=/\.csv$/i;
const _EXCALIDRAW_EXTS=/\.excalidraw$/i;
// ── Media playback speed controls ─────────────────────────────────────────
const MEDIA_PLAYBACK_RATES=[0.5,0.75,1,1.25,1.5,2];
const MEDIA_PLAYBACK_STORAGE_KEY='hermes-media-playback-rate';
function _getStoredMediaPlaybackRate(){
try{
const raw=localStorage.getItem(MEDIA_PLAYBACK_STORAGE_KEY);
const rate=Number(raw);
return MEDIA_PLAYBACK_RATES.includes(rate)?rate:1;
}catch(_){return 1;}
}
function _setStoredMediaPlaybackRate(rate){
if(!MEDIA_PLAYBACK_RATES.includes(rate)) return;
try{localStorage.setItem(MEDIA_PLAYBACK_STORAGE_KEY,String(rate));}catch(_){}
}
function _syncMediaSpeedButtons(editor, rate){
if(!editor) return;
editor.querySelectorAll('.media-speed-btn').forEach(b=>{
const active=Number(b.dataset.rate)===rate;
b.classList.toggle('active',active);
b.setAttribute('aria-pressed',active?'true':'false');
});
}
function _applyMediaPlaybackRate(media, rate=_getStoredMediaPlaybackRate()){
if(!media) return;
media.playbackRate=rate;
_syncMediaSpeedButtons(media.closest('.msg-media-editor,.preview-media-wrap'),rate);
}
function _mediaKindForName(name=''){
const clean=String(name||'').split('?')[0].toLowerCase();
if(_AUDIO_EXTS.test(clean)) return 'audio';
if(_VIDEO_EXTS.test(clean)) return 'video';
if(_IMAGE_EXTS.test(clean)) return 'image';
return '';
}
function _mediaSpeedControlsHtml(kind, label){
const safeLabel=esc(label||kind||'media');
const current=_getStoredMediaPlaybackRate();
return `${MEDIA_PLAYBACK_RATES.map(rate=>`${rate}× `).join('')}
`;
}
function _mediaPlayerHtml(kind, src, name, extra=''){
const safeName=esc(name||'media');
const safeSrc=esc(src);
const tag=kind==='video'
? ` `
: ` `;
return ``;
}
function _renderAttachmentHtml(fname, url){
const kind=_mediaKindForName(fname);
if(kind==='image') return ` `;
if(kind==='audio'||kind==='video') return _mediaPlayerHtml(kind,url,fname);
return `${li('paperclip',12)} ${esc(fname)}
`;
}
document.addEventListener('click', e => {
const btn=e.target&&e.target.closest?e.target.closest('.media-speed-btn'):null;
if(!btn) return;
const editor=btn.closest('.msg-media-editor,.preview-media-wrap');
if(!editor) return;
const media=editor.querySelector('audio,video');
if(!media) return;
const rate=Number(btn.dataset.rate)||1;
_setStoredMediaPlaybackRate(rate);
_applyMediaPlaybackRate(media,rate);
});
document.addEventListener("loadedmetadata", e=>{
if(e.target&&e.target.matches&&e.target.matches('.msg-media-player,audio,video')){
_applyMediaPlaybackRate(e.target);
}
},true);
function _initMediaPlaybackObserver(){
if(!document.body||window._mediaPlaybackObserver) return;
window._mediaPlaybackObserver=new MutationObserver(records=>{
for(const rec of records){
for(const node of rec.addedNodes||[]){
if(!node||node.nodeType!==1) continue;
const media=[];
if(node.matches&&node.matches('audio,video')) media.push(node);
if(node.querySelectorAll) media.push(...node.querySelectorAll('audio,video'));
media.forEach(m=>_applyMediaPlaybackRate(m));
}
}
});
window._mediaPlaybackObserver.observe(document.body,{childList:true,subtree:true});
document.querySelectorAll('audio,video').forEach(m=>_applyMediaPlaybackRate(m));
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',_initMediaPlaybackObserver);
else _initMediaPlaybackObserver();
setTimeout(_initMediaPlaybackObserver,0);
// Dynamic model labels -- populated by populateModelDropdown(), fallback to static map
let _dynamicModelLabels={};
window._configuredModelBadges=window._configuredModelBadges||{};
const MODEL_STATE_KEY='hermes-webui-model-state';
// ── Smart model resolver ────────────────────────────────────────────────────
// Finds the best matching option value in a for a given model ID.
// Handles mismatches like 'claude-sonnet-4-6' vs 'anthropic/claude-sonnet-4.6'.
// When a preferred provider is supplied, duplicate normalized IDs prefer that
// provider's option so Settings/profile rehydration doesn't snap back to the
// first colliding entry.
function _getOptionProviderId(opt){
if(!opt) return '';
const group=opt.parentElement;
if(group && group.tagName==='OPTGROUP' && group.dataset && group.dataset.provider){
return group.dataset.provider;
}
const value=String(opt.value||'');
if(value.startsWith('@') && value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
return '';
}
function _providerFromModelValue(modelId){
const value=String(modelId||'').trim();
if(value.startsWith('@')&&value.includes(':')) return value.slice(1,value.lastIndexOf(':'));
return '';
}
function _modelStateForSelect(sel, modelId){
const value=String(modelId||'').trim();
if(!value) return {model:'',model_provider:null};
const explicitProvider=_providerFromModelValue(value);
if(explicitProvider) return {model:value,model_provider:explicitProvider};
const opt=sel&&sel.selectedOptions&&sel.selectedOptions[0];
const provider=String(_getOptionProviderId(opt)||'').trim();
return {model:value,model_provider:(provider&&provider!=='default')?provider:null};
}
function _providerQualifiedModelValueForSelect(sel, modelId){
return _modelStateForSelect(sel,modelId).model;
}
function _readPersistedModelState(){
try{
const raw=localStorage.getItem(MODEL_STATE_KEY);
if(raw){
const parsed=JSON.parse(raw);
if(parsed&&parsed.model){
return {
model:String(parsed.model||''),
model_provider:parsed.model_provider?String(parsed.model_provider):(_providerFromModelValue(parsed.model)||null),
};
}
}
}catch(_){}
const legacy=localStorage.getItem('hermes-webui-model');
if(!legacy) return null;
return {model:legacy,model_provider:_providerFromModelValue(legacy)||null};
}
function _writePersistedModelState(model, modelProvider){
const value=String(model||'').trim();
const provider=modelProvider?String(modelProvider).trim():(_providerFromModelValue(value)||null);
if(!value){
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem(MODEL_STATE_KEY);
return;
}
localStorage.setItem('hermes-webui-model', value);
try{
localStorage.setItem(MODEL_STATE_KEY, JSON.stringify({model:value,model_provider:provider||null}));
}catch(_){}
}
function _clearPersistedModelState(){
localStorage.removeItem('hermes-webui-model');
localStorage.removeItem(MODEL_STATE_KEY);
}
function _findModelInDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const options=Array.from(sel.options);
const opts=options.map(o=>o.value);
// 1. Normalize: lowercase, strip namespace prefix, replace hyphens→dots.
// Also strip @provider: prefix from deduplicated model IDs (#1228, #1313).
const norm=s=>s.toLowerCase().replace(/^[^/]+\//,'').replace(/^@([^:]+:)+/,'').replace(/-/g,'.');
const target=norm(modelId);
let explicitProvider='';
const rawModel=String(modelId||'');
if(rawModel.startsWith('@')&&rawModel.includes(':')){
explicitProvider=rawModel.slice(1,rawModel.lastIndexOf(':'));
}
const preferred=String(preferredProviderId||explicitProvider||'').toLowerCase();
if(preferred){
const providerMatch=options.find(o=>norm(o.value)===target && _getOptionProviderId(o).toLowerCase()===preferred);
if(providerMatch) return providerMatch.value;
}
// 2. Exact match
if(opts.includes(modelId)) return modelId;
const exact=opts.find(o=>norm(o)===target);
if(exact) return exact;
// 3. Prefix/substring: require the candidate to start with the FULL normalized target
// (not a truncated base). This avoids false matches like gpt.5.5 → gpt.5.4.mini (#1188).
// Only fall back to the shorter base form if target itself is very short (a bare root
// like "gpt" or "claude") where stripping would be a no-op anyway.
const base=target.replace(/\.\d+$/,''); // strip trailing version number
const useBase=base.length<=4||base===target; // bare root — stripping changed nothing meaningful
const prefixTarget=useBase?base:target;
const partial=opts.find(o=>norm(o).startsWith(prefixTarget));
return partial||null;
}
// Set the model picker to the best match for modelId.
// Returns the resolved value that was actually set, or null if nothing matched.
function _applyModelToDropdown(modelId, sel, preferredProviderId){
if(!modelId||!sel) return null;
const resolved=_findModelInDropdown(modelId,sel,preferredProviderId);
if(resolved){
sel.value=resolved;
if(sel.id==='modelSelect' && typeof syncModelChip==='function') syncModelChip();
return resolved;
}
return null;
}
async function populateModelDropdown(){
const sel=$('modelSelect');
if(!sel) return;
try{
const _modelsRes=await fetch(new URL('api/models',document.baseURI||location.href).href,{credentials:'include'});
if(_redirectIfUnauth(_modelsRes)) return;
const data=await _modelsRes.json();
if(!data.groups||!data.groups.length) return; // keep HTML defaults
// Store active provider globally so the send path can warn on mismatch
window._activeProvider=data.active_provider||null;
// Store default model so newSession() can apply it (#872).
// Per-page-load — not synced across browser tabs.
window._defaultModel=data.default_model||null;
window._configuredModelBadges=data.configured_model_badges||{};
// Clear existing options
sel.innerHTML='';
_dynamicModelLabels={};
for(const g of data.groups){
const og=document.createElement('optgroup');
og.label=g.provider;
if(g.provider_id) og.dataset.provider=g.provider_id;
for(const m of g.models){
const opt=document.createElement('option');
opt.value=m.id;
opt.textContent=m.label;
og.appendChild(opt);
_dynamicModelLabels[m.id]=m.label;
}
sel.appendChild(og);
}
// Set default model from server if no localStorage preference
if(data.default_model && !(typeof _readPersistedModelState==='function'&&_readPersistedModelState()) && !localStorage.getItem('hermes-webui-model')){
_applyModelToDropdown(data.default_model, sel, data.active_provider||null);
}
if(typeof syncModelChip==='function') syncModelChip();
// Kick off a background live-model fetch for the active provider.
// This runs after the static list is already shown (no blocking flicker).
if(data.active_provider) _fetchLiveModels(data.active_provider, sel);
}catch(e){
// API unavailable -- keep the hardcoded HTML options as fallback
console.warn('Failed to load models from server:',e.message);
if(typeof syncModelChip==='function') syncModelChip();
}
}
// Cache so we don't re-fetch on every page load
const _liveModelCache={};
// Tracks providers for which a live-model fetch is in flight.
// Used by syncTopbar() to defer model corrections until the fetch completes,
// preventing premature fallback to the first static model (#1169).
const _liveModelFetchPending=new Set();
function _addLiveModelsToSelect(provider, models, sel){
if(!provider||!models||!models.length||!sel) return 0;
const currentVal=sel.value;
let providerGroup=null;
for(const og of sel.querySelectorAll('optgroup')){
if(og.dataset.provider&&og.dataset.provider===provider){
providerGroup=og; break;
}
if(og.label&&og.label.toLowerCase().includes(provider.toLowerCase())){
providerGroup=og; break;
}
}
if(!providerGroup){
providerGroup=document.createElement('optgroup');
providerGroup.label=provider.charAt(0).toUpperCase()+provider.slice(1)+' (live)';
sel.appendChild(providerGroup);
}
const existingIds=new Set([...sel.options].map(o=>o.value));
// Normalized dedup: strip @provider: prefix and unify separators so
// 'minimax/minimax-m2.7' matches '@nous:minimax/minimax-m2.7' (#907).
// Strip ONLY the first colon — Ollama tag IDs are multi-colon
// (e.g. '@ollama-cloud:qwen3-vl:235b-instruct') and split(':',2) would
// truncate the tag suffix in JS (the limit arg discards extras, unlike Python).
const _normId=id=>{
let s=String(id||'');
if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1); // strip only @provider:
s=s.split('/').pop(); // strip namespace prefix
return s.replace(/-/g,'.').toLowerCase();
};
const existingNorm=new Set([...sel.options].map(o=>_normId(o.value)));
let added=0;
const _ap=(window._activeProvider||'').toLowerCase();
const _isPortalFetch=_ap && _ap!=='openrouter' && _ap!=='custom' && _ap!=='openai-codex' && provider===_ap;
for(const m of models){
let mid=m.id;
if(_isPortalFetch && !mid.startsWith('@')){
mid=`@${provider}:${mid}`;
}
if(existingIds.has(mid)) continue;
if(existingNorm.has(_normId(mid))) continue; // dedup cross-prefix duplicates (#907)
const opt=document.createElement('option');
opt.value=mid;
opt.textContent=m.label||m.id;
opt.title='Live model — fetched from provider';
providerGroup.appendChild(opt);
_dynamicModelLabels[mid]=m.label||m.id;
added++;
}
const currentProvider=(S.session&&S.session.model_provider)||null;
if(added>0 && currentVal) _applyModelToDropdown(currentVal, sel, currentProvider);
// After live models are added, re-apply the session's model in case it was
// absent from the static list and syncTopbar() fired before the live fetch
// completed (#1169). This ensures the session model wins over any premature
// fallback that may have set sel.value to the first available option.
if(S.session && S.session.model && sel.id==='modelSelect'){
const reapplied=_applyModelToDropdown(S.session.model, sel, S.session.model_provider||null);
if(reapplied && typeof syncModelChip==='function') syncModelChip();
}
return added;
}
async function _fetchLiveModels(provider, sel){
if(!provider||!sel) return;
// Already fetched — apply cached models to this select element (#872)
if(_liveModelCache[provider]){
const added=_addLiveModelsToSelect(provider,_liveModelCache[provider],sel);
if(added>0 && typeof syncModelChip==='function') syncModelChip();
return;
}
_liveModelFetchPending.add(provider);
try{
const url=new URL('api/models/live',document.baseURI||location.href);
url.searchParams.set('provider',provider);
const _liveRes=await fetch(url.href,{credentials:'include'});
if(_redirectIfUnauth(_liveRes)) return;
const data=await _liveRes.json();
if(!data.models||!data.models.length) return;
_liveModelCache[provider]=data.models;
const added=_addLiveModelsToSelect(provider,data.models,sel);
if(added>0){
if(typeof syncModelChip==='function') syncModelChip();
console.log('[hermes] Live models loaded for',provider+':',added,'new models added');
}
}catch(e){
console.debug('[hermes] Live model fetch failed for',provider,e.message);
}finally{
_liveModelFetchPending.delete(provider);
}
}
/**
* Check if the given model ID belongs to a different provider than the one
* currently configured in Hermes. Returns a warning string if mismatched,
* or null if the selection looks compatible.
*
* Provider detection is intentionally loose — we compare the model's slash
* prefix (e.g. "openai/" from "openai/gpt-4o") against the active provider
* name. Custom/local endpoints report active_provider='custom' or the
* base_url hostname and we skip the check to avoid false positives.
*/
function _checkProviderMismatch(modelId){
const ap=(window._activeProvider||'').toLowerCase();
if(!ap||ap==='custom'||ap==='openrouter') return null; // can't reliably check
// @provider: prefixed IDs came from that provider's live model list — no mismatch possible
if(modelId.startsWith('@')) return null;
const slash=modelId.indexOf('/');
if(slash<0) return null; // bare model name, no provider prefix
const modelProvider=modelId.substring(0,slash).toLowerCase();
// Normalise common aliases
const aliases={'claude':'anthropic','gpt':'openai','gemini':'google'};
const norm=p=>aliases[p]||p;
if(norm(modelProvider)!==norm(ap)){
return (window.t?window.t('provider_mismatch_warning',modelId,ap):
`"${modelId}" may not work with your configured provider (${ap}). Send anyway or run \`hermes model\` to switch.`);
}
return null;
}
function _selectedModelOption(){
const sel=$('modelSelect');
if(!sel) return null;
return sel.options[sel.selectedIndex]||null;
}
function _normalizeConfiguredModelKey(modelId){
let s=String(modelId||'').trim().toLowerCase();
if(s.startsWith('@')&&s.includes(':')) s=s.substring(s.indexOf(':')+1);
if(s.includes('/')) s=s.split('/').pop();
return s.replace(/-/g,'.');
}
function _getConfiguredModelBadge(modelId,badgeMap,providerId){
const map=badgeMap||window._configuredModelBadges||{};
if(!modelId||!map) return null;
const provider=String(providerId||'').toLowerCase();
const exact=map[modelId];
if(exact && (!provider || !exact.provider || String(exact.provider).toLowerCase()===provider)) return exact;
const targetNorm=_normalizeConfiguredModelKey(modelId);
const matches=[];
for(const [candidate,badge] of Object.entries(map)){
if(_normalizeConfiguredModelKey(candidate)===targetNorm) matches.push(badge);
}
if(!matches.length) return null;
if(provider){
const providerMatch=matches.find(badge=>String(badge&&badge.provider||'').toLowerCase()===provider);
if(providerMatch) return providerMatch;
return matches.length===1 ? matches[0] : null;
}
return matches[0];
}
function syncModelChip(){
const sel=$('modelSelect');
const chip=$('composerModelChip');
const label=$('composerModelLabel');
const mobileLabel=$('composerMobileModelLabel');
const mobileAction=$('composerMobileModelAction');
const dd=$('composerModelDropdown');
if(!sel||!chip||!label) return;
// Don't show a model label until boot has finished loading to prevent flash of wrong default
if(!S._bootReady){
label.textContent='';
if(mobileLabel) mobileLabel.textContent='';
chip.title='Conversation model';
return;
}
const opt=_selectedModelOption();
const text=opt?opt.textContent:getModelLabel(sel.value||'');
label.textContent=text;
if(mobileLabel) mobileLabel.textContent=text;
chip.title=sel.value||'Conversation model';
chip.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
if(mobileAction) mobileAction.classList.toggle('active',!!(dd&&dd.classList.contains('open')));
}
function _positionModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const mobileAction=$('composerMobileModelAction');
const footer=document.querySelector('.composer-footer');
if(!dd||!footer) return;
const panel=$('composerMobileConfigPanel');
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:(chip&&chip.offsetParent?chip:mobileAction);
if(!anchor) return;
const chipRect=anchor.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0, footer.clientWidth-dd.offsetWidth);
left=Math.max(0, Math.min(left, maxLeft));
dd.style.left=`${left}px`;
}
function renderModelDropdown(){
const dd=$('composerModelDropdown');
const sel=$('modelSelect');
if(!dd||!sel) return;
// Store model data for filtering
const _modelData=[];
const _badgeMap=window._configuredModelBadges||{};
for(const child of Array.from(sel.children)){
if(child.tagName==='OPTGROUP'){
const providerId=child.dataset&&child.dataset.provider?child.dataset.provider:'';
for(const opt of Array.from(child.children)){
_modelData.push({value:opt.value,name:esc(opt.textContent||getModelLabel(opt.value)),id:esc(opt.value),group:child.label||'',badge:_getConfiguredModelBadge(opt.value,_badgeMap,providerId)});
}
}
if(child.tagName==='OPTION'){
_modelData.push({value:child.value,name:esc(child.textContent||getModelLabel(child.value)),id:esc(child.value),group:'',badge:_getConfiguredModelBadge(child.value,_badgeMap)});
}
}
const _existingConfiguredKeys=new Set(_modelData.map(existing=>_normalizeConfiguredModelKey(existing.value)));
for(const [modelId,badge] of Object.entries(_badgeMap)){
if(_existingConfiguredKeys.has(_normalizeConfiguredModelKey(modelId))) continue;
_modelData.push({
value:modelId,
name:esc(getModelLabel(modelId)),
id:esc(modelId),
group:'',
badge,
});
_existingConfiguredKeys.add(_normalizeConfiguredModelKey(modelId));
}
// Create search input FIRST before filterModels definition
const _scopeNote=document.createElement('div');
_scopeNote.className='model-scope-note';
_scopeNote.textContent=t('model_scope_advisory')||'Applies to this conversation from your next message.';
const _searchRow=document.createElement('div');
_searchRow.className='model-search-row';
_searchRow.innerHTML=`${li('x',10)} `;
const _si=_searchRow.querySelector('.model-search-input');
const _sc=_searchRow.querySelector('.model-search-clear');
// Create custom model section elements
const _custSep=document.createElement('div');
_custSep.className='model-group model-custom-sep';
_custSep.textContent=t('model_custom_label')||'Custom model ID';
const _custRow=document.createElement('div');
_custRow.className='model-custom-row';
_custRow.innerHTML=`${li('plus',12)} `;
const _ci=_custRow.querySelector('.model-custom-input');
const _cb=_custRow.querySelector('.model-custom-btn');
const _configuredRank=(badge)=>{
if(!badge) return Number.POSITIVE_INFINITY;
if(badge.role==='primary') return 0;
if(badge.role==='fallback'){
const m=String(badge.label||'').match(/fallback\s+(\d+)/i);
return m?Number(m[1]):999;
}
return 500;
};
// Filter function (defined AFTER _searchRow and _cust* are created)
const _filterModels=(term)=>{
term=term.trim().toLowerCase();
const found=new Set();
for(const m of _modelData){
const name=m.name.toLowerCase();
const id=m.id.toLowerCase();
if(name.includes(term)||id.includes(term)){
found.add(m.value);
}
}
const matches=(m)=>!term||found.has(m.value);
const configuredModels=_modelData
.filter(m=>m.badge&&matches(m))
.sort((a,b)=>{
const configuredRankA=_configuredRank(a.badge);
const configuredRankB=_configuredRank(b.badge);
if(configuredRankA!==configuredRankB) return configuredRankA-configuredRankB;
return a.name.localeCompare(b.name);
});
const configuredIds=new Set(configuredModels.map(m=>m.value));
// Clear and rebuild
dd.innerHTML='';
// Add search and custom elements first (CRITICAL: must be before models)
dd.appendChild(_scopeNote);
dd.appendChild(_searchRow);
dd.appendChild(_custSep);
dd.appendChild(_custRow);
if(configuredModels.length){
const configuredHeading=document.createElement('div');
configuredHeading.className='model-group';
configuredHeading.textContent=t('model_group_configured')||'Configured';
dd.appendChild(configuredHeading);
for(const m of configuredModels){
const row=document.createElement('div');
row.className='model-opt'+(m.value===sel.value?' active':'');
const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')} `:'';
row.innerHTML=`${m.name} ${badgeHtml}
${m.id} `;
row.onclick=()=>selectModelFromDropdown(m.value);
dd.appendChild(row);
}
}
// Add remaining models matching filter
let _lastGroup=null;
for(const m of _modelData){
if(configuredIds.has(m.value)||!matches(m)) continue;
if(m.group&&m.group!==_lastGroup){
const heading=document.createElement('div');
heading.className='model-group';
heading.textContent=m.group;
dd.appendChild(heading);
_lastGroup=m.group;
}
const row=document.createElement('div');
row.className='model-opt'+(m.value===sel.value?' active':'');
const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')} `:'';
row.innerHTML=`${m.name} ${badgeHtml}
${m.id} `;
row.onclick=()=>selectModelFromDropdown(m.value);
dd.appendChild(row);
}
// Show "No results" if filtered and nothing matched
if(term&&found.size===0){
const noResult=document.createElement('div');
noResult.className='model-search-no-results';
noResult.textContent=t('model_search_no_results')||'No models found';
noResult.style.padding='12px 14px';
noResult.style.color='var(--muted)';
noResult.style.textAlign='center';
dd.appendChild(noResult);
}
// Restore focus to search input
_si.focus();
};
// Event handlers for search input
_si.addEventListener('input',()=>_filterModels(_si.value));
_si.addEventListener('keydown',e=>{if(e.key==='Enter') {e.preventDefault();}if(e.key==='Escape') {closeModelDropdown();}});
_si.addEventListener('click',e=>e.stopPropagation());
// Event handlers for clear button
_sc.onclick=()=>{ _si.value=''; _filterModels(''); _si.focus(); };
_sc.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){ _si.value=''; _filterModels(''); _si.focus(); e.preventDefault(); }});
// Event handlers for custom input
const _applyCustom=()=>{const v=_ci.value.trim();if(!v)return;selectModelFromDropdown(v);_ci.value='';};
_cb.onclick=_applyCustom;
_ci.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();_applyCustom();}if(e.key==='Escape'){closeModelDropdown();}});
_ci.addEventListener('click',e=>e.stopPropagation());
// Add search and custom elements to dropdown (initial render)
dd.appendChild(_scopeNote);
dd.appendChild(_searchRow);
dd.appendChild(_custSep);
dd.appendChild(_custRow);
// Apply initial filter (empty shows all)
_filterModels('');
}
async function selectModelFromDropdown(value){
const sel=$('modelSelect');
if(!sel||sel.value===value) { closeModelDropdown(); return; }
// If the value isn't in the option list (custom model ID), add a temporary option
// so sel.value assignment succeeds and the model chip shows the custom ID.
if(!Array.from(sel.options).some(o=>o.value===value)){
const opt=document.createElement('option');
opt.value=value;
opt.textContent=getModelLabel(value);
opt.dataset.custom='1';
// Remove any previous custom option before adding new one
sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove());
sel.appendChild(opt);
}
sel.value=value;
syncModelChip();
closeModelDropdown();
if(typeof sel.onchange==='function') await sel.onchange();
}
function toggleModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const sel=$('modelSelect');
if(!dd||!chip||!sel) return;
const open=dd.classList.contains('open');
if(open){closeModelDropdown(); return;}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
if(typeof closeReasoningDropdown==='function') closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
renderModelDropdown();
dd.classList.add('open');
_positionModelDropdown();
chip.classList.add('active');
const mobileAction=$('composerMobileModelAction');
if(mobileAction) mobileAction.classList.add('active');
}
function closeModelDropdown(){
const dd=$('composerModelDropdown');
const chip=$('composerModelChip');
const mobileAction=$('composerMobileModelAction');
if(dd) dd.classList.remove('open');
if(chip) chip.classList.remove('active');
if(mobileAction) mobileAction.classList.remove('active');
}
document.addEventListener('click',e=>{
if(
!e.target.closest('#composerModelChip') &&
!e.target.closest('#composerMobileModelAction') &&
!e.target.closest('#composerModelDropdown')
) closeModelDropdown();
});
window.addEventListener('resize',()=>{
const dd=$('composerModelDropdown');
if(dd&&dd.classList.contains('open')) _positionModelDropdown();
// Keep the reasoning dropdown aligned under its chip when the window
// resizes while open — same pattern as the model dropdown above.
const rdd=$('composerReasoningDropdown');
if(rdd&&rdd.classList.contains('open')&&typeof _positionReasoningDropdown==='function'){
_positionReasoningDropdown();
}
});
// ── Reasoning effort chip ────────────────────────────────────────────────────
let _currentReasoningEffort=null;
function _normalizeReasoningEffort(eff){
return String(eff||'').trim().toLowerCase();
}
function _formatReasoningEffortLabel(effort){
if(effort==='none') return 'None';
if(!effort) return 'Default';
return effort;
}
function _applyReasoningChip(eff){
const effort=_normalizeReasoningEffort(eff);
_currentReasoningEffort=effort;
const wrap=$('composerReasoningWrap');
const label=$('composerReasoningLabel');
const chip=$('composerReasoningChip');
const mobileLabel=$('composerMobileReasoningLabel');
const mobileAction=$('composerMobileReasoningAction');
if(!wrap||!label) return;
wrap.style.display='';
if(mobileAction) mobileAction.style.display='';
const text=_formatReasoningEffortLabel(effort);
label.textContent=text;
if(mobileLabel) mobileLabel.textContent=text;
if(chip){
const inactive=!effort||effort==='none';
chip.classList.toggle('inactive',inactive);
chip.title='Reasoning effort: '+text;
}
if(mobileAction) mobileAction.classList.toggle('inactive',!effort||effort==='none');
_highlightReasoningOption(effort);
}
function fetchReasoningChip(){
api('/api/reasoning').then(function(st){
_applyReasoningChip((st&&st.reasoning_effort)||'');
}).catch(function(){_applyReasoningChip('');});
}
function syncReasoningChip(){
if(_currentReasoningEffort===null){fetchReasoningChip();return;}
_applyReasoningChip(_currentReasoningEffort);
}
function _highlightReasoningOption(effort){
const dd=$('composerReasoningDropdown');
if(!dd) return;
dd.querySelectorAll('.reasoning-option').forEach(function(opt){
opt.classList.toggle('selected',opt.dataset.effort===effort);
});
}
function toggleReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
if(!dd||!chip) return;
const open=dd.classList.contains('open');
if(open){closeReasoningDropdown();return;}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
_highlightReasoningOption(_currentReasoningEffort);
dd.classList.add('open');
_positionReasoningDropdown();
chip.classList.add('active');
const mobileAction=$('composerMobileReasoningAction');
if(mobileAction) mobileAction.classList.add('active');
}
function _positionReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
const mobileAction=$('composerMobileReasoningAction');
const footer=document.querySelector('.composer-footer');
if(!dd||!chip||!footer) return;
const panel=$('composerMobileConfigPanel');
const anchor=(panel&&panel.classList.contains('open')&&mobileAction)?mobileAction:chip;
const chipRect=anchor.getBoundingClientRect();
const footerRect=footer.getBoundingClientRect();
let left=chipRect.left-footerRect.left;
const maxLeft=Math.max(0,footer.clientWidth-dd.offsetWidth);
left=Math.max(0,Math.min(left,maxLeft));
dd.style.left=`${left}px`;
}
function closeReasoningDropdown(){
const dd=$('composerReasoningDropdown');
const chip=$('composerReasoningChip');
const mobileAction=$('composerMobileReasoningAction');
if(dd) dd.classList.remove('open');
if(chip) chip.classList.remove('active');
if(mobileAction) mobileAction.classList.remove('active');
}
document.addEventListener('click',function(e){
if(
!e.target.closest('#composerReasoningChip') &&
!e.target.closest('#composerMobileReasoningAction') &&
!e.target.closest('#composerReasoningDropdown')
) closeReasoningDropdown();
if(e.target.closest('.reasoning-option')){
const opt=e.target.closest('.reasoning-option');
const effort=opt&&opt.dataset.effort;
if(effort){
api('/api/reasoning',{method:'POST',body:JSON.stringify({effort:effort})})
.then(function(st){
_applyReasoningChip((st&&st.reasoning_effort)||effort);
showToast('🧠 Reasoning effort set to '+((st&&st.reasoning_effort)||effort));
})
.catch(function(){showToast('🧠 Failed to set effort');});
closeReasoningDropdown();
}
}
});
// ── Session toolsets chip (#493) ───────────────────────────────────────────
let _currentSessionToolsets = null; // null = global, array = custom list
function _applyToolsetsChip(toolsets) {
_currentSessionToolsets = toolsets;
const wrap = $('composerToolsetsWrap');
const label = $('composerToolsetsLabel');
const chip = $('composerToolsetsChip');
if (!wrap || !label) return;
// Visibility is controlled entirely by responsive CSS — the chip shows only
// at wide composer-footer widths (>= 1100px container query). At narrower
// widths the layout is too cramped (model + reasoning + profile + workspace
// + context-ring + send) to add another chip. Cleared inline style so the
// CSS @container query is the single source of truth. State is still
// tracked so /api/session/toolsets continues to work for cron/scripted
// callers regardless of UI visibility. (#1431)
wrap.style.display = '';
const hasCustom = Array.isArray(toolsets) && toolsets.length > 0;
if (hasCustom) {
label.textContent = toolsets.join(', ');
chip.classList.add('has-custom');
chip.title = t('session_toolsets') + ': ' + toolsets.join(', ');
} else {
label.textContent = t('session_toolsets_global');
chip.classList.remove('has-custom');
chip.title = t('session_toolsets');
}
}
function _syncToolsetsChip() {
if (typeof S === 'undefined' || !S || !S.session) {
_applyToolsetsChip(null);
return;
}
_applyToolsetsChip(S.session.enabled_toolsets || null);
}
function syncToolsetsChip() {
_syncToolsetsChip();
}
function _populateToolsetsDropdown() {
const desc = $('toolsetsDropdownDesc');
const state = $('toolsetsDropdownState');
const input = $('toolsetsInput');
const applyBtn = $('toolsetsApplyBtn');
const clearBtn = $('toolsetsClearBtn');
if (!desc || !state || !input) return;
desc.textContent = t('session_toolsets_desc');
if (applyBtn) applyBtn.textContent = t('session_toolsets_apply');
if (clearBtn) clearBtn.textContent = t('session_toolsets_clear');
input.placeholder = t('session_toolsets_placeholder');
// Escape key handler for toolsets input
input.onkeydown = function(e) { if(e.key === 'Escape') closeToolsetsDropdown(); };
const hasCustom = Array.isArray(_currentSessionToolsets) && _currentSessionToolsets.length > 0;
if (hasCustom) {
state.textContent = '🔧 ' + _currentSessionToolsets.join(', ');
input.value = _currentSessionToolsets.join(', ');
} else {
state.textContent = '🌍 ' + t('session_toolsets_global');
input.value = '';
}
}
function _positionToolsetsDropdown() {
const dd = $('composerToolsetsDropdown');
const chip = $('composerToolsetsChip');
const footer = document.querySelector('.composer-footer');
if (!dd || !chip || !footer) return;
// Defense: if the chip has been hidden by responsive CSS (e.g. resize across
// 1100px container threshold while dropdown was open), don't try to anchor
// to a zero-rect element — close the dropdown instead. (#1431)
if (chip.offsetParent === null) { closeToolsetsDropdown(); return; }
const chipRect = chip.getBoundingClientRect();
const footerRect = footer.getBoundingClientRect();
let left = chipRect.left - footerRect.left;
const maxLeft = Math.max(0, footer.clientWidth - dd.offsetWidth);
left = Math.max(0, Math.min(left, maxLeft));
dd.style.left = left + 'px';
}
function toggleToolsetsDropdown() {
const dd = $('composerToolsetsDropdown');
const chip = $('composerToolsetsChip');
if (!dd || !chip) return;
if (typeof S === 'undefined' || !S || !S.session) return;
// Don't open when the chip itself is hidden by responsive CSS (#1431).
// offsetParent === null catches display:none on the element or any ancestor.
if (chip.offsetParent === null) return;
const open = dd.classList.contains('open');
if (open) { closeToolsetsDropdown(); return; }
if (typeof closeProfileDropdown === 'function') closeProfileDropdown();
if (typeof closeWsDropdown === 'function') closeWsDropdown();
closeModelDropdown();
if (typeof closeReasoningDropdown === 'function') closeReasoningDropdown();
_syncToolsetsChip();
_populateToolsetsDropdown();
dd.classList.add('open');
_positionToolsetsDropdown();
chip.classList.add('active');
// Focus the input after a tick so the layout has settled
setTimeout(() => { const inp = $('toolsetsInput'); if (inp) inp.focus(); }, 50);
}
function closeToolsetsDropdown() {
const dd = $('composerToolsetsDropdown');
const chip = $('composerToolsetsChip');
if (dd) dd.classList.remove('open');
if (chip) chip.classList.remove('active');
}
function _applySessionToolsets(toolsets) {
if (typeof S === 'undefined' || !S || !S.session) return;
const sid = S.session.session_id;
api('/api/session/toolsets', {
method: 'POST',
body: JSON.stringify({ session_id: sid, toolsets: toolsets })
})
.then(function(r) {
if (r && r.ok) {
S.session.enabled_toolsets = r.enabled_toolsets || null;
_applyToolsetsChip(r.enabled_toolsets || null);
if (r.enabled_toolsets && r.enabled_toolsets.length) {
showToast('🔧 ' + t('session_toolsets_applied') + ': ' + r.enabled_toolsets.join(', '));
} else {
showToast('🌍 ' + t('session_toolsets_cleared'));
}
} else {
showToast(t('session_toolsets_failed') + (r && r.error ? r.error : 'Unknown error'), 3000, 'error');
}
})
.catch(function(err) {
showToast(t('session_toolsets_failed') + (err.message || err), 3000, 'error');
});
}
// Click-outside handler for toolsets dropdown
document.addEventListener('click', function(e) {
if (
!e.target.closest('#composerToolsetsChip') &&
!e.target.closest('#composerToolsetsDropdown')
) closeToolsetsDropdown();
// Apply button
if (e.target.closest('#toolsetsApplyBtn')) {
const input = $('toolsetsInput');
if (!input) return;
const raw = input.value.trim();
if (!raw) {
showToast(t('session_toolsets_desc'), 2000);
return;
}
const toolsets = raw.split(',').map(s => s.trim()).filter(Boolean);
if (toolsets.length === 0) {
showToast(t('session_toolsets_desc'), 2000);
return;
}
_applySessionToolsets(toolsets);
closeToolsetsDropdown();
}
// Clear button
if (e.target.closest('#toolsetsClearBtn')) {
_applySessionToolsets(null);
closeToolsetsDropdown();
}
});
// Position toolsets dropdown on resize, OR close it if the chip is no longer
// visible (e.g. resize crossed the 1100px container threshold while dropdown
// was open — the wrap is hidden by CSS but the dropdown sibling stays open
// without an anchor). (#1431)
window.addEventListener('resize', () => {
const dd = $('composerToolsetsDropdown');
if (!dd || !dd.classList.contains('open')) return;
const chip = $('composerToolsetsChip');
if (!chip || chip.offsetParent === null) { closeToolsetsDropdown(); return; }
_positionToolsetsDropdown();
});
function _syncMobileComposerConfigButton(open){
const btn=$('composerMobileConfigBtn');
if(!btn) return;
btn.classList.toggle('active',!!open);
btn.setAttribute('aria-expanded',open?'true':'false');
}
function closeMobileComposerConfig(){
const panel=$('composerMobileConfigPanel');
if(panel) panel.classList.remove('open');
_syncMobileComposerConfigButton(false);
if(typeof closeWsDropdown==='function') closeWsDropdown();
}
function toggleMobileComposerConfig(){
const panel=$('composerMobileConfigPanel');
if(!panel) return;
const open=panel.classList.contains('open');
if(open){
closeMobileComposerConfig();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
return;
}
if(typeof closeProfileDropdown==='function') closeProfileDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeToolsetsDropdown==='function') closeToolsetsDropdown();
panel.classList.add('open');
_syncMobileComposerConfigButton(true);
}
document.addEventListener('click',function(e){
if(
e.target.closest('#composerMobileConfigBtn') ||
e.target.closest('#composerMobileConfigPanel') ||
e.target.closest('#composerWsDropdown') ||
e.target.closest('#composerModelDropdown') ||
e.target.closest('#composerReasoningDropdown')
) return;
closeMobileComposerConfig();
});
document.addEventListener('keydown',function(e){
if(e.key!=='Escape') return;
const panel=$('composerMobileConfigPanel');
if(!panel||!panel.classList.contains('open')) return;
e.preventDefault();
closeMobileComposerConfig();
if(typeof closeWsDropdown==='function') closeWsDropdown();
closeModelDropdown();
closeReasoningDropdown();
});
window.addEventListener('resize',function(){
if(window.matchMedia && !window.matchMedia('(max-width: 640px)').matches){
closeMobileComposerConfig();
closeModelDropdown();
closeReasoningDropdown();
if(typeof closeWsDropdown==='function') closeWsDropdown();
}
});
// ── Scroll pinning ──────────────────────────────────────────────────────────
// When streaming, auto-scroll only if the user hasn't manually scrolled up.
// Once the user scrolls back to within 250px of the bottom, re-pin.
let _scrollPinned=true;
(function(){
const el=document.getElementById('messages');
if(!el) return;
el.addEventListener('scroll',()=>{
const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250;
_scrollPinned=nearBottom;
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display=_scrollPinned?'none':'flex';
// Load older messages when scrolled near the top
if(el.scrollTop<80 && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){
_loadOlderMessages();
}
});
})();
function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
const _MOBILE_CONFIG_BASE_LABEL='Workspace, model, reasoning, and context settings';
function _setCtxCompressButton(btn,text){
if(!btn)return;
if(text){
btn.style.display='';
btn.textContent=text;
btn.onclick=function(e){
if(e)e.stopPropagation();
const ta=$('msg');
if(ta){ta.value='/compress ';ta.focus();autoResize();}
};
}else{
btn.style.display='none';
btn.textContent='';
btn.onclick=null;
}
}
function _syncMobileCtxDisplay(state){
const badge=$('composerMobileCtxBadge');
const mobileConfigBtn=$('composerMobileConfigBtn');
const row=$('composerMobileContextAction');
const usageLine=$('composerMobileContextUsage');
const tokensLine=$('composerMobileContextTokens');
const thresholdLine=$('composerMobileContextThreshold');
const costLine=$('composerMobileContextCost');
const compressBtn=$('composerMobileCtxCompressBtn');
if(!state||!state.visible){
if(badge)badge.style.display='none';
if(row)row.style.display='none';
if(mobileConfigBtn){
mobileConfigBtn.setAttribute('aria-label',_MOBILE_CONFIG_BASE_LABEL);
mobileConfigBtn.setAttribute('title',_MOBILE_CONFIG_BASE_LABEL);
}
_setCtxCompressButton(compressBtn,'');
return;
}
if(badge){
badge.style.display='inline-flex';
badge.textContent=state.hasPromptTok?String(state.pct):'\u00b7';
badge.classList.toggle('ctx-mid',state.pct>50&&state.pct<=75);
badge.classList.toggle('ctx-high',state.pct>75);
badge.setAttribute('title',state.label);
}
if(mobileConfigBtn){
mobileConfigBtn.setAttribute('aria-label',`${_MOBILE_CONFIG_BASE_LABEL}; ${state.label}`);
mobileConfigBtn.setAttribute('title',`${_MOBILE_CONFIG_BASE_LABEL} \u00b7 ${state.label}`);
}
if(row){
row.style.display='';
row.setAttribute('aria-label',state.label);
row.classList.toggle('ctx-mid',state.pct>50&&state.pct<=75);
row.classList.toggle('ctx-high',state.pct>75);
}
if(usageLine)usageLine.textContent=state.usageText||'';
if(tokensLine)tokensLine.textContent=state.tokensText||'';
if(thresholdLine){
if(state.thresholdText){
thresholdLine.style.display='';
thresholdLine.textContent=state.thresholdText;
}else{
thresholdLine.style.display='none';
thresholdLine.textContent='';
}
}
if(costLine){
if(state.costText){
costLine.style.display='';
costLine.textContent=state.costText;
}else{
costLine.style.display='none';
costLine.textContent='';
}
}
_setCtxCompressButton(compressBtn,state.compressText||'');
}
// Context usage indicator in composer footer
function _syncCtxIndicator(usage){
const wrap=$('ctxIndicatorWrap');
const el=$('ctxIndicator');
if(!el)return;
// #1436: Use last_prompt_tokens only — NEVER fall back to cumulative
// input_tokens for the "context window % used" calculation. input_tokens
// is summed across all turns, so dividing it by the context window gives a
// nonsense percentage (often >100%) on long sessions. When we have no
// last-prompt data we render "·" + "tokens used" via the !hasPromptTok
// branch below — honest "no data" instead of misleading "890% used".
const promptTok=usage.last_prompt_tokens||0;
const totalTok=(usage.input_tokens||0)+(usage.output_tokens||0);
// Default context window to 128K when not provided by backend
const DEFAULT_CTX=128*1024;
const ctxWindow=usage.context_length||DEFAULT_CTX;
const cost=usage.estimated_cost;
// Show indicator whenever we have any usage data (tokens or cost)
if(!promptTok&&!totalTok&&!cost){
if(wrap) wrap.style.display='none';
_syncMobileCtxDisplay({visible:false});
return;
}
if(wrap) wrap.style.display='';
const hasPromptTok=!!promptTok;
const rawPct=hasPromptTok?Math.round((promptTok/ctxWindow)*100):0;
const pct=Math.min(100,rawPct);
const overflowed=rawPct>100;
const ring=$('ctxRingValue');
const center=$('ctxPercent');
const usageLine=$('ctxTooltipUsage');
const tokensLine=$('ctxTooltipTokens');
const thresholdLine=$('ctxTooltipThreshold');
const costLine=$('ctxTooltipCost');
if(ring){
const circumference=61.261056745;
ring.style.strokeDasharray=String(circumference);
ring.style.strokeDashoffset=String(circumference*(1-pct/100));
}
if(center) center.textContent=hasPromptTok?String(pct):'\u00b7';
const hasExplicitCtx=!!usage.context_length;
el.classList.toggle('ctx-mid',pct>50&&pct<=75);
el.classList.toggle('ctx-high',pct>75);
// ── Compress affordance (#524) ──
// Show a hint in the tooltip when context usage is high so users
// discover /compress without having to know the slash command.
const compressWrap=$('ctxTooltipCompress');
const compressBtn=$('ctxCompressBtn');
const compressText=pct>=75?t('ctx_compress_action'):(pct>=50?t('ctx_compress_hint'):'');
if(compressWrap) compressWrap.style.display=compressText?'':'none';
_setCtxCompressButton(compressBtn,compressText);
let label=hasPromptTok?`Context window ${pct}% used`:`${_fmtTokens(totalTok)} tokens used`;
if(!hasExplicitCtx&&hasPromptTok) label+=' (est. 128K)';
if(cost) label+=` \u00b7 $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
el.setAttribute('aria-label',label);
const usageText=hasPromptTok?(overflowed?`${rawPct}% used (context exceeded)`:`${pct}% used (${100-pct}% left)`):`${_fmtTokens(totalTok)} tokens used`;
const tokensText=hasPromptTok?`${_fmtTokens(promptTok)} / ${_fmtTokens(ctxWindow)} tokens used`:`In: ${_fmtTokens(usage.input_tokens||0)} \u00b7 Out: ${_fmtTokens(usage.output_tokens||0)}`;
if(usageLine) usageLine.textContent=usageText;
if(tokensLine) tokensLine.textContent=tokensText;
const threshold=usage.threshold_tokens||0;
let thresholdText='';
if(thresholdLine){
if(threshold&&ctxWindow){
thresholdText=`Auto-compress at ${_fmtTokens(threshold)} (${Math.round(threshold/ctxWindow*100)}%)`;
thresholdLine.style.display='';
thresholdLine.textContent=thresholdText;
}else{
thresholdLine.style.display='none';
thresholdLine.textContent='';
}
}
let costText='';
if(costLine){
if(cost){
costText=`Estimated cost: $${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
costLine.style.display='';
costLine.textContent=costText;
}else{
costLine.style.display='none';
costLine.textContent='';
}
}
_syncMobileCtxDisplay({
visible:true,
hasPromptTok,
pct,
label,
usageText,
tokensText,
thresholdText,
costText,
compressText
});
}
// ── Touch support: toggle context tooltip on tap (#524) ──
// On mobile, hover doesn't work — allow tap on the context ring button
// to toggle the tooltip visibility so the compress affordance is reachable.
document.addEventListener('DOMContentLoaded',function(){
const wrap=document.getElementById('ctxIndicatorWrap');
const tooltip=document.getElementById('ctxTooltip');
if(!wrap||!tooltip)return;
const btn=document.getElementById('ctxIndicator');
if(!btn)return;
btn.addEventListener('click',function(e){
e.stopPropagation();
const isOpen=tooltip.classList.contains('ctx-tooltip-active');
tooltip.classList.toggle('ctx-tooltip-active',!isOpen);
tooltip.setAttribute('aria-hidden',String(isOpen));
});
// Close on outside tap
document.addEventListener('click',function(){
tooltip.classList.remove('ctx-tooltip-active');
tooltip.setAttribute('aria-hidden','true');
},{passive:true});
// Prevent tooltip click from closing itself
tooltip.addEventListener('click',function(e){e.stopPropagation();});
});
function scrollIfPinned(){
if(!_scrollPinned) return;
const el=$('messages');
if(el) el.scrollTop=el.scrollHeight;
}
function scrollToBottom(){
_scrollPinned=true;
const el=$('messages');
if(el) el.scrollTop=el.scrollHeight;
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display='none';
}
function _fmtOllamaLabel(mid){
const [namePart, ...variantParts] = mid.split(':');
const variant = variantParts.join(':');
const _fmt = (s) => {
const tokens = s.replace(/[-_]/g, ' ').split(' ');
return tokens.map(t => {
const alphaOnly = t.replace(/\./g, '');
if (t.length <= 3 && /^[a-zA-Z.]+$/.test(t)) return t.toUpperCase();
if (/^\d/.test(alphaOnly)) return t.toUpperCase();
return t.charAt(0).toUpperCase() + t.slice(1);
}).join(' ');
};
let label = _fmt(namePart);
if (variant) label += ' (' + _fmt(variant) + ')';
return label;
}
function getModelLabel(modelId){
if(!modelId) return 'Unknown';
// Check dynamic labels first, then fall back to splitting the ID
if(_dynamicModelLabels[modelId]) return _dynamicModelLabels[modelId];
// Static fallback for common models
const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-v4-flash':'DeepSeek V4 Flash','deepseek/deepseek-v4-pro':'DeepSeek V4 Pro','deepseek/deepseek-chat-v3-0324':'DeepSeek V3 (legacy)','meta-llama/llama-4-scout':'Llama 4 Scout'};
if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId];
// Safe Ollama-tag fallback formatter before generic split('/').pop()
let _last = modelId.split('/').pop() || modelId;
// Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6)
if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':');
const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last);
// Narrow: only apply Ollama formatter to IDs with explicit @ollama prefix or colon-tag format.
// Avoids reformatting bare provider model IDs like claude-sonnet-4-6 or gpt-4o.
const looksLikeBareOllamaId = modelId.startsWith('@ollama') || looksLikeOllamaTag;
const ollamaLabel = _fmtOllamaLabel(_last);
if ((modelId.startsWith('ollama/') || modelId.startsWith('@ollama') || looksLikeOllamaTag || looksLikeBareOllamaId) && ollamaLabel !== _last) {
return ollamaLabel;
}
return _last || 'Unknown';
}
function _stripXmlToolCallsDisplay(s){
// Strip ... blocks emitted by DeepSeek and
// similar models in their raw response text. These are processed separately
// as tool calls; leaving them in the content causes them to render visibly
// in the settled chat bubble. (#702)
// Also handles DSML-prefixed variants from DeepSeek/Bedrock, including
// spacing variants like "<|DSML |function_calls" and truncated prefixes.
if(!s) return s;
const lo=String(s).toLowerCase();
if(lo.indexOf('function_calls')===-1 && lo.indexOf('dsml')===-1) return s;
// Support both plain and DSML-prefixed variants.
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls>[\s\S]*?<\/(?:\s*|\s*DSML\s*[||]\s*)?function_calls>/gi,'');
// Also remove truncated opening tags (missing closing ">" at stream tail).
s=s.replace(/<(?:\s*|\s*DSML\s*[||]\s*)?function_calls(?:>|$)[\s\S]*$/i,'');
// Remove malformed DSML tag fragments like "<|DSML |" that can leak in tokens.
s=s.replace(/<\s*|\s*DSML\s*[||]\s*/gi,'');
return s.trim();
}
function _sanitizeThinkingDisplayText(text){
const stripped=_stripXmlToolCallsDisplay(String(text||''));
return stripped.trim();
}
function renderMd(raw){
let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n');
// ── Entity decode: must run FIRST so > lines become > for the blockquote
// pre-pass below. LLMs sometimes emit HTML-entity-encoded output; without this
// a blockquote sent as "> text" would never be recognised as a blockquote.
s=s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'");
// ── Blockquote pre-pass (must run BEFORE every other markdown pass) ────────
// Group consecutive >-prefixed lines, strip the > prefix from each line,
// recursively render the stripped content with the full pipeline, and
// replace the group with a stash token. This is the only way fenced code,
// headings, hr, and ordered lists inside a blockquote can render correctly:
// the per-line passes downstream don't know about > prefixes, and by the
// time the blockquote handler used to run those passes had already mangled
// the >-prefixed lines.
//
// Walks lines (instead of using a single regex) so >-prefixed lines that
// sit inside a non-blockquote fenced block (e.g. a shell prompt in a
// ```bash``` example) are not miscaptured as a blockquote.
const _bq_stash=[];
s=(function _applyBlockquotes(input){
const lines=input.split('\n');
const out=[];
let inFence=false; // inside a non-blockquote ```...``` fence
let bqStart=-1;
const flush=(end)=>{
if(bqStart<0) return;
// Strip "> " prefix (and bare ">" → empty) from each line
const stripped=lines.slice(bqStart,end).map(l=>l.replace(/^> ?/,'')).join('\n');
// Recursive call: full pipeline on stripped content. Handles fenced
// code, headings, hr, ordered/unordered lists, nested blockquotes
// (>>) — anything that renderMd handles at the top level.
const rendered=renderMd(stripped);
_bq_stash.push(''+rendered+' ');
// Surround the token with blank lines so the paragraph splitter
// isolates it as its own chunk (otherwise the token gets wrapped
// in ... with adjacent text, producing invalid HTML).
out.push('');
out.push('\x00Q'+(_bq_stash.length-1)+'\x00');
out.push('');
bqStart=-1;
};
for(let i=0;i/.test(line)){
if(bqStart<0) bqStart=i;
} else {
flush(i);
out.push(line);
}
}
flush(lines.length);
return out.join('\n');
})(s);
// ── MEDIA: token stash (must run first, before any other processing) ───────
// Detect MEDIA: tokens emitted by the agent (e.g. screenshots,
// generated images) and replace them with inline or download links.
// Stashed so the path/URL is never processed as markdown.
const media_stash=[];
s=s.replace(/MEDIA:([^\s\)\]]+)/g,(_,raw_ref)=>{
media_stash.push(raw_ref);
return '\x00D'+(media_stash.length-1)+'\x00';
});
// ── End MEDIA stash ─────────────────────────────────────────────────────────
// Pre-pass: decode HTML entities first so markdown processing works correctly.
// This prevents double-escaping when LLM outputs entities like < > &
const decode=s=>s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,"'");
s=decode(s);
// Pre-pass: convert safe inline HTML tags the model may emit into their
// markdown equivalents so the pipeline can render them correctly.
// Only runs OUTSIDE fenced code blocks and backtick spans (stash + restore).
// Unsafe tags (anything not in the allowlist) are left as-is and will be
// HTML-escaped by esc() when they reach an innerHTML assignment -- no XSS risk.
// Fence stash: protect code blocks and backtick spans from all further processing.
// Must run BEFORE math_stash so $..$ inside code spans is not extracted as math.
// Split into fenced blocks (\x00P — kept stashed until after all markdown passes)
// and inline backtick spans (\x00F — restored before bold/italic so **`code`** works).
// Fenced blocks are converted to here so their content is HTML-escaped
// and never exposed to list/heading/table regexes that could corrupt the layout.
// Fixes #1154: diff/patch lines inside fenced blocks (e.g. + added, - removed)
// were matching the unordered-list regex and injecting / inside ,
// breaking closure and corrupting all subsequent message rendering.
const _preBlock_stash=[];
const fence_stash=[];
// CommonMark §4.5: opening fence must start a line (with up to 3 spaces of indent)
// and closing fence must also start a line. Without line anchoring, a literal ``` inside
// a code block (e.g. a regex pattern with ``` in a lookbehind, a script that documents
// fences) terminates the outer block at the wrong place, leaking content into the
// markdown stream where bold/italic/inline-code passes corrupt it. Fixes #1438.
s=s.replace(/(^|\n)[ ]{0,3}```(?:([\s\S]*?)\n)?[ ]{0,3}```(?=\n|$)/g,(_,lead,raw)=>{
const m=raw.match(/^(\w[\w+-]*)\n?([\s\S]*)$/);
const lang=m?(m[1]||'').trim().toLowerCase():'';
const code=m?m[2]:raw.replace(/^\n?/,'');
const codeLines=code.split('\n');
const firstCodeLine=codeLines.find(line=>line.trim())||'';
const firstMermaidLine=codeLines.map(line=>line.trim()).find(line=>line&&!line.startsWith('%%'))||'';
const looksLikeLineNumberedToolOutput=/^\s*\d+\|/.test(firstCodeLine);
const looksLikeMermaidStart=firstMermaidLine==='---'||/^(graph|flowchart|sequenceDiagram|classDiagram|classDiagram-v2|stateDiagram|stateDiagram-v2|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|quadrantChart|requirementDiagram|C4Context|C4Container|C4Component|C4Dynamic|c4Context|c4Container|c4Component|c4Dynamic|sankey-beta|block-beta|packet-beta|xychart-beta|kanban|architecture-beta)\b/.test(firstMermaidLine);
if(lang==='mermaid'&&!looksLikeLineNumberedToolOutput&&looksLikeMermaidStart){
const id='mermaid-'+Math.random().toString(36).slice(2,10);
_preBlock_stash.push(`${esc(code.trim())}
`);
} else {
const h=lang?``:'';
const langAttr=lang?` class="language-${esc(lang)}"`:'';
// For diff/patch blocks, wrap each line in a colored span
if(lang==='diff'||lang==='patch'){
const colored=esc(code.replace(/\n$/,'')).split('\n').map(line=>{
if(line.startsWith('@@')) return `${line} `;
if(line.startsWith('+')) return `${line} `;
if(line.startsWith('-')) return `${line} `;
return `${line} `;
}).join('\n');
_preBlock_stash.push(`${h}${colored} `);
// For JSON/YAML blocks, add tree-view placeholder with raw data
} else if(lang==='json'||lang==='yaml'){
const rawCode=esc(code.replace(/\n$/,''));
// Encode newlines as
to prevent HTML attribute normalization
// (browsers collapse \n to spaces inside attribute values).
const rawAttr=rawCode.replace(/"/g,'"').replace(/\n/g,'
');
const blockId='tree-'+Math.random().toString(36).slice(2,10);
_preBlock_stash.push(``);
// CSV blocks → render as styled table
} else if(lang==='csv'){
const rows=code.replace(/\n$/,'').split('\n').filter(r=>r.trim());
if(rows.length>=2){
const headers=rows[0].split(',').map(c=>c.trim());
const body=rows.slice(1).map(r=>''+r.split(',').map(c=>`${esc(c.trim())} `).join('')+' ').join('');
_preBlock_stash.push(`${h}${headers.map(h=>`${esc(h)} `).join('')} ${body}
`);
} else {
_preBlock_stash.push(`${h}${esc(code.replace(/\n$/,''))} `);
}
} else {
_preBlock_stash.push(`${h}${esc(code.replace(/\n$/,''))} `);
}
}
return lead+'\x00P'+(_preBlock_stash.length-1)+'\x00';
});
s=s.replace(/`([^`\n]+)`/g,(_,c)=>{fence_stash.push(''+esc(c)+'');return '\x00F'+(fence_stash.length-1)+'\x00';});
// Math stash: protect $$..$$ and $..$ from markdown processing
// Runs AFTER fence_stash so backtick code spans protect their dollar-sign contents
const math_stash=[];
// Display math: $$...$$ (must come before inline to avoid mis-parsing)
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Inline math: $...$ — require non-space at boundaries to avoid false positives
// e.g. "costs $5 and $10" should not trigger (space after opening $)
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Also stash \(...\) and \[...\] LaTeX delimiters
s=s.replace(/\\\\\((.+?)\\\\\)/g,(_,m)=>{math_stash.push({type:'inline',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
s=s.replace(/\\\\\[(.+?)\\\\\]/gs,(_,m)=>{math_stash.push({type:'display',src:m});return '\x00M'+(math_stash.length-1)+'\x00';});
// Safe tag → markdown equivalent (these produce the same output as **text** etc.)
// Stash raw blocks so the inline rewrite below does not run
// inside them. Running that rewrite in content can introduce stray
// backticks for multiline code and break subsequent code-box rendering.
const rawPreStash=[];
s=s.replace(/(]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
s=s.replace(/([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/([\s\S]*?)<\/i>/gi,(_,t)=>'*'+t+'*');
s=s.replace(/([^<]*?)<\/code>/gi,(_,t)=>'`'+t+'`');
s=s.replace(/ /gi,'\n');
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+i]);
// ── Glued-bold-heading lift (issue #1446) ────────────────────────────────
// LLMs in thinking/reasoning mode frequently emit a "section header" glued
// to the end of the previous paragraph with no whitespace, like:
//
// Para 1 text.**Heading to Para 2**
//
// Para 2 text.**Heading to Para 3**
//
// CommonMark renders that correctly as paragraph-end inline bold, but the
// visual effect is a run-on label rather than a section break. Lift the
// glued bold into its own paragraph when it follows a sentence terminator
// and is followed by a blank line.
//
// Constraints (avoid false positives):
// - Trigger only on a sentence terminator (.!?) IMMEDIATELY before `**`
// (no space) — that pattern is almost always a glued heading, not
// intentional emphasis.
// - Inner text length ≤ 80 chars — long bold runs are usually emphasis
// prose, not headings.
// - Trailing `\n\n` required — preserves mid-paragraph emphasis like
// "this is **important**." untouched.
// - Inner text must not contain newlines or `*` (single-line bold only).
// - Runs after fenced code, math, and raw are stashed, so code
// content is protected (see pipeline notes).
s=s.replace(/([.!?])\*\*([^*\n]{1,80})\*\*\n\n/g,'$1\n\n**$2**\n\n');
// Inline backtick spans: restore tags produced in the stash callback above.
// Must happen BEFORE bold/italic so **`code`** → code .
s=s.replace(/\x00F(\d+)\x00/g,(_,i)=>fence_stash[+i]);
// inlineMd: process bold/italic/code/links within a single line of text.
// Used inside list items and blockquotes where the text may already contain
// HTML from the pre-pass → bold pipeline, so we cannot call esc() directly.
function inlineMd(t){
// Stash backtick code spans first so bold/italic never esc() their content
const _code_stash=[];
t=t.replace(/`([^`\n]+)`/g,(_,x)=>{_code_stash.push(`${esc(x)}`);return `\x00C${_code_stash.length-1}\x00`;});
t=t.replace(/\*\*\*(.+?)\*\*\*/g,(_,x)=>`${esc(x)} `);
t=t.replace(/\*\*(.+?)\*\*/g,(_,x)=>`${esc(x)} `);
t=t.replace(/\*([^*\n]+)\*/g,(_,x)=>`${esc(x)} `);
// Strikethrough: ~~text~~ → text
t=t.replace(/~~(.+?)~~/g,(_,x)=>`${esc(x)}`);
// #487: Image pass — runs while code stash is active so  inside
// backticks stays protected as a \x00C token and is never rendered as .
// Must run before _code_stash restore and before _link_stash so the image
// is not consumed by the [label](url) link regex.
t=t.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>` `);
// Stash rendered tags so autolink never matches URLs inside src=
const _img_stash=[];
t=t.replace(/( ]*>)/g,m=>{_img_stash.push(m);return `\x00G${_img_stash.length-1}\x00`;});
t=t.replace(/\x00C(\d+)\x00/g,(_,i)=>_code_stash[+i]);
// Stash [label](url) links before autolink so the URL in href= is not re-linked
const _link_stash=[];
t=t.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,lb,u)=>{_link_stash.push(`${esc(lb)} `);return `\x00L${_link_stash.length-1}\x00`;});
t=t.replace(/(https?:\/\/[^\s<>"')\]]+)/g,(url)=>{const trail=url.match(/[.,;:!?)]$/)?url.slice(-1):'';const clean=trail?url.slice(0,-1):url;return `${esc(clean)} ${trail}`;});
t=t.replace(/\x00L(\d+)\x00/g,(_,i)=>_link_stash[+i]);
t=t.replace(/\x00G(\d+)\x00/g,(_,i)=>_img_stash[+i]);
// Escape any plain text that isn't already wrapped in a tag we produced
// by escaping bare < > that are not part of our own tags
const SAFE_INLINE=/^<\/?(strong|em|del|code|a|img)([\s>]|$)/i;
t=t.replace(/<\/?[a-z][^>]*>/gi,tag=>SAFE_INLINE.test(tag)?tag:esc(tag));
return t;
}
// Stash tags from the backtick pass above so the outer bold/italic
// regexes don't esc() their content (e.g. **`code`** → code )
const _ob_stash=[];
s=s.replace(/(]*>[\s\S]*?<\/code>)/g,m=>{_ob_stash.push(m);return `\x00O${_ob_stash.length-1}\x00`;});
s=s.replace(/\*\*\*(.+?)\*\*\*/g,(_,t)=>`${esc(t)} `);
s=s.replace(/\*\*(.+?)\*\*/g,(_,t)=>`${esc(t)} `);
s=s.replace(/\*([^*\n]+)\*/g,(_,t)=>`${esc(t)} `);
s=s.replace(/~~(.+?)~~/g,(_,t)=>`${esc(t)}`);
s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]);
s=s.replace(/^###### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^##### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^#### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^### (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^## (.+)$/gm,(_,t)=>`${inlineMd(t)} `).replace(/^# (.+)$/gm,(_,t)=>`${inlineMd(t)} `);
s=s.replace(/^---+$/gm,' ');
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
// fence_stash. The per-line passes below never see > prefixes.)
// B8: improved list handling supporting up to 2 levels of indentation
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n');
let html='';
for(const l of lines){
const indent=/^ {2,}/.test(l);
const text=l.replace(/^ {0,4}[-*+] /,'');
let _ih;
if(/^\[x\] /i.test(text)) _ih='✅ '+inlineMd(text.slice(4));
else if(/^\[ \] /.test(text)) _ih='☐ '+inlineMd(text.slice(4));
else _ih=inlineMd(text);
if(indent) html+=`${_ih} `;
else html+=`${_ih} `;
}
return html+' ';
});
// Ordered lists: use value= on each so the correct number is preserved
// even when blank lines between items cause the paragraph splitter to place
// each item in its own container — without value= every restarts
// at 1, producing "1. 1. 1." instead of "1. 2. 3." (#886).
s=s.replace(/((?:^(?: )?\d+\. .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n');
let html='';
for(const l of lines){
const numMatch=l.match(/^\s*(\d+)\. /);
const num=numMatch?parseInt(numMatch[1],10):null;
const text=l.replace(/^ {0,4}\d+\. /,'');
const valAttr=num!==null?` value="${num}"`:'';
html+=`${inlineMd(text)} `;
}
return html+' ';
});
// Tables: | col | col | header row followed by | --- | --- | separator then data rows
// NOTE: table pass runs BEFORE outer link pass so [label](url) in table cells
// is handled by inlineMd() only — prevents double-linking.
s=s.replace(/((?:^\|.+\|\n?)+)/gm,block=>{
const rows=block.trim().split('\n').filter(r=>r.trim());
if(rows.length<2)return block;
const isSep=r=>/^\|[\s|:-]+\|$/.test(r.trim());
if(!isSep(rows[1]))return block;
const parseRow=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(c.trim())} `).join('');
const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(c.trim())} `).join('');
const header=`${parseHeader(rows[0])} `;
const body=rows.slice(2).map(r=>`${parseRow(r)} `).join('');
return ``;
});
// #487: Outer image pass — handles  in plain paragraphs (outside tables/lists).
// Runs AFTER the table pass (images in table cells are handled by inlineMd() above).
// Runs BEFORE the outer [label](url) link pass so the image is not consumed as a plain link.
s=s.replace(/!\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g,(_,alt,url)=>` `);
// Outer link pass for labeled links in plain paragraphs (outside table cells).
// Runs AFTER the table pass so table cells are processed by inlineMd() only.
// Stash existing tags first to avoid re-linking already-linked URLs.
const _a_stash=[];
s=s.replace(/( ]*>[\s\S]*?<\/a>)/g,m=>{_a_stash.push(m);return `\x00A${_a_stash.length-1}\x00`;});
s=s.replace(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/g,(_,label,url)=>` ${esc(label)} `);
s=s.replace(/\x00A(\d+)\x00/g,(_,i)=>_a_stash[+i]);
// Sanitize any remaining HTML tags. The renderer intentionally returns
// HTML and inserts it with innerHTML later, so tag names alone are not enough:
// raw/model-provided HTML like or
// must lose executable attributes and dangerous schemes while preserving the
// small set of attributes generated by this markdown pipeline.
// Reference only — documents the allowed tag set. Superseded by _tag() allowlists.
// Tests verify this list is complete; _tag() enforces it.
const SAFE_TAGS=/^<\/?(?:strong|em|del|code|pre|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|hr|blockquote|p|br|a|div|span|img)([\s>]|$)/i;
function _safeAttrValue(v){
return String(v||'').replace(/"/g,'"').replace(/'/g,"'").replace(/&/g,'&').trim();
}
function _isSafeUrl(v, img){
const raw=_safeAttrValue(v);
const compact=raw.replace(/[\u0000-\u001f\u007f\s]+/g,'').toLowerCase();
if(!compact) return false;
if(/^(javascript|data|vbscript):/i.test(compact)) return false;
if(/^https?:\/\//i.test(raw)) return true;
if(img && /^api\//i.test(raw)) return true;
if(!img && (/^api\//i.test(raw) || /^#/.test(raw))) return true;
return false;
}
function _attrs(raw){
const out={};
String(raw||'').replace(/([a-zA-Z0-9:_-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>`]+)))?/g,(_,k,dq,sq,bare)=>{
out[String(k).toLowerCase()]=dq!==undefined?dq:(sq!==undefined?sq:(bare!==undefined?bare:''));
return '';
});
return out;
}
function _cls(v, allowed){
const got=String(v||'').split(/\s+/).filter(c=>allowed.includes(c));
return got.length?` class="${esc(got.join(' '))}"`:'';
}
function _tag(tag){
const m=String(tag||'').match(/^<\s*(\/)?\s*([a-zA-Z][\w:-]*)([\s\S]*?)(\/)?\s*>$/);
if(!m) return esc(tag);
const closing=!!m[1];
const name=m[2].toLowerCase();
const rawAttrs=m[3]||'';
const plain=['strong','em','del','pre','h1','h2','h3','h4','h5','h6','ul','ol','table','thead','tbody','tr','th','td','blockquote','p','br','hr'];
if(closing) return plain.includes(name)||['a','div','span','li','code'].includes(name)?`${name}>`:'';
if(name==='code'){
const a=_attrs(rawAttrs);
const cls=/^language-[a-z0-9_+-]+$/i.test(a.class||'')?` class="${esc(a.class)}"`:'';
return ``;
}
if(plain.includes(name)) return `<${name}>`;
const a=_attrs(rawAttrs);
if(name==='li'){
const value=/^\d+$/.test(a.value||'')?` value="${esc(a.value)}"`:'';
const style=(a.style||'').replace(/\s+/g,'').toLowerCase()==='margin-left:16px'?` style="margin-left:16px"`:'';
return ``;
}
if(name==='span'){
return ``;
}
if(name==='div'){
const cls=_cls(a.class,['pre-header','mermaid-block','katex-block']);
const mermaid=a['data-mermaid-id']?` data-mermaid-id="${esc(a['data-mermaid-id'])}"`:'';
const katex=a['data-katex']==='display'?' data-katex="display"':'';
return `