@@ -12285,6 +12293,9 @@ class DashboardManager {
}
closeTerminalHistoryPanel() {
+ // If panel is pinned/docked, don't close on command execute
+ if (this.terminalHistoryPanelPinned) return;
+
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('terminalHistoryBtn');
@@ -12320,56 +12331,34 @@ class DashboardManager {
const hostId = this.terminalSession.host.id;
const timeFilter = this.terminalHistoryTimeFilter;
- // Build query params
const params = new URLSearchParams();
params.set('limit', '100');
if (query) params.set('query', query);
- // Add time filter
- if (timeFilter !== 'all') {
- const now = new Date();
- let since;
- switch (timeFilter) {
- case 'today':
- since = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- break;
- case 'week':
- since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
- break;
- case 'month':
- since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
- break;
- }
- if (since) {
- params.set('since', since.toISOString());
- }
- }
-
let endpoint;
if (allHosts) {
+ // Global history - pass session token for auth in the pop-out context
+ const token = this.terminalSession.token;
+ if (token) params.set('token', token);
endpoint = `/api/terminal/command-history?${params.toString()}`;
} else {
- // Use shell-history to fetch real commands from the remote host via SSH
- endpoint = `/api/terminal/${hostId}/shell-history?${params.toString()}`;
+ // Per-host unique commands (includes command_hash for pinning)
+ const token = this.terminalSession.token;
+ if (token) params.set('token', token);
+ endpoint = `/api/terminal/${hostId}/command-history/unique?${params.toString()}`;
}
const response = await this.apiCall(endpoint);
let commands = response.commands || [];
- // Client-side time filtering if API doesn't support it
+ // Client-side time filtering
if (timeFilter !== 'all') {
const now = new Date();
let cutoff;
switch (timeFilter) {
- case 'today':
- cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- break;
- case 'week':
- cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
- break;
- case 'month':
- cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
- break;
+ case 'today': cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); break;
+ case 'week': cutoff = new Date(now.getTime() - 7 * 86400000); break;
+ case 'month': cutoff = new Date(now.getTime() - 30 * 86400000); break;
}
if (cutoff) {
commands = commands.filter(cmd => {
@@ -12379,9 +12368,13 @@ class DashboardManager {
}
}
+ // Client-side pinned-only filter
+ if (this.terminalHistoryPinnedOnly) {
+ commands = commands.filter(cmd => cmd.is_pinned);
+ }
+
this.terminalCommandHistory = commands;
this.terminalHistorySelectedIndex = -1;
-
this.renderTerminalHistory();
} catch (e) {
@@ -12433,7 +12426,7 @@ class DashboardManager {
ondblclick="dashboard.executeHistoryCommand(${index})"
title="${this.escapeHtml(command)}">
+
@@ -12557,56 +12553,116 @@ class DashboardManager {
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
+ togglePinnedOnlyFilter() {
+ this.terminalHistoryPinnedOnly = !this.terminalHistoryPinnedOnly;
+ const btn = document.getElementById('terminalHistoryPinnedOnly');
+ if (btn) btn.classList.toggle('active', this.terminalHistoryPinnedOnly);
+ this.terminalHistorySelectedIndex = -1;
+ this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
+ }
+
+ toggleHistoryPanelPin() {
+ this.terminalHistoryPanelPinned = !this.terminalHistoryPanelPinned;
+ const btn = document.getElementById('terminalHistoryDockBtn');
+ if (btn) btn.classList.toggle('active', this.terminalHistoryPanelPinned);
+ // In pinned mode, reposition panel as docked above terminal body
+ const panel = document.getElementById('terminalHistoryPanel');
+ if (panel) panel.classList.toggle('docked', this.terminalHistoryPanelPinned);
+ }
+
+ async togglePinHistory(index) {
+ if (!this.terminalSession) return;
+ const cmd = this.terminalCommandHistory[index];
+ if (!cmd || !cmd.command_hash) return;
+ const newPinned = !cmd.is_pinned;
+
+ try {
+ const hostId = this.terminalSession.host.id;
+ await this.apiCall(`/api/terminal/${hostId}/command-history/${cmd.command_hash}/pin`, {
+ method: 'POST',
+ body: JSON.stringify({ is_pinned: newPinned })
+ });
+
+ cmd.is_pinned = newPinned;
+ this.renderTerminalHistory(this.terminalHistorySearchQuery);
+ } catch (e) {
+ this.showNotification("Impossible de modifier l'épingle", "error");
+ }
+ }
+
+ // Find the xterm instance inside the terminal iframe (or nested iframe)
+ _getTermFromIframe() {
+ // The side-terminal embeds the connect page in #terminalIframe
+ // Inside that page, the ttyd terminal runs in #terminalFrame
+ // We try both levels.
+ const iframe = document.getElementById('terminalIframe');
+ if (!iframe || !iframe.contentWindow) return null;
+ try {
+ // Level 1: the connect page itself (pop-out case)
+ const cw1 = iframe.contentWindow;
+ if (cw1.term && typeof cw1.term.paste === 'function') return { term: cw1.term, win: cw1 };
+ // Level 2: nested #terminalFrame inside connect page (side-terminal case)
+ const inner = cw1.document && cw1.document.getElementById('terminalFrame');
+ if (inner && inner.contentWindow) {
+ const cw2 = inner.contentWindow;
+ if (cw2.term && typeof cw2.term.paste === 'function') return { term: cw2.term, win: cw2 };
+ }
+ } catch (e) { /* cross-origin */ }
+ return null;
+ }
+
selectAndInsertHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
-
const command = cmd.command || '';
- // Copy to clipboard and show notification
+ const found = this._getTermFromIframe();
+ if (found) {
+ found.term.focus();
+ found.term.paste(command);
+ // Don't close panel (insert mode)
+ return;
+ }
+
+ // Fallback: postMessage to connect page
+ const iframe = document.getElementById('terminalIframe');
+ if (iframe && iframe.contentWindow) {
+ iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command }, '*');
+ return;
+ }
+
+ // Last resort: clipboard
this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
-
- // Best-effort log
- this.logTerminalCommand(command);
-
- // Close history panel and focus terminal
- this.closeTerminalHistoryPanel();
-
- // Focus the iframe
- const iframe = document.getElementById('terminalIframe');
- if (iframe && iframe.contentWindow) {
- iframe.focus();
- iframe.contentWindow.focus();
- }
- }).catch(() => {
- this.showNotification('Commande: ' + command, 'info');
- });
+ }).catch(() => { });
}
executeHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
-
const command = cmd.command || '';
- // Copy command + newline to execute it
- this.copyTextToClipboard(command + '\n').then(() => {
- this.showNotification('Commande copiée avec Enter - Collez pour exécuter', 'success');
-
- // Best-effort log
- this.logTerminalCommand(command);
-
+ const found = this._getTermFromIframe();
+ if (found) {
+ found.term.focus();
+ found.term.paste(command + '\r');
this.closeTerminalHistoryPanel();
+ return;
+ }
- const iframe = document.getElementById('terminalIframe');
- if (iframe && iframe.contentWindow) {
- iframe.focus();
- iframe.contentWindow.focus();
- }
- }).catch(() => {
- this.showNotification('Commande: ' + command, 'info');
- });
+ // Fallback: postMessage to connect page
+ const iframe = document.getElementById('terminalIframe');
+ if (iframe && iframe.contentWindow) {
+ iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command + '\r' }, '*');
+ this.closeTerminalHistoryPanel();
+ return;
+ }
+
+ // Last resort: clipboard + newline
+ this.copyTextToClipboard(command + '\n').then(() => {
+ this.showNotification('Commande copiée - Collez pour exécuter', 'success');
+ this.closeTerminalHistoryPanel();
+ }).catch(() => { });
}
copyTerminalCommand(index) {
diff --git a/app/models/terminal_command_log.py b/app/models/terminal_command_log.py
index 6e178a1..3f61533 100644
--- a/app/models/terminal_command_log.py
+++ b/app/models/terminal_command_log.py
@@ -54,6 +54,8 @@ class TerminalCommandLog(Base):
# Source identifier
source: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'terminal'"))
+ is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
+
# If command was blocked (for audit - no raw command stored)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
blocked_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
diff --git a/app/routes/terminal.py b/app/routes/terminal.py
index 23adfdf..cf7da97 100644
--- a/app/routes/terminal.py
+++ b/app/routes/terminal.py
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse, parse_qs
from datetime import datetime, timedelta, timezone
from typing import Optional
+from pydantic import BaseModel
+
from app.services.shell_history_service import shell_history_service, ShellHistoryError
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
@@ -26,7 +28,7 @@ from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
-from app.core.dependencies import get_db, get_current_user, require_debug_mode
+from app.core.dependencies import get_db, get_current_user, get_current_user_optional, require_debug_mode
_templates = Jinja2Templates(directory=str(settings.base_dir / "templates"))
from app.crud.host import HostRepository
@@ -181,15 +183,46 @@ def _get_session_token_from_request(request: Request, session_id: str, token: Op
referer = request.headers.get("referer")
if referer:
try:
- referer_qs = parse_qs(urlparse(referer).query)
- referer_token = referer_qs.get("token", [None])[0]
- if referer_token:
- return referer_token
+ parsed = urlparse(referer)
+ qs = parse_qs(parsed.query)
+ if "token" in qs:
+ return qs["token"][0]
except Exception:
pass
return None
+async def _verify_history_access(
+ host_id: str,
+ request: Request,
+ db_session: AsyncSession,
+ current_user: Optional[dict] = None
+) -> bool:
+ """Verify access to history for a specific host (JWT or Session Token)."""
+ if current_user:
+ return True
+
+ token = _get_session_token_from_request(request, host_id, token=request.query_params.get("token"))
+ if not token:
+ return False
+
+ session_repo = TerminalSessionRepository(db_session)
+ # Try by ID first
+ sessions = await session_repo.list_active_for_host(host_id)
+
+ # Try by name if no sessions found (host_id might be a name)
+ if not sessions:
+ host_repo = HostRepository(db_session)
+ host = await host_repo.get_by_name(host_id)
+ if host:
+ sessions = await session_repo.list_active_for_host(host.id)
+
+ for s in sessions:
+ if terminal_service.verify_token(token, s.token_hash):
+ return True
+
+ return False
+
def _get_session_token_from_websocket(websocket: WebSocket, session_id: str) -> Optional[str]:
token = websocket.query_params.get("token")
if token:
@@ -884,54 +917,76 @@ async def cleanup_terminal_sessions(
"expired_marked": 0,
}
+class LogCommandRequest(BaseModel):
+ command: str
+
@router.post("/sessions/{session_id}/command")
async def log_terminal_command(
session_id: str,
- command_data: dict,
+ payload: LogCommandRequest,
request: Request,
- current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
- session_repo = TerminalSessionRepository(db_session)
- session = await session_repo.get(session_id)
+ try:
+ session_repo = TerminalSessionRepository(db_session)
+ session = await session_repo.get_active_by_id(session_id)
- if not session:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Session not found"
+ if not session:
+ raise HTTPException(status_code=404, detail="Session not found")
+
+ # Verify session token
+ actual_token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
+ if not actual_token or not terminal_service.verify_token(actual_token, session.token_hash):
+ raise HTTPException(status_code=403, detail="Invalid session token")
+
+ cmd = payload.command.strip()
+ if not cmd:
+ return {"status": "ignored", "reason": "empty_command"}
+
+ if len(cmd) > _MAX_TERMINAL_COMMAND_LENGTH:
+ cmd = cmd[:_MAX_TERMINAL_COMMAND_LENGTH]
+
+ try:
+ from app.security.command_policy import get_command_policy
+ policy = get_command_policy()
+ result = policy.evaluate(cmd)
+
+ # If not allowed and not blocked, we ignore it (UNKNOWN)
+ if not result.should_log and not result.is_blocked:
+ return {"status": "ignored", "reason": "not_allowed_to_log"}
+
+ is_blocked = result.is_blocked
+ reason = result.reason
+ masked_cmd = result.masked_command or ("[BLOCKED]" if is_blocked else cmd)
+ cmd_hash = result.command_hash or hashlib.sha256(masked_cmd.encode('utf-8')).hexdigest()
+ except Exception as pe:
+ logger.warning(f"Policy evaluation failure: {pe}")
+ is_blocked = False
+ reason = str(pe)
+ masked_cmd = cmd
+ cmd_hash = hashlib.sha256(cmd.encode('utf-8')).hexdigest()
+
+ cmd_repo = TerminalCommandLogRepository(db_session)
+ await cmd_repo.create(
+ host_id=session.host_id,
+ host_name=session.host_name,
+ user_id=session.user_id,
+ username=session.username,
+ terminal_session_id=session.id,
+ command=masked_cmd,
+ command_hash=cmd_hash,
+ source="terminal_dynamic",
+ is_blocked=is_blocked,
+ blocked_reason=reason,
)
- token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
- if not token or not terminal_service.verify_token(token, session.token_hash):
- raise HTTPException(
- status_code=status.HTTP_403_FORBIDDEN,
- detail="Invalid session token"
- )
-
- command = command_data.get("command")
- if not command:
- return {"status": "ignored", "reason": "empty_command"}
-
- if len(command) > _MAX_TERMINAL_COMMAND_LENGTH:
- command = command[:_MAX_TERMINAL_COMMAND_LENGTH]
-
- cmd_repo = TerminalCommandLogRepository(db_session)
- command_hash = hashlib.sha256(command.encode()).hexdigest()
-
- await cmd_repo.create(
- host_id=session.host_id,
- host_name=session.host_name,
- user_id=session.user_id,
- username=session.username,
- terminal_session_id=session.id,
- command=command,
- command_hash=command_hash,
- source="terminal_interactive"
- )
-
- await db_session.commit()
-
- return {"status": "logged", "command_len": len(command)}
+ await db_session.commit()
+ return {"status": "success", "blocked": is_blocked}
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error logging terminal command: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
@router.get("/connect/{session_id}")
async def get_terminal_connect_page(
@@ -1049,20 +1104,24 @@ async def get_terminal_connect_page(
)
history_js = """
- let historyPanelOpen = false, historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all';
- async function toggleHistory() {
+ // ---- History panel state ----
+ let historyPanelOpen = false, historyPanelPinned = false;
+ let historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all';
+ let historyPinnedOnly = false;
+
+ // ---- Panel open/close ----
+ function toggleHistory() {
+ if (historyPanelOpen && !historyPanelPinned) { closeHistoryPanel(); return; }
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
- if (historyPanelOpen) { closeHistoryPanel(); }
- else {
- panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
- historyPanelOpen = true; historySelectedIndex = -1;
- const si = document.getElementById('terminalHistorySearch');
- if (si) { si.focus(); si.select(); }
- if (historyData.length === 0) loadHistory();
- }
+ panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
+ historyPanelOpen = true; historySelectedIndex = -1;
+ const si = document.getElementById('terminalHistorySearch');
+ if (si) { si.focus(); si.select(); }
+ if (historyData.length === 0) loadHistory();
}
function closeHistoryPanel() {
+ if (historyPanelPinned) return;
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
panel.classList.remove('open'); btn.classList.remove('active');
@@ -1070,15 +1129,33 @@ async def get_terminal_connect_page(
setTimeout(() => { try { panel.style.display = 'none'; } catch(e){} }, 200);
document.getElementById('terminalFrame').focus();
}
+ function toggleHistoryPin() {
+ historyPanelPinned = !historyPanelPinned;
+ const btn = document.getElementById('btnHistoryPin');
+ const panel = document.getElementById('terminalHistoryPanel');
+ btn?.classList.toggle('active', historyPanelPinned);
+ panel?.classList.toggle('docked', historyPanelPinned);
+ }
+ function togglePinnedOnly() {
+ historyPinnedOnly = !historyPinnedOnly;
+ const btn = document.getElementById('btnPinnedOnly');
+ btn?.classList.toggle('active', historyPinnedOnly);
+ historySelectedIndex = -1; loadHistory();
+ }
+
+ // ---- Data loading ----
async function loadHistory() {
const list = document.getElementById('terminalHistoryList');
list.innerHTML = '
Chargement...
';
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const query = historySearchQuery;
- let ep = allHosts ? '/api/terminal/command-history?limit=100' : `/api/terminal/${HOST_ID}/shell-history?limit=100`;
- if (query) ep += (ep.includes('?') ? '&' : '?') + 'query=' + encodeURIComponent(query);
+ let ep = allHosts
+ ? `/api/terminal/command-history?limit=100&token=${encodeURIComponent(TOKEN)}`
+ : `/api/terminal/${HOST_ID}/command-history/unique?limit=100&token=${encodeURIComponent(TOKEN)}`;
+ if (query) ep += '&query=' + encodeURIComponent(query);
try {
const res = await fetch(ep, { headers: { 'Authorization': 'Bearer ' + TOKEN } });
+ if (!res.ok) { list.innerHTML = `
Erreur ${res.status}
`; return; }
const data = await res.json();
let cmds = data.commands || [];
if (historyTimeFilter !== 'all') {
@@ -1089,38 +1166,82 @@ async def get_terminal_connect_page(
else if (historyTimeFilter === 'month') cutoff = new Date(now.getTime() - 30*86400000);
if (cutoff) cmds = cmds.filter(c => new Date(c.last_used||c.created_at) >= cutoff);
}
+ if (historyPinnedOnly) cmds = cmds.filter(c => c.is_pinned);
historyData = cmds; historySelectedIndex = -1; renderHistory();
- } catch(e) { list.innerHTML = '
Erreur
'; }
+ } catch(e) { list.innerHTML = '
Erreur réseau
'; }
}
+ async function togglePinH(i) {
+ const cmd = historyData[i];
+ if (!cmd || !cmd.command_hash) return;
+ const newPinned = !cmd.is_pinned;
+ try {
+ const res = await fetch(`/api/terminal/${HOST_ID}/command-history/${cmd.command_hash}/pin?token=${encodeURIComponent(TOKEN)}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN },
+ body: JSON.stringify({ is_pinned: newPinned })
+ });
+ if (res.ok) { cmd.is_pinned = newPinned; renderHistory(); }
+ } catch(e) {}
+ }
+
+ // ---- Helpers ----
function escH(t){return (t||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');}
- function escRE(s){return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');}
+ function escRE(s){return s.replace(/[.*+?^${}()|[\\\\]\\\\\\\\]/g, '\\\\\\\\$&');}
function relTime(d){if(!d)return'';const dt=new Date(d),now=new Date(),ds=Math.floor((now-dt)/1000);if(ds<60)return'À l instant';const dm=Math.floor(ds/60),dh=Math.floor(dm/60),dd=Math.floor(dh/24);if(dm<60)return`Il y a ${dm}min`;if(dh<24)return`Il y a ${dh}h`;if(dd<7)return`Il y a ${dd}j`;return dt.toLocaleDateString('fr-FR',{day:'numeric',month:'short'});}
+
+ // ---- Execution ----
+ function execH(i){
+ const cmd = historyData[i];
+ if(cmd && cmd.command) {
+ const fr = document.getElementById('terminalFrame');
+ const cw = fr ? fr.contentWindow : null;
+ if(cw && cw.term) {
+ cw.term.focus();
+ cw.term.paste(cmd.command + '\\r');
+ closeHistoryPanel();
+ } else if (cw) {
+ cw.postMessage({ type: 'terminal:paste', text: cmd.command + '\\r' }, '*');
+ closeHistoryPanel();
+ } else {
+ copyH(i);
+ }
+ }
+ }
+ function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});}
+
+ // ---- Rendering ----
function renderHistory(){
const list=document.getElementById('terminalHistoryList'),q=historySearchQuery||'';
- if(!historyData.length){list.innerHTML=`
${q?`Aucun résultat pour "${escH(q)}"`:'Aucune commande'}
`;return;}
+ if(!historyData.length){list.innerHTML=`
${q?`Aucun résultat pour "${escH(q)}"`:historyPinnedOnly?'Aucune commande épinglée':'Aucune commande'}
`;return;}
list.innerHTML=historyData.map((cmd,i)=>{
- const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex;
- let dc=escH(c.length>60?c.substring(0,60)+'...':c);
+ const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex,pinned=cmd.is_pinned;
+ let dc=escH(c.length>80?c.substring(0,80)+'...':c);
if(q)dc=dc.replace(new RegExp(`(${escRE(q)})`,'gi'),'
$1');
- return `
${dc}
${ta}${ec>1?`×${ec}`:''}
`;
+ return `
${pinned?'':''}${dc}
${ta}${ec>1?`×${ec}`:''}
`;
}).join('');
if(historySelectedIndex>=0){const s=list.querySelector('.selected');if(s)s.scrollIntoView({block:'nearest',behavior:'smooth'});}
}
- function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)selectH(historySelectedIndex);else if(l>0)selectH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}}
+ function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)execH(historySelectedIndex);else if(l>0)execH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}}
let _st=null;
function searchHistory(q){historySearchQuery=q;historySelectedIndex=-1;if(_st)clearTimeout(_st);_st=setTimeout(()=>loadHistory(),250);}
function clearHistorySearch(){const i=document.getElementById('terminalHistorySearch');if(i){i.value='';i.focus();}historySearchQuery='';historySelectedIndex=-1;loadHistory();}
function setHistoryTimeFilter(v){historyTimeFilter=v;historySelectedIndex=-1;loadHistory();}
function toggleHistoryScope(){historySelectedIndex=-1;loadHistory();}
- function selectH(i){historySelectedIndex=i;renderHistory();}
- function execH(i){selectH(i);closeHistoryPanel();}
- function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});}
document.addEventListener('keydown',(e)=>{
- if(e.key==='Escape'&&historyPanelOpen)closeHistoryPanel();
+ if(e.key==='Escape'&&historyPanelOpen&&!historyPanelPinned)closeHistoryPanel();
if((e.ctrlKey||e.metaKey)&&e.key==='r'){e.preventDefault();toggleHistory();}
});
+ // postMessage handler so parent can paste into this terminal
+ window.addEventListener('message', (ev) => {
+ if (ev.data && ev.data.type === 'terminal:paste') {
+ const fr = document.getElementById('terminalFrame');
+ const cw = fr ? fr.contentWindow : null;
+ if (cw && cw.term) { cw.term.focus(); cw.term.paste(ev.data.text); }
+ }
+ });
"""
+
script_block = (
"