const S={session:null,messages:[],entries:[],busy:false,pendingFiles:[],toolCalls:[],activeStreamId:null,currentDir:'.',activeProfile:'default',showHiddenWorkspaceFiles:false};
const INFLIGHT={}; // keyed by session_id while request in-flight
const SESSION_QUEUES={}; // keyed by session_id for queued follow-up turns
const MAX_UPLOAD_BYTES=20*1024*1024;
const MAX_UPLOAD_MB=Math.round(MAX_UPLOAD_BYTES/1024/1024);
// 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);
const OFFLINE_RECHECK_MS=2500;
let _offlineVisible=false;
let _offlineReason='browser';
let _offlineProbeTimer=null;
let _offlineChecking=false;
let _offlineProbePromise=null;
let _offlineHealthProbePromise=null;
let _offlineRawFetch=null;
let _offlineFetchPatched=false;
function _browserReportsOnline(){return !('onLine' in navigator)||navigator.onLine!==false;}
function _offlineHealthUrl(){const url=new URL('health',document.baseURI||location.href);url.searchParams.set('offline_probe',String(Date.now()));return url.href;}
function _setOfflineChecking(checking){
_offlineChecking=!!checking;
const btn=$('offlineCheckNow');
if(btn){btn.disabled=_offlineChecking;btn.textContent=_offlineChecking?t('offline_checking'):t('offline_check_now');}
}
function _renderOfflineBanner(){
const banner=$('offlineBanner');
if(!banner)return;
const detail=$('offlineDetails');
if(detail)detail.textContent=t(_offlineReason==='browser'?'offline_browser_detail':'offline_network_detail');
const title=$('offlineTitle');
if(title)title.textContent=t('offline_title');
const auto=$('offlineAutorefresh');
if(auto)auto.textContent=t('offline_autorefresh');
_setOfflineChecking(_offlineChecking);
banner.hidden=false;
banner.classList.add('visible');
}
function _startOfflineProbeTimer(){
if(_offlineProbeTimer)return;
_offlineProbeTimer=setInterval(()=>{checkOfflineRecoveryNow();},OFFLINE_RECHECK_MS);
}
function _stopOfflineProbeTimer(){
if(_offlineProbeTimer){clearInterval(_offlineProbeTimer);_offlineProbeTimer=null;}
}
function showOfflineBanner(reason){
_offlineVisible=true;
_offlineReason=reason||(_browserReportsOnline()?'network':'browser');
_renderOfflineBanner();
_startOfflineProbeTimer();
}
function isOfflineBannerVisible(){return _offlineVisible;}
function _hideOfflineBanner(){
_offlineVisible=false;
_stopOfflineProbeTimer();
_setOfflineChecking(false);
const banner=$('offlineBanner');
if(banner){banner.classList.remove('visible');banner.hidden=true;}
}
async function _probeOfflineRecovery(){
if(_offlineHealthProbePromise)return _offlineHealthProbePromise;
_offlineHealthProbePromise=(async()=>{
const fetcher=_offlineRawFetch||window.fetch.bind(window);
try{
const res=await fetcher(_offlineHealthUrl(),{cache:'no-store',credentials:'include'});
return !!(res&&res.ok);
}catch(_){return false;}
})();
try{return await _offlineHealthProbePromise;}
finally{_offlineHealthProbePromise=null;}
}
async function checkOfflineRecoveryNow(){
if(_offlineProbePromise)return _offlineProbePromise;
_offlineProbePromise=(async()=>{
if(!_offlineVisible)return false;
if(!_browserReportsOnline()){showOfflineBanner('browser');return false;}
_setOfflineChecking(true);
const ok=await _probeOfflineRecovery();
_setOfflineChecking(false);
if(ok){_stopOfflineProbeTimer();window.location.reload();return true;}
showOfflineBanner('network');
return false;
})();
try{return await _offlineProbePromise;}
finally{_offlineProbePromise=null;}
}
function _isAbortError(e){return !!(e&&(e.name==='AbortError'||e.code===20));}
function _patchOfflineFetch(){
if(_offlineFetchPatched||typeof window.fetch!=='function')return;
_offlineFetchPatched=true;
_offlineRawFetch=window.fetch.bind(window);
window.fetch=async function(...args){
try{return await _offlineRawFetch(...args);}
catch(e){
if(!_browserReportsOnline())showOfflineBanner('browser');
else if(e instanceof TypeError&&!_isAbortError(e))void _probeOfflineRecovery().then(ok=>{if(!ok)showOfflineBanner('network');});
throw e;
}
};
}
function initOfflineMonitor(){
_patchOfflineFetch();
window.addEventListener('offline',()=>showOfflineBanner('browser'));
window.addEventListener('online',()=>{if(_offlineVisible)checkOfflineRecoveryNow();});
if(!_browserReportsOnline())showOfflineBanner('browser');
}
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',initOfflineMonitor,{once:true});
else initOfflineMonitor();
// Redirect to login when the server responds with 401 (auth session expired).
// Handles iOS PWA standalone mode and keeps subpath mounts like /hermes/ from
// escaping to the personal site root /login.
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]));
function _matchBacktickFenceLine(line){
const m=String(line||'').match(/^[ ]{0,3}(`{3,})([^`]*)$/);
if(!m) return null;
return {fence:m[1],len:m[1].length,info:(m[2]||'').trim()};
}
function _isBacktickFenceClose(line,minLen){
const m=String(line||'').match(/^[ ]{0,3}(`{3,})[ \t]*$/);
return !!(m&&m[1].length>=minLen);
}
/**
* 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 _stripWorkspaceDisplayPrefix(text){
// v1 sentinel format `[Workspace::v1: ]\n` injected since #1918.
// Legacy format `[Workspace: ]\n` may still be present in transcripts
// saved before the v1 migration; fall through to the legacy regex when the
// v1 strip didn't match. Mirrors the Python `include_legacy=True` branch in
// api/streaming.py:_strip_workspace_prefix(). Per Opus advisor on stage-322.
const value = String(text||'');
const stripped = value.replace(/^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*/,'');
if(stripped !== value) return stripped.trim();
return value.replace(/^\s*\[Workspace:[^\]]+\]\s*/,'').trim();
}
function _renderUserFencedBlocks(text){
const stash=[];
const mathStash=[];
const stashMath=(type,src)=>{mathStash.push({type,src});return '\x00UM'+(mathStash.length-1)+'\x00';};
const restoreMath=html=>String(html||'').replace(/\x00UM(\d+)\x00/g,(_,i)=>{
const item=mathStash[+i];
if(!item) return '';
if(item.type==='display') return `${esc(item.src)}
`;
return `${esc(item.src)} `;
});
let s=String(text||'');
// Extract fenced code blocks FIRST so math regexes never run inside fenced
// content. If math were stashed first, a user-typed code block containing
// \[..\] / \(..\) / $$..$$ would be rendered as a KaTeX block inside
// instead of as literal source. Mirrors renderMd()'s ordering.
// CommonMark §4.5 line-anchored fence: the closing run must use at least
// as many backticks as the opener, so inner triple-backtick fences remain content.
s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
const langInfo=(info||'').trim();
const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
let lang=langMatch?(langMatch[1]||'').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';
});
// Now stash math from the OUTSIDE-of-fence text. Display delimiters must
// run before inline so $$..$$ isn't mis-parsed as $..$..$..$.
s=s.replace(/\$\$([\s\S]+?)\$\$/g,(_,m)=>stashMath('display',m));
s=s.replace(/\\\[([\s\S]+?)\\\]/g,(_,m)=>stashMath('display',m));
s=s.replace(/\$([^\s$\n][^$\n]*?[^\s$\n]|\S)\$/g,(_,m)=>stashMath('inline',m));
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>stashMath('inline',m));
// Escape remaining plain text and convert newlines to
s=esc(s).replace(/\n/g,' ');
// Restore stashed code blocks, then math placeholders as KaTeX targets.
s=s.replace(/\x00UF(\d+)\x00/g,(_,i)=>stash[+i]);
s=restoreMath(s);
return s;
}
function _statusCardHtml(card){
card=card||{};
const rows=Array.isArray(card.rows)?card.rows:[];
const sessionId=String(card.sessionId||'');
const shortSessionId=sessionId.length>22?`${sessionId.slice(0,10)}…${sessionId.slice(-8)}`:sessionId;
const copyIcon=(typeof li==='function')?li('copy',13):'Copy';
const copyBtn=sessionId
? `${esc(shortSessionId)} ${copyIcon} `
: '';
const rowHtml=rows.map(row=>`
${esc(row.label||'')}
${esc(row.value||'')}
`).join('');
return `
${esc(card.title||t('status_heading'))}
${esc(card.subtitle||'')}
${copyBtn}
${rowHtml}
`;
}
const MESSAGE_RENDER_WINDOW_DEFAULT=50;
let _messageRenderWindowSid=null;
let _messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
function _resetMessageRenderWindow(sid){
_messageRenderWindowSid=sid||null;
_messageRenderWindowSize=MESSAGE_RENDER_WINDOW_DEFAULT;
}
function _currentMessageRenderWindowSize(){
return Math.max(
MESSAGE_RENDER_WINDOW_DEFAULT,
Number(_messageRenderWindowSize)||MESSAGE_RENDER_WINDOW_DEFAULT
);
}
function _messageRenderableMessageCount(){
let count=0;
for(const m of (S.messages||[])){
if(!m||!m.role||m.role==='tool') continue;
if(_isContextCompactionMessage(m)||_isPreservedCompressionTaskListMessage(m)) continue;
const hasTc=Array.isArray(m.tool_calls)&&m.tool_calls.length>0;
const hasTu=Array.isArray(m.content)&&m.content.some(p=>p&&p.type==='tool_use');
if(msgContent(m)||m.attachments?.length||(m.role==='assistant'&&(hasTc||hasTu||_messageHasReasoningPayload(m)))) count++;
}
return count;
}
function _messageHiddenBeforeCount(){
return Math.max(0,_messageRenderableMessageCount()-_currentMessageRenderWindowSize());
}
function _isSessionEndlessScrollEnabled(){
return window._sessionEndlessScrollEnabled===true;
}
function _wireMessageWindowLoadEarlierButton(){
const indicator=$('loadOlderIndicator');
if(!indicator) return;
indicator.onclick=()=>{
if(_messageHiddenBeforeCount()>0) _showEarlierRenderedMessages();
else if(typeof _loadOlderMessages==='function') _loadOlderMessages();
};
}
function _showEarlierRenderedMessages(){
const container=$('messages');
const prevScrollH=container?container.scrollHeight:0;
const prevScrollTop=container?container.scrollTop:0;
_messageRenderWindowSize=_currentMessageRenderWindowSize()+MESSAGE_RENDER_WINDOW_DEFAULT;
renderMessages();
if(container){
const newScrollH=container.scrollHeight;
container.scrollTop=prevScrollTop+(newScrollH-prevScrollH);
}
_scrollPinned=false;
}
function _isSessionJumpButtonsEnabled(){
return window._sessionJumpButtonsEnabled===true;
}
function _applySessionNavigationPrefs(){
const container=$('messages');
if(container) container.classList.toggle('session-nav-enabled',_isSessionJumpButtonsEnabled());
_updateSessionStartJumpButton();
}
function _updateSessionStartJumpButton(){
const btn=$('jumpToSessionStartBtn');
const container=$('messages');
if(!btn||!container) return;
if(!_isSessionJumpButtonsEnabled()){
btn.style.display='none';
return;
}
const hasSession=!!(S&&S.session&&S.messages&&S.messages.length);
const awayFromStart=container.scrollTop>Math.max(240,container.clientHeight*0.35);
const hasScrollableHistory=container.scrollHeight>container.clientHeight+Math.max(240,container.clientHeight*0.35);
const canRevealStart=hasScrollableHistory||_messageHiddenBeforeCount()>0||!!(typeof _messagesTruncated!=='undefined'&&_messagesTruncated);
btn.style.display=(hasSession&&canRevealStart&&awayFromStart)?'flex':'none';
}
async function jumpToSessionStart(){
const container=$('messages');
if(!container||!S.session) return;
_scrollPinned=false;
_messageUserUnpinned=true;
_programmaticScroll=true;
try{
if(typeof _ensureAllMessagesLoaded==='function') await _ensureAllMessagesLoaded();
_messageRenderWindowSize=Math.max(_currentMessageRenderWindowSize(),_messageRenderableMessageCount());
renderMessages({ preserveScroll:true });
requestAnimationFrame(()=>{
container.scrollTop=0;
_updateSessionStartJumpButton();
requestAnimationFrame(()=>{ _programmaticScroll=false; });
});
}catch(e){
console.warn('jumpToSessionStart failed:',e);
_programmaticScroll=false;
}
}
const DASHBOARD_STATUS_TTL_MS=60000;
let _dashboardStatusCache=null;
let _dashboardStatusFetchedAt=0;
function _dashboardIsBrowserLoopback(){
const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase();
return host==='127.0.0.1'||host==='localhost'||host==='::1';
}
function _dashboardBrowserUrl(status){
if(!status||!status.running||!status.port) return '';
let source;
try{source=new URL(status.url||('http://127.0.0.1:'+status.port));}
catch(_){source=new URL('http://127.0.0.1:'+status.port);}
const browserHost=window.location.hostname||source.hostname;
const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost;
return source.protocol+'//'+displayHost+':'+status.port;
}
function _applyDashboardStatus(status){
const running=!!(status&&status.running);
const url=running?_dashboardBrowserUrl(status):'';
const warning=running&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):'';
document.querySelectorAll('[data-dashboard-link]').forEach(btn=>{
btn.classList.toggle('dashboard-link-visible',running);
btn.style.display=running?'':'none';
btn.dataset.dashboardUrl=url;
const tipText=warning||t('tab_dashboard');
if(btn.hasAttribute('data-tooltip')){
// Sync the custom CSS tooltip and explicitly clear the native title so
// the slow ~1.5s native browser tooltip does not co-fire alongside the
// fast custom tooltip (#1775).
btn.setAttribute('data-tooltip',tipText);
if(btn.hasAttribute('title')) btn.removeAttribute('title');
} else {
btn.title=tipText;
}
btn.setAttribute('aria-label',tipText);
});
}
async function refreshDashboardStatus(force=false){
const now=Date.now();
if(!force&&_dashboardStatusCache&&(now-_dashboardStatusFetchedAt) 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 => {
if(!e.target || !e.target.closest) return;
// Message-attached images (already wired since v0.50.x).
let img = e.target.closest('.msg-media-img');
if(img){ _openImgLightbox(img.src, img.alt); return; }
// Composer attach-tray image thumbnails — click any pasted/dropped image
// chip to lightbox-zoom it before sending. Excludes audio/video chips,
// which keep their inline media controls. SVG thumbnails (.attach-thumb--svg)
// are still images visually, so they qualify.
img = e.target.closest('.attach-thumb');
if(img && img.tagName === 'IMG'){
_openImgLightbox(img.src, img.alt || img.title || 'Attached image');
return;
}
});
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);
if(_HTML_EXTS.test(fname)){
const inlineUrl=url+(String(url).includes('?')?'&':'?')+'inline=1';
return `${li('file-code',12)} ${esc(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 _refreshOpenModelDropdown(){
const dd=$('composerModelDropdown');
if(dd&&dd.classList&&dd.classList.contains('open')&&typeof renderModelDropdown==='function'){
renderModelDropdown();
if(typeof _positionModelDropdown==='function') _positionModelDropdown();
}
}
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'){
if(typeof syncModelChip==='function') syncModelChip();
_refreshOpenModelDropdown();
}
return resolved;
}
return null;
}
function _modelStateFromAppliedDropdown(sel, modelValue){
const state=(typeof _modelStateForSelect==='function')
? _modelStateForSelect(sel,modelValue)
: {model:modelValue,model_provider:null};
return {model:state.model||modelValue,model_provider:state.model_provider||null};
}
function _persistSessionModelCorrection(model, provider){
if(!S.session) return;
fetch(new URL('api/session/update',document.baseURI||location.href).href,{
method:'POST',credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({session_id:S.session.id||S.session.session_id,model:model,model_provider:provider||null})
}).catch(()=>{});
}
function _applySessionModelFallback(sel){
if(!sel) return null;
const configuredDefault=String(window._defaultModel||'').trim();
if(configuredDefault){
const appliedDefault=_applyModelToDropdown(configuredDefault,sel,window._activeProvider||null);
if(appliedDefault) return _modelStateFromAppliedDropdown(sel,appliedDefault);
}
const first=sel.querySelector('optgroup > option, option');
if(first){
sel.value=first.value;
if(sel.id==='modelSelect'){
if(typeof syncModelChip==='function') syncModelChip();
_refreshOpenModelDropdown();
}
return _modelStateFromAppliedDropdown(sel,first.value);
}
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;
}
// Hydrate the label map from extra_models too (the catalog tail that
// doesn't render as entries when the picker is capped — see
// _build_nous_featured_set in api/config.py for the rationale). This
// keeps a model selected from the slash-command autocomplete or a
// persisted-localStorage value renderable with its proper label
// instead of falling back to the bare ID. #1567.
if(Array.isArray(g.extra_models)){
for(const m of g.extra_models){
if(m && m.id) _dynamicModelLabels[m.id]=m.label||m.id;
}
}
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();
const dd=$('composerModelDropdown');
if(dd&&dd.classList.contains('open')&&typeof renderModelDropdown==='function'){
renderModelDropdown();
_positionModelDropdown();
}
// 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();
// Strip @provider: prefix (e.g., @custom:jingdong:GLM-5 -> GLM-5).
// Defensive: trailing-colon / trailing-slash falls back to the original key
// so malformed configs don't collapse distinct ids to '' (matches backend _norm_model_id).
if(s.startsWith('@')&&s.includes(':')){const last=s.split(':').pop();s=last||s;}
if(s.includes('/')){const last=s.split('/').pop();s=last||s;}
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||'');
const gatewayRouting=_latestGatewayRoutingForSession(S.session);
const displayText=_formatGatewayModelLabel(sel.value||'',text,gatewayRouting)||text;
label.textContent=displayText;
if(mobileLabel) mobileLabel.textContent=displayText;
chip.title=gatewayRouting?`${sel.value||'Conversation model'} ${_gatewayRoutingLabel(gatewayRouting)}`:(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':'');
// Add provider info to badge label (e.g., "Primary (jingdong)")
let badgeLabel=m.badge?(m.badge.label||'Configured'):'';
if(m.badge&&m.badge.provider){
const providerName=m.badge.provider.replace(/^custom:/,'').split('/')[0];
badgeLabel+=` (${providerName})`;
}
const badgeHtml=m.badge?`${esc(badgeLabel)} `:'';
row.innerHTML=`${m.name} ${badgeHtml}
${m.id} `;
row.onclick=()=>selectModelFromDropdown(m.value);
dd.appendChild(row);
}
}
// Add remaining models matching filter
let _lastGroup=null;
// Count models per group for heading labels (#1425)
const _groupCounts={};
for(const m of _modelData){
if(configuredIds.has(m.value)) continue;
if(m.group) _groupCounts[m.group]=(_groupCounts[m.group]||0)+1;
}
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';
const count=_groupCounts[m.group]||0;
heading.textContent=count>1?`${m.group} (${count})`: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')} `:'';
// Inline provider chip on every row that has a group (#1425)
const providerChip=m.group?`${esc(m.group)} `:'';
row.innerHTML=`${m.name} ${badgeHtml}${providerChip}
${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();
}
async 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();
const ready=window._modelDropdownReady;
if(ready&&typeof ready.then==='function'){
try{await ready;}catch(_){}
}
if(dd.classList.contains('open')) return;
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.
// Uses a guard flag to avoid the race where programmatic scrolls (from
// scrollIfPinned / scrollToBottom) re-set _scrollPinned=true, overriding
// the user's explicit scroll-up. Fixes #1469 / #1360.
// Direction-aware unpin (issue #1731): the hysteresis below is correct
// for re-pinning (entering the near-bottom zone), but applying it to
// unpinning stranded users who scrolled up by a small amount inside the
// 250px zone — every upward sample still landed in the near-bottom
// region, so the counter kept incrementing and _scrollPinned stayed
// true. The next streaming token snapped them back. We now track
// scrollTop direction: an explicit upward movement (scrollTop decreased
// by more than 2px between samples) unpins immediately and resets the
// counter, while downward / stationary movement falls through the
// original hysteresis path so the macOS momentum re-pin protection from
// #1360 is preserved.
// rAF-debounced scroll listener (issue #1360): on macOS WKWebView, trackpad
// momentum scrolling fires scroll events that interleave with the
// _programmaticScroll setTimeout(0) guard. A mid-momentum scroll event can
// either get swallowed (_programmaticScroll still true) or falsely report
// the user is at the bottom (momentum hasn't settled). rAF defers the
// distance check to the next paint frame when the browser's scroll
// position has settled. A hysteresis counter requires two consecutive
// near-bottom samples before re-pinning, preventing accidental re-pin
// during initial deceleration.
let _scrollPinned=true;
let _programmaticScroll=false;
let _nearBottomCount=0;
let _lastScrollTop=null;
let _lastNonMessageScrollIntentMs=0;
let _lastMessageUpwardIntentMs=0;
let _messageUserUnpinned=false;
let _bottomSettleToken=0;
const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350;
const MESSAGE_UPWARD_INTENT_MS=450;
function _cancelBottomSettle(){ _bottomSettleToken++; }
function _recordNonMessageScrollIntent(e){
const el=document.getElementById('messages');
const target=e&&e.target;
if(!el||!target) return;
// Streaming token renders should keep pinning the chat only while the user is
// actually interacting with the chat pane. A wheel/touch gesture over the
// session sidebar (or another independent pane) must not be immediately fought
// by scrollIfPinned() writing #messages.scrollTop on the next token (#1784).
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){
// User is intentionally moving upward in the transcript. Record the real
// input event so later scrollTop decreases caused by layout/windowing do
// not masquerade as user intent and strand live streaming away from bottom.
_lastMessageUpwardIntentMs=performance.now();
_messageUserUnpinned=true;
// User is intentionally moving in the transcript. Cancel any delayed
// scrollToBottom settling that was scheduled by session-load/layout growth.
_cancelBottomSettle();
_nearBottomCount=0;
_scrollPinned=false;
}
}
function _recentMessageUpwardIntent(){
return performance.now()-_lastMessageUpwardIntentMs{
if(_programmaticScroll) return; // ignore scrolls we triggered ourselves
cancelAnimationFrame(_scrollRaf);
_scrollRaf=requestAnimationFrame(()=>{
const top=el.scrollTop;
const nearBottom=el.scrollHeight-top-el.clientHeight<250;
// scrollToBottomBtn visibility is updated below after pin state settles.
const movedUp=_lastScrollTop!==null && top<_lastScrollTop-2 && _recentMessageUpwardIntent();
_lastScrollTop=top;
if(movedUp){ _cancelBottomSettle(); _nearBottomCount=0; _scrollPinned=false; _messageUserUnpinned=true; } // #1731
else {
if(nearBottom){
_nearBottomCount=_nearBottomCount+1;
if(_nearBottomCount>=2) _scrollPinned=true;
} else { _nearBottomCount=0; _scrollPinned=false; }
if(_scrollPinned) _messageUserUnpinned=false;
} // #1360
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display=_scrollPinned?'none':'flex';
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
// Prefetch older messages before the reader hits the hard top. Prepending
// then preserving scrollTop is seamless only if there is runway left for
// the user's continued upward wheel/touch movement.
const olderPrefetchPx=Math.max(600,el.clientHeight*1.5);
if(_isSessionEndlessScrollEnabled()&&el.scrollTop=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
function _formatTurnDuration(seconds){
const n=Number(seconds);
if(!Number.isFinite(n)||n<0)return'';
const total=Math.max(0,Math.round(n));
if(total<60)return`${total}s`;
const h=Math.floor(total/3600);
const m=Math.floor((total%3600)/60);
const s=total%60;
if(h)return`${h}h ${m}m`;
return`${m}m ${s}s`;
}
function _formatActiveElapsedTimer(seconds){
const n=Number(seconds);
if(!Number.isFinite(n)||n<0)return'';
const total=Math.max(0,Math.floor(n));
const m=Math.floor(total/60);
const s=total%60;
return`${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}
let _activityElapsedTimer=null;
let _activityElapsedTimerGroup=null;
function _activityElapsedStartedAt(group){
if(!group)return null;
const raw=(group.dataset&&group.dataset.turnStartedAt!==undefined&&group.dataset.turnStartedAt!=='')
?group.dataset.turnStartedAt
:(S.session&&S.session.pending_started_at);
const started=Number(raw);
return Number.isFinite(started)&&started>0?started:null;
}
function _activityElapsedLabel(group){
const started=_activityElapsedStartedAt(group);
if(!started)return'';
return _formatActiveElapsedTimer((Date.now()/1000)-started);
}
function _setActivityElapsedStartedAt(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
const started=_activityElapsedStartedAt(group);
if(started)group.setAttribute('data-turn-started-at',String(started));
}
function _updateActiveActivityElapsedTimer(){
const group=_activityElapsedTimerGroup;
if(!group||!group.isConnected||group.getAttribute('data-live-tool-call-group')!=='1'){
_clearActivityElapsedTimer();
return;
}
const durationEl=group.querySelector('.tool-call-group-duration');
const label=_activityElapsedLabel(group);
if(label){
group.setAttribute('data-active-turn-elapsed',label);
}else{
group.removeAttribute('data-active-turn-elapsed');
}
if(durationEl){
durationEl.textContent=label?`Working ${label}`:'';
durationEl.style.display=label?'':'none';
}
}
function _startActivityElapsedTimer(group){
if(!group||group.getAttribute('data-live-tool-call-group')!=='1')return;
_setActivityElapsedStartedAt(group);
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup!==group)_clearActivityElapsedTimer();
_activityElapsedTimerGroup=group;
_updateActiveActivityElapsedTimer();
if(!_activityElapsedTimer)_activityElapsedTimer=setInterval(_updateActiveActivityElapsedTimer,1000);
}
function _clearActivityElapsedTimer(){
if(_activityElapsedTimer){
clearInterval(_activityElapsedTimer);
_activityElapsedTimer=null;
}
if(_activityElapsedTimerGroup&&_activityElapsedTimerGroup.isConnected){
_activityElapsedTimerGroup.removeAttribute('data-active-turn-elapsed');
const durationEl=_activityElapsedTimerGroup.querySelector('.tool-call-group-duration');
if(durationEl){durationEl.textContent='';durationEl.style.display='none';}
}
_activityElapsedTimerGroup=null;
}
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 _setMessageScrollToBottom(){
const el=$('messages');
if(!el) return;
_programmaticScroll=true;
el.scrollTop=el.scrollHeight;
_lastScrollTop=el.scrollTop;
_nearBottomCount=2;
_scrollPinned=true;
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
}
function _isMessagePaneNearBottom(threshold=250){
const el=$('messages');
if(!el) return false;
return el.scrollHeight-el.scrollTop-el.clientHeight<=threshold;
}
function _shouldFollowMessagesOnDomReplace(){
return !_messageUserUnpinned && (_scrollPinned || _isMessagePaneNearBottom(1200));
}
function _settleMessageScrollToBottom(force){
// Markdown post-processing (Prism, tables, Mermaid/KaTeX/PDF placeholders)
// can grow the transcript after the first scroll write. Re-apply the bottom
// position across a few frames while pinned so late layout does not leave the
// viewport a few lines above the real end. User scroll increments
// _bottomSettleToken and cancels the delayed passes.
const token=++_bottomSettleToken;
const passes=[0,16,80,180];
passes.forEach(delay=>setTimeout(()=>{
if(token!==_bottomSettleToken) return;
if(!force && (!_scrollPinned||_recentNonMessageScrollIntent())) return;
_setMessageScrollToBottom();
},delay));
requestAnimationFrame(()=>{
if(token!==_bottomSettleToken) return;
if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
requestAnimationFrame(()=>{
if(token!==_bottomSettleToken) return;
if(force || (_scrollPinned&&!_recentNonMessageScrollIntent())) _setMessageScrollToBottom();
});
});
}
function scrollIfPinned(){
if(!_scrollPinned) return;
if(_recentNonMessageScrollIntent()) return;
_settleMessageScrollToBottom(false);
}
function scrollToBottom(){
_scrollPinned=true;
_messageUserUnpinned=false;
// Write the first bottom position synchronously. A final renderMessages()
// rebuild can queue a native scroll event from the temporary scrollTop=0
// layout state; if we only schedule delayed settles, that event can cancel
// them before the viewport ever reaches the bottom.
_setMessageScrollToBottom();
_settleMessageScrollToBottom(true);
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display='none';
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
}
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 _gatewayProviderName(provider){
const text=String(provider||'').trim();
if(!text)return'';
return text.replace(/^custom:/,'').replace(/[-_]/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
}
function _gatewayRoutingLabel(routing){
if(!routing)return'';
const provider=_gatewayProviderName(routing.used_provider||routing.provider);
return provider?`via ${provider}`:'';
}
function _formatGatewayModelLabel(modelId,labelText,routing){
if(!routing)return'';
const usedModel=String(routing.used_model||'').trim();
const base=usedModel?getModelLabel(usedModel):(labelText||getModelLabel(modelId));
const via=_gatewayRoutingLabel(routing);
return via?`${base} ${via}`:base;
}
function _gatewayRoutingFailoverText(routing){
if(!routing||!routing.has_failover)return'';
const attempts=Array.isArray(routing.routing)?routing.routing:[];
const providers=attempts.map(a=>_gatewayProviderName(a&&a.provider)).filter(Boolean);
const unique=[];providers.forEach(p=>{if(!unique.includes(p))unique.push(p);});
if(unique.length>=2)return`Failover: ${unique[0]} → ${unique[unique.length-1]}`;
const from=_gatewayProviderName(routing.requested_provider);
const to=_gatewayProviderName(routing.used_provider);
if(from&&to&&from!==to)return`Failover: ${from} → ${to}`;
return'Gateway failover detected';
}
function _gatewayModelWarningText(routing){
if(!routing||!routing.model_changed)return'';
const requested=getModelLabel(routing.requested_model||'requested model');
const used=getModelLabel(routing.used_model||'served model');
return`Model switched: ${requested} → ${used}`;
}
function _latestGatewayRoutingForSession(session){
if(!session)return null;
if(session.gateway_routing)return session.gateway_routing;
const history=Array.isArray(session.gateway_routing_history)?session.gateway_routing_history:[];
return history.length?history[history.length-1]:null;
}
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 backtick fence
let fenceLen=0;
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 start a line with the same backtick char and at least
// as many backticks as the opener. Without line/fence-length anchoring, a literal
// ``` inside a code block (e.g. a nested markdown example) terminates the outer
// block at the wrong place, leaking content into the markdown stream where
// bold/italic/inline-code passes corrupt it. Fixes #1438 and #1696.
s=s.replace(/(^|\n)[ ]{0,3}(`{3,})([^\n`]*)\n(?:([\s\S]*?)\n)?[ ]{0,3}\2`*[ \t]*(?=\n|$)/g,(_,lead,_fence,info,code)=>{
const langInfo=(info||'').trim();
const langMatch=langInfo.match(/^(\w[\w+-]*)$/);
const lang=langMatch?(langMatch[1]||'').trim().toLowerCase():'';
code=code||'';
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: $$...$$ and \[...\] (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';});
// Match a single literal backslash before the display delimiter (the common LLM form).
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 \(...\) LaTeX delimiters.
// Match a single literal backslash before the delimiter (the common LLM form).
s=s.replace(/\\\((.+?)\\\)/g,(_,m)=>{math_stash.push({type:'inline',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');
// ── 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]);
// Restore raw only after markdown rewrites so literal preformatted
// content stays placeholder-protected, then let the sanitizer normalize tags.
s=s.replace(/\x00R(\d+)\x00/g,(_,i)=>rawPreStash[+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 `