Files
hermes-webui/static/terminal.js
T
2026-05-01 19:52:05 +08:00

633 lines
20 KiB
JavaScript

const TERMINAL_UI={
open:false,
collapsed:false,
sessionId:null,
workspace:null,
source:null,
term:null,
fitAddon:null,
resizeObserver:null,
resizeTimer:null,
closeTimer:null,
typedLine:'',
height:null,
resizeHandleReady:false,
resizing:false,
resizeStartY:0,
resizeStartHeight:0,
};
const TERMINAL_HEIGHT_DEFAULT=260;
const TERMINAL_HEIGHT_MIN=180;
const TERMINAL_HEIGHT_MAX=520;
const TERMINAL_MOBILE_HEIGHT_DEFAULT=190;
const TERMINAL_MOBILE_HEIGHT_MIN=140;
const TERMINAL_MOBILE_HEIGHT_MAX=300;
function _terminalEls(){
return {
panel:$('composerTerminalPanel'),
inner:$('composerTerminalPanel')&&$('composerTerminalPanel').querySelector('.composer-terminal-inner'),
dock:$('composerTerminalDock'),
viewport:$('terminalViewport'),
surface:$('terminalSurface'),
toggle:$('btnTerminalToggle'),
workspace:$('terminalWorkspaceLabel'),
dockWorkspace:$('terminalDockWorkspaceLabel'),
handle:$('terminalResizeHandle'),
};
}
function _terminalSessionId(){
return S.session&&S.session.session_id;
}
function _terminalWorkspaceName(){
const ws=S.session&&S.session.workspace;
if(!ws)return '';
const parts=String(ws).split(/[\\/]+/).filter(Boolean);
return parts[parts.length-1]||ws;
}
function _isTerminalCloseCommand(value){
return ['exit','quit','logout','close'].includes(String(value||'').trim().toLowerCase());
}
function _trackTerminalInput(data){
if(data==='\r'||data==='\n'){
const command=TERMINAL_UI.typedLine;
TERMINAL_UI.typedLine='';
return command;
}
if(data==='\u0003'){
TERMINAL_UI.typedLine='';
return null;
}
if(data==='\u007f'||data==='\b'){
TERMINAL_UI.typedLine=TERMINAL_UI.typedLine.slice(0,-1);
return null;
}
if(data.length===1&&data>=' '){
TERMINAL_UI.typedLine+=data;
}else if(data.length>1&&/^[\x20-\x7e]+$/.test(data)){
TERMINAL_UI.typedLine+=data;
}
return null;
}
function _terminalCssVar(name,fallback){
const value=getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value||fallback;
}
function _terminalTheme(){
const isDark=document.documentElement.classList.contains('dark');
const background=_terminalCssVar('--code-bg',isDark?'#1A1A2E':'#F5F0E5');
const foreground=_terminalCssVar('--pre-text',_terminalCssVar('--text',isDark?'#E2E8F0':'#1A1610'));
const muted=_terminalCssVar('--muted',isDark?'#C0C0C0':'#5C5344');
const accent=_terminalCssVar('--accent-text',_terminalCssVar('--accent',isDark?'#FFD700':'#8B6508'));
const error=_terminalCssVar('--error',isDark?'#EF5350':'#C62828');
const success=_terminalCssVar('--success',isDark?'#4CAF50':'#3D8B40');
const warning=_terminalCssVar('--warning',isDark?'#FFA726':'#E68A00');
const info=_terminalCssVar('--info',isDark?'#4DD0E1':'#0288A8');
return {
background,
foreground,
cursor:accent,
selectionBackground:_terminalCssVar('--accent-bg-strong',isDark?'rgba(255,215,0,.18)':'rgba(184,134,11,.18)'),
black:isDark?'#0D0D1A':'#1A1610',
red:error,
green:success,
yellow:warning,
blue:info,
magenta:accent,
cyan:info,
white:foreground,
brightBlack:muted,
brightRed:error,
brightGreen:success,
brightYellow:accent,
brightBlue:info,
brightMagenta:accent,
brightCyan:info,
brightWhite:isDark?'#FFFFFF':'#0F0D08',
};
}
function syncComposerTerminalTheme(){
if(TERMINAL_UI.term)TERMINAL_UI.term.options.theme=_terminalTheme();
}
function _xtermReady(){
return typeof window.Terminal==='function';
}
function _ensureXterm(){
const {surface}= _terminalEls();
if(!surface)return null;
if(TERMINAL_UI.term)return TERMINAL_UI.term;
if(!_xtermReady()){
surface.textContent='Terminal library failed to load. Check network access to cdn.jsdelivr.net.';
return null;
}
const term=new window.Terminal({
cursorBlink:true,
fontSize:13,
fontFamily:'Menlo, Monaco, Consolas, "Liberation Mono", monospace',
scrollback:1000,
convertEol:false,
theme:_terminalTheme(),
});
let fitAddon=null;
if(window.FitAddon&&typeof window.FitAddon.FitAddon==='function'){
fitAddon=new window.FitAddon.FitAddon();
term.loadAddon(fitAddon);
}
if(window.WebLinksAddon&&typeof window.WebLinksAddon.WebLinksAddon==='function'){
term.loadAddon(new window.WebLinksAddon.WebLinksAddon());
}
term.open(surface);
term.onData(data=>{
const completedCommand=_trackTerminalInput(data);
if(completedCommand!==null&&_isTerminalCloseCommand(completedCommand)){
closeComposerTerminal();
return;
}
const sid=TERMINAL_UI.sessionId||_terminalSessionId();
if(!sid)return;
api('/api/terminal/input',{method:'POST',body:JSON.stringify({
session_id:sid,
data,
})}).catch(e=>showToast(t('terminal_input_failed')+e.message,2600,'error'));
});
TERMINAL_UI.term=term;
TERMINAL_UI.fitAddon=fitAddon;
_fitTerminal();
return term;
}
function _terminalDimensions(){
const term=TERMINAL_UI.term;
if(term&&term.cols&&term.rows)return {rows:term.rows,cols:term.cols};
return {rows:18,cols:80};
}
function _terminalHeightBounds(){
const mobile=window.matchMedia&&window.matchMedia('(max-width: 700px)').matches;
const min=mobile?TERMINAL_MOBILE_HEIGHT_MIN:TERMINAL_HEIGHT_MIN;
const maxByViewport=Math.floor(window.innerHeight*(mobile?0.44:0.5));
const hardMax=mobile?TERMINAL_MOBILE_HEIGHT_MAX:TERMINAL_HEIGHT_MAX;
return {
min,
max:Math.max(min,Math.min(hardMax,maxByViewport)),
defaultHeight:mobile?TERMINAL_MOBILE_HEIGHT_DEFAULT:TERMINAL_HEIGHT_DEFAULT,
};
}
function _clampTerminalHeight(height){
const bounds=_terminalHeightBounds();
const n=Number(height);
const fallback=TERMINAL_UI.height||bounds.defaultHeight;
return Math.max(bounds.min,Math.min(bounds.max,Number.isFinite(n)?n:fallback));
}
function _applyTerminalHeight(height){
const {inner,handle}= _terminalEls();
const next=_clampTerminalHeight(height);
TERMINAL_UI.height=next;
if(inner)inner.style.setProperty('--composer-terminal-height',next+'px');
if(handle){
const bounds=_terminalHeightBounds();
handle.setAttribute('aria-valuemin',String(bounds.min));
handle.setAttribute('aria-valuemax',String(bounds.max));
handle.setAttribute('aria-valuenow',String(next));
}
if(TERMINAL_UI.open&&!TERMINAL_UI.collapsed){
_fitTerminal();
_syncTerminalTranscriptSpace(true);
}
return next;
}
function _resetTerminalHeightForViewport(){
const bounds=_terminalHeightBounds();
_applyTerminalHeight(TERMINAL_UI.height||bounds.defaultHeight);
}
function _startTerminalHeightResize(ev){
if(ev.pointerType==='touch')return;
const {inner,handle}= _terminalEls();
if(!inner||!handle)return;
ev.preventDefault();
TERMINAL_UI.resizing=true;
TERMINAL_UI.resizeStartY=ev.clientY;
TERMINAL_UI.resizeStartHeight=TERMINAL_UI.height||inner.getBoundingClientRect().height||_terminalHeightBounds().defaultHeight;
inner.classList.add('is-resizing');
try{handle.setPointerCapture(ev.pointerId);}catch(_){}
}
function _moveTerminalHeightResize(ev){
if(!TERMINAL_UI.resizing)return;
ev.preventDefault();
_applyTerminalHeight(TERMINAL_UI.resizeStartHeight+(TERMINAL_UI.resizeStartY-ev.clientY));
}
function _endTerminalHeightResize(ev){
if(!TERMINAL_UI.resizing)return;
TERMINAL_UI.resizing=false;
const {inner,handle}= _terminalEls();
if(inner)inner.classList.remove('is-resizing');
if(handle&&ev&&ev.pointerId!==undefined)try{handle.releasePointerCapture(ev.pointerId);}catch(_){}
_fitTerminal();
}
function _handleTerminalResizeKey(ev){
let delta=0;
if(ev.key==='ArrowUp')delta=16;
else if(ev.key==='ArrowDown')delta=-16;
else if(ev.key==='PageUp')delta=64;
else if(ev.key==='PageDown')delta=-64;
else if(ev.key==='Home'){
ev.preventDefault();
return _applyTerminalHeight(_terminalHeightBounds().min);
}
else if(ev.key==='End'){
ev.preventDefault();
return _applyTerminalHeight(_terminalHeightBounds().max);
}
else return;
ev.preventDefault();
_applyTerminalHeight((TERMINAL_UI.height||_terminalHeightBounds().defaultHeight)+delta);
}
function _initTerminalResizeHandle(){
if(TERMINAL_UI.resizeHandleReady)return;
const {handle}= _terminalEls();
if(!handle)return;
TERMINAL_UI.resizeHandleReady=true;
handle.addEventListener('pointerdown',_startTerminalHeightResize);
handle.addEventListener('pointermove',_moveTerminalHeightResize);
handle.addEventListener('pointerup',_endTerminalHeightResize);
handle.addEventListener('pointercancel',_endTerminalHeightResize);
handle.addEventListener('keydown',_handleTerminalResizeKey);
}
function _terminalMessagesEl(){
return document.getElementById('messages');
}
function _terminalIsMessagesNearBottom(el){
if(!el)return false;
return el.scrollHeight-el.scrollTop-el.clientHeight<150;
}
function _syncTerminalTranscriptSpace(open,opts){
opts=opts||{};
const messages=_terminalMessagesEl();
if(!messages)return;
const wasNearBottom=_terminalIsMessagesNearBottom(messages);
if(!open){
messages.classList.remove('terminal-open');
messages.classList.remove('terminal-collapsed');
messages.classList.remove('terminal-expanding-from-dock');
messages.style.removeProperty('--terminal-card-height');
messages.style.removeProperty('--terminal-dock-height');
if(wasNearBottom&&typeof scrollToBottom==='function')requestAnimationFrame(scrollToBottom);
return;
}
if(open==='collapsed'){
messages.classList.remove('terminal-open');
messages.classList.add('terminal-collapsed');
}else{
messages.classList.add('terminal-open');
messages.classList.remove('terminal-collapsed');
}
const measure=()=>{
if(!TERMINAL_UI.open)return;
const {panel,inner,dock}= _terminalEls();
const target=open==='collapsed'?(dock||panel):(inner||panel);
const h=target&&target.getBoundingClientRect().height;
if(h>0){
if(open==='collapsed')messages.style.setProperty('--terminal-dock-height',Math.ceil(h+24)+'px');
else messages.style.setProperty('--terminal-card-height',Math.ceil(h+24)+'px');
}
if(wasNearBottom&&typeof scrollToBottom==='function')scrollToBottom();
};
if(opts.immediate)measure();
requestAnimationFrame(measure);
setTimeout(measure,420);
}
function _fitTerminal(){
const term=TERMINAL_UI.term;
if(!term)return;
if(TERMINAL_UI.collapsed)return;
try{
if(TERMINAL_UI.fitAddon)TERMINAL_UI.fitAddon.fit();
}catch(_){}
_syncTerminalTranscriptSpace(true);
_scheduleTerminalResize();
}
function _setTerminalChromeState(state){
const {panel,inner,dock,workspace,dockWorkspace}= _terminalEls();
const composerWrap=$('composerWrap');
if(!panel)return;
const collapsed=state==='collapsed';
const expanded=state==='expanded';
if(composerWrap)composerWrap.classList.toggle('terminal-dock-visible',collapsed);
panel.hidden=!(collapsed||expanded);
panel.classList.toggle('is-open',expanded);
panel.classList.toggle('is-collapsed',collapsed);
if(inner)inner.setAttribute('aria-hidden',collapsed?'true':'false');
if(dock)dock.hidden=!collapsed;
const label=_terminalWorkspaceName();
if(workspace)workspace.textContent=label;
if(dockWorkspace)dockWorkspace.textContent=label;
}
function syncTerminalButton(){
const {toggle}= _terminalEls();
const currentSid=_terminalSessionId();
const currentWorkspace=S.session&&S.session.workspace;
if(TERMINAL_UI.open&&TERMINAL_UI.sessionId&&(currentSid!==TERMINAL_UI.sessionId||currentWorkspace!==TERMINAL_UI.workspace)){
closeComposerTerminal(TERMINAL_UI.sessionId);
}
if(!toggle)return;
const hasWorkspace=!!(S.session&&S.session.workspace);
toggle.disabled=!hasWorkspace;
toggle.classList.toggle('active',TERMINAL_UI.open);
toggle.setAttribute('aria-pressed',TERMINAL_UI.open?'true':'false');
toggle.title=hasWorkspace?(TERMINAL_UI.collapsed?t('terminal_expand'):t('terminal_open_title')):t('terminal_no_workspace_title');
toggle.setAttribute('aria-label',toggle.title);
}
function focusComposerTerminalInput(){
if(TERMINAL_UI.term)TERMINAL_UI.term.focus();
}
function _connectTerminalOutput(){
const sid=_terminalSessionId();
if(!sid)return;
if(TERMINAL_UI.source){
try{TERMINAL_UI.source.close();}catch(_){}
TERMINAL_UI.source=null;
}
const url=new URL('api/terminal/output',document.baseURI||location.href);
url.searchParams.set('session_id',sid);
const source=new EventSource(url.href,{withCredentials:true});
TERMINAL_UI.source=source;
source.addEventListener('output',ev=>{
if(TERMINAL_UI.source!==source)return;
let text='';
try{text=(JSON.parse(ev.data)||{}).text||'';}
catch(_){text=ev.data||'';}
if(TERMINAL_UI.term&&text)TERMINAL_UI.term.write(text);
});
source.addEventListener('terminal_closed',()=>{
if(TERMINAL_UI.source!==source)return;
if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal closed]\r\n');
try{source.close();}catch(_){}
TERMINAL_UI.source=null;
setTimeout(()=>closeComposerTerminal(null,{skipApi:true}),260);
});
source.addEventListener('terminal_error',ev=>{
if(TERMINAL_UI.source!==source)return;
let msg=t('terminal_error');
try{msg=(JSON.parse(ev.data)||{}).error||msg;}catch(_){}
if(TERMINAL_UI.term)TERMINAL_UI.term.writeln('\r\n[terminal error] '+msg+'\r\n');
try{source.close();}catch(_){}
TERMINAL_UI.source=null;
});
}
async function _startComposerTerminal(restart=false){
const sid=_terminalSessionId();
if(!sid||!(S.session&&S.session.workspace)){
showToast(t('terminal_no_workspace_title'),2600,'warning');
syncTerminalButton();
return;
}
const term=_ensureXterm();
if(!term)return;
_fitTerminal();
const dims=_terminalDimensions();
await api('/api/terminal/start',{method:'POST',body:JSON.stringify({
session_id:sid,
rows:dims.rows,
cols:dims.cols,
restart:!!restart,
})});
TERMINAL_UI.sessionId=sid;
TERMINAL_UI.workspace=S.session&&S.session.workspace||null;
TERMINAL_UI.typedLine='';
_connectTerminalOutput();
_resizeComposerTerminal();
}
async function toggleComposerTerminal(force){
const next=typeof force==='boolean'?force:!TERMINAL_UI.open;
if(next){
if(TERMINAL_UI.open){
if(TERMINAL_UI.collapsed)expandComposerTerminal();
else focusComposerTerminalInput();
return;
}
const {panel,inner}= _terminalEls();
const messages=_terminalMessagesEl();
if(!panel)return;
clearTimeout(TERMINAL_UI.closeTimer);
_initTerminalResizeHandle();
_resetTerminalHeightForViewport();
if(messages)messages.classList.add('terminal-expanding-from-dock');
_setTerminalChromeState('expanded');
TERMINAL_UI.open=true;
TERMINAL_UI.collapsed=false;
_syncTerminalTranscriptSpace(true,{immediate:true});
if(messages)void messages.offsetHeight;
requestAnimationFrame(()=>{
panel.classList.add('is-open');
window.setTimeout(_fitTerminal,80);
setTimeout(()=>{
if(messages)messages.classList.remove('terminal-expanding-from-dock');
},120);
});
syncTerminalButton();
if(!TERMINAL_UI.resizeObserver&&window.ResizeObserver){
TERMINAL_UI.resizeObserver=new ResizeObserver(()=>_fitTerminal());
TERMINAL_UI.resizeObserver.observe(inner||panel);
}
try{
await _startComposerTerminal(false);
focusComposerTerminalInput();
}catch(e){
showToast(t('terminal_start_failed')+e.message,3200,'error');
}
}else{
await closeComposerTerminal();
}
}
function collapseComposerTerminal(){
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
TERMINAL_UI.collapsed=true;
_setTerminalChromeState('collapsed');
_syncTerminalTranscriptSpace('collapsed');
syncTerminalButton();
}
function expandComposerTerminal(){
if(!TERMINAL_UI.open)return;
const {panel}= _terminalEls();
const messages=_terminalMessagesEl();
TERMINAL_UI.collapsed=false;
clearTimeout(TERMINAL_UI.closeTimer);
if(panel)panel.classList.add('is-expanding-from-dock');
if(messages)messages.classList.add('terminal-expanding-from-dock');
_syncTerminalTranscriptSpace(true,{immediate:true});
if(messages)void messages.offsetHeight;
_setTerminalChromeState('expanded');
_resetTerminalHeightForViewport();
requestAnimationFrame(()=>{
_fitTerminal();
focusComposerTerminalInput();
setTimeout(()=>{
if(panel)panel.classList.remove('is-expanding-from-dock');
if(messages)messages.classList.remove('terminal-expanding-from-dock');
},120);
});
syncTerminalButton();
}
function _disposeXterm(){
if(TERMINAL_UI.term){
try{TERMINAL_UI.term.dispose();}catch(_){}
}
TERMINAL_UI.term=null;
TERMINAL_UI.fitAddon=null;
TERMINAL_UI.typedLine='';
const {surface}= _terminalEls();
if(surface)surface.textContent='';
}
async function closeComposerTerminal(sessionId,opts){
opts=opts||{};
const sid=sessionId||TERMINAL_UI.sessionId||_terminalSessionId();
if(TERMINAL_UI.source){
try{TERMINAL_UI.source.close();}catch(_){}
TERMINAL_UI.source=null;
}
if(sid&&!opts.skipApi){
api('/api/terminal/close',{method:'POST',body:JSON.stringify({session_id:sid})}).catch(()=>{});
}
const {panel}= _terminalEls();
if(panel){
panel.classList.remove('is-open','is-collapsed','is-expanding-from-dock');
_syncTerminalTranscriptSpace(false);
clearTimeout(TERMINAL_UI.closeTimer);
TERMINAL_UI.closeTimer=setTimeout(()=>{
if(!TERMINAL_UI.open)panel.hidden=true;
_disposeXterm();
},280);
}else{
_syncTerminalTranscriptSpace(false);
_disposeXterm();
}
TERMINAL_UI.open=false;
TERMINAL_UI.collapsed=false;
const composerWrap=$('composerWrap');
if(composerWrap)composerWrap.classList.remove('terminal-dock-visible');
TERMINAL_UI.sessionId=null;
TERMINAL_UI.workspace=null;
syncTerminalButton();
}
async function restartComposerTerminal(){
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
if(TERMINAL_UI.source){
try{TERMINAL_UI.source.close();}catch(_){}
TERMINAL_UI.source=null;
}
if(TERMINAL_UI.term)TERMINAL_UI.term.reset();
try{await _startComposerTerminal(true);}
catch(e){showToast(t('terminal_start_failed')+e.message,3200,'error');}
}
function clearComposerTerminal(){
if(TERMINAL_UI.term)TERMINAL_UI.term.clear();
}
function _terminalBufferText(){
const term=TERMINAL_UI.term;
if(!term||!term.buffer)return '';
const buffer=term.buffer.active;
const lines=[];
for(let i=0;i<buffer.length;i++){
const line=buffer.getLine(i);
if(line)lines.push(line.translateToString(true));
}
return lines.join('\n').trim();
}
async function copyComposerTerminalOutput(){
try{
const selection=TERMINAL_UI.term&&TERMINAL_UI.term.getSelection?TERMINAL_UI.term.getSelection():'';
await navigator.clipboard.writeText(selection||_terminalBufferText());
showToast(t('copied'));
}catch(e){
showToast(t('terminal_copy_failed')+e.message,2600,'error');
}
}
async function submitComposerTerminalInput(ev){
if(ev)ev.preventDefault();
}
function _scheduleTerminalResize(){
clearTimeout(TERMINAL_UI.resizeTimer);
TERMINAL_UI.resizeTimer=setTimeout(_resizeComposerTerminal,120);
}
async function _resizeComposerTerminal(){
if(!TERMINAL_UI.open||TERMINAL_UI.collapsed)return;
const sid=TERMINAL_UI.sessionId||_terminalSessionId();
if(!sid)return;
const dims=_terminalDimensions();
try{
await api('/api/terminal/resize',{method:'POST',body:JSON.stringify({
session_id:sid,
rows:dims.rows,
cols:dims.cols,
})});
}catch(_){}
}
window.addEventListener('beforeunload',()=>{
if(TERMINAL_UI.source)try{TERMINAL_UI.source.close();}catch(_){}
if(TERMINAL_UI.sessionId){
const url=new URL('api/terminal/close',document.baseURI||location.href).href;
const body=JSON.stringify({session_id:TERMINAL_UI.sessionId});
try{
navigator.sendBeacon(url,new Blob([body],{type:'application/json'}));
}catch(_){
try{fetch(url,{method:'POST',credentials:'include',headers:{'Content-Type':'application/json'},body,keepalive:true});}catch(__){}
}
}
});
window.addEventListener('resize',()=>{
if(!TERMINAL_UI.open)return;
if(TERMINAL_UI.collapsed){
_syncTerminalTranscriptSpace('collapsed');
return;
}
_resetTerminalHeightForViewport();
});
if(window.MutationObserver){
new MutationObserver(syncComposerTerminalTheme).observe(document.documentElement,{
attributes:true,
attributeFilter:['class','data-skin'],
});
}