Use this script to chat and launch commands given an MCP server running on localhost:3000
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OllamaTerminal — Dracula</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Fira+Code:wght@300;400;500;600&display=swap" rel="stylesheet" />
<style>
/* ─── DRACULA PALETTE ─────────────────────────────────────────── */
:root {
--bg: #282a36;
--bg-dark: #1e1f29;
--bg-darker: #191a24;
--surface: #21222c;
--surface2: #2d2f3d;
--border: #44475a;
--comment: #6272a4;
--fg: #f8f8f2;
--fg-dim: #c8c8c0;
--cyan: #8be9fd;
--green: #50fa7b;
--orange: #ffb86c;
--pink: #ff79c6;
--purple: #bd93f9;
--red: #ff5555;
--yellow: #f1fa8c;
--accent: #bd93f9;
--accent-glow: rgba(189,147,249,0.25);
--green-glow: rgba(80,250,123,0.2);
--cyan-glow: rgba(139,233,253,0.2);
--radius: 4px;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg-darker);
color: var(--fg);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.55;
overflow: hidden;
}
/* ─── SCANLINE OVERLAY ────────────────────────────────────────── */
body::before {
content: '';
position: fixed; inset: 0; z-index: 9999;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,0.07) 2px,
rgba(0,0,0,0.07) 4px
);
}
/* ─── CHROME / WINDOW ─────────────────────────────────────────── */
#app {
display: flex;
flex-direction: column;
height: 100vh;
border: 1px solid var(--border);
background: var(--bg);
position: relative;
}
/* ─── TITLE BAR ───────────────────────────────────────────────── */
#titleBar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: var(--bg-darker);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
user-select: none;
}
.traffic-lights { display: flex; gap: 6px; align-items: center; }
.tl { width: 12px; height: 12px; border-radius: 50%; cursor: pointer; transition: filter .15s; }
.tl:hover { filter: brightness(1.3); }
.tl-red { background: var(--red); }
.tl-yellow { background: var(--yellow); }
.tl-green { background: var(--green); }
#titleText {
flex: 1;
text-align: center;
font-size: 11px;
font-weight: 500;
color: var(--comment);
letter-spacing: 0.12em;
text-transform: uppercase;
}
.title-badge {
font-size: 10px;
padding: 1px 7px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--purple);
letter-spacing: 0.08em;
}
/* ─── SESSION / MULTIPLEXER BAR ───────────────────────────────── */
#sessionBar {
display: flex;
align-items: stretch;
gap: 2px;
padding: 4px 8px 0;
background: var(--bg-darker);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 34px;
flex-wrap: wrap;
}
.session-wrapper {
display: flex;
align-items: center;
position: relative;
}
.session-tab {
display: inline-flex;
align-items: center;
padding: 4px 12px;
font-size: 11.5px;
font-weight: 500;
cursor: pointer;
color: var(--comment);
border: 1px solid transparent;
border-bottom: none;
border-radius: var(--radius) var(--radius) 0 0;
background: transparent;
transition: color .15s, background .15s, border-color .15s;
white-space: nowrap;
letter-spacing: 0.03em;
position: relative;
top: 1px;
}
.session-tab:hover {
color: var(--fg-dim);
background: var(--surface);
border-color: var(--border);
}
.session-tab.active {
color: var(--purple);
background: var(--bg);
border-color: var(--border);
border-bottom-color: var(--bg);
font-weight: 600;
}
.session-tab.active::before {
content: '▸ ';
color: var(--pink);
font-size: 10px;
}
.delete-btn {
font-size: 9px;
color: var(--comment);
cursor: pointer;
padding: 0 4px;
transition: color .15s;
opacity: 0.5;
}
.session-wrapper:hover .delete-btn { opacity: 1; }
.delete-btn:hover { color: var(--red); }
.rename-input {
background: var(--surface2);
border: 1px solid var(--purple);
border-radius: var(--radius);
color: var(--fg);
font-family: var(--font-mono);
font-size: 11.5px;
padding: 2px 8px;
outline: none;
width: 110px;
box-shadow: 0 0 6px var(--accent-glow);
}
/* ─── BAR ACTIONS (right-side controls) ──────────────────────── */
.bar-actions-group {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
padding: 0 4px 4px;
}
.model-selector-wrapper {
display: flex;
align-items: center;
gap: 5px;
font-size: 10.5px;
color: var(--comment);
}
.model-select-native {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--cyan);
font-family: var(--font-mono);
font-size: 10.5px;
padding: 2px 22px 2px 7px;
cursor: pointer;
outline: none;
transition: border-color .15s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath fill='%236272a4' d='M0 0l5 6 5-6z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 6px center;
}
.model-select-native:hover,
.model-select-native:focus { border-color: var(--purple); }
.model-select-native option { background: var(--surface); }
.bar-btn {
font-family: var(--font-mono);
font-size: 10.5px;
padding: 3px 9px;
border-radius: var(--radius);
border: 1px solid;
cursor: pointer;
transition: background .15s, color .15s, box-shadow .15s;
letter-spacing: 0.04em;
font-weight: 500;
}
.new-session-btn {
color: var(--green);
border-color: var(--green);
background: transparent;
}
.new-session-btn:hover {
background: rgba(80,250,123,0.12);
box-shadow: 0 0 8px var(--green-glow);
}
.wipe-workspace-btn {
color: var(--red);
border-color: var(--red);
background: transparent;
}
.wipe-workspace-btn:hover {
background: rgba(255,85,85,0.12);
box-shadow: 0 0 8px rgba(255,85,85,0.3);
}
/* ─── STATUS BAR ──────────────────────────────────────────────── */
#statusBar {
display: flex;
align-items: center;
gap: 14px;
padding: 3px 14px;
background: var(--bg-darker);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
font-size: 10px;
color: var(--comment);
letter-spacing: 0.04em;
}
.status-dot {
width: 6px; height: 6px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
.status-dot.online { background: var(--green); box-shadow: 0 0 5px var(--green); animation: pulse-green 2s infinite; }
.status-dot.offline { background: var(--red); }
@keyframes pulse-green {
0%,100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-item { display: flex; align-items: center; }
.status-sep { color: var(--border); }
#mcpStatus { color: var(--green); }
#mcpStatus.offline { color: var(--red); }
/* ─── LOG / OUTPUT AREA ───────────────────────────────────────── */
#log {
flex: 1;
overflow-y: auto;
padding: 14px 18px;
background: var(--bg);
scroll-behavior: smooth;
}
#log::-webkit-scrollbar { width: 6px; }
#log::-webkit-scrollbar-track { background: transparent; }
#log::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
#log::-webkit-scrollbar-thumb:hover { background: var(--comment); }
/* ─── LOG LINE TYPES ──────────────────────────────────────────── */
.line {
display: block;
padding: 1.5px 0;
white-space: pre-wrap;
word-break: break-word;
animation: fadein .12s ease-out;
}
@keyframes fadein {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: none; }
}
.line.user {
color: var(--green);
font-weight: 500;
}
.line.ai {
color: var(--fg);
padding-left: 0;
position: relative;
}
.line.ai span {
color: var(--purple);
font-weight: 600;
}
.line.sys {
color: var(--comment);
font-size: 11.5px;
font-style: italic;
}
.line.err {
color: var(--red);
font-weight: 600;
}
.line.tool {
color: var(--orange);
border-left: 2px solid var(--orange);
padding-left: 10px;
margin: 2px 0;
background: rgba(255,184,108,0.04);
border-radius: 0 var(--radius) var(--radius) 0;
}
/* welcome banner */
.banner-line {
color: var(--pink);
font-size: 11px;
letter-spacing: 0.08em;
}
/* ─── DIVIDER ─────────────────────────────────────────────────── */
.log-divider {
border: none;
border-top: 1px solid var(--surface2);
margin: 8px 0;
}
/* ─── INPUT ROW ───────────────────────────────────────────────── */
#inputRow {
display: flex;
align-items: center;
gap: 0;
padding: 8px 14px 10px;
background: var(--bg-dark);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
#terminalPrompt {
color: var(--green);
font-weight: 600;
font-size: 13px;
white-space: nowrap;
margin-right: 8px;
flex-shrink: 0;
text-shadow: 0 0 8px var(--green-glow);
}
#input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--fg);
font-family: var(--font-mono);
font-size: 13px;
caret-color: var(--purple);
}
#input::placeholder { color: var(--border); }
.input-cursor-hint {
width: 8px; height: 15px;
background: var(--purple);
animation: blink .9s step-start infinite;
border-radius: 1px;
flex-shrink: 0;
opacity: 0.7;
}
@keyframes blink {
50% { opacity: 0; }
}
/* ─── THINKING INDICATOR ──────────────────────────────────────── */
.thinking-dots {
display: inline-flex;
gap: 3px;
margin-left: 6px;
vertical-align: middle;
}
.thinking-dots span {
width: 4px; height: 4px;
background: var(--purple);
border-radius: 50%;
animation: bounce .8s ease-in-out infinite;
}
.thinking-dots span:nth-child(2) { animation-delay: .15s; }
.thinking-dots span:nth-child(3) { animation-delay: .30s; }
@keyframes bounce {
0%,100% { transform: translateY(0); opacity: .4; }
50% { transform: translateY(-4px); opacity: 1; }
}
/* ─── SCROLLBAR ───────────────────────────────────────────────── */
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
/* ─── GLOW FOCUS INPUT ROW ────────────────────────────────────── */
#inputRow:focus-within {
border-top-color: var(--purple);
box-shadow: 0 -1px 0 0 var(--accent-glow);
}
/* ─── STOP BUTTON ─────────────────────────────────────────────── */
#stopBtn {
display: none;
align-items: center;
gap: 5px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: var(--radius);
border: 1px solid var(--red);
color: var(--red);
background: rgba(255,85,85,0.08);
cursor: pointer;
letter-spacing: 0.06em;
transition: background .15s, box-shadow .15s;
flex-shrink: 0;
animation: fadein .15s ease-out;
}
#stopBtn:hover {
background: rgba(255,85,85,0.18);
box-shadow: 0 0 10px rgba(255,85,85,0.35);
}
#stopBtn.visible { display: inline-flex; }
.stop-icon {
width: 8px; height: 8px;
background: var(--red);
border-radius: 1px;
flex-shrink: 0;
}
/* ─── COLLAPSIBLE TOOL BLOCK ──────────────────────────────────── */
.tool-block {
margin: 4px 0;
border-left: 2px solid var(--orange);
border-radius: 0 var(--radius) var(--radius) 0;
background: rgba(255,184,108,0.04);
overflow: hidden;
animation: fadein .12s ease-out;
}
.tool-block-header {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 10px;
cursor: pointer;
color: var(--orange);
font-size: 12px;
font-weight: 500;
user-select: none;
transition: background .12s;
}
.tool-block-header:hover { background: rgba(255,184,108,0.08); }
.tool-chevron {
font-size: 9px;
transition: transform .2s;
opacity: 0.7;
flex-shrink: 0;
}
.tool-block.open .tool-chevron { transform: rotate(90deg); }
.tool-block-label { flex: 1; }
.tool-block-badge {
font-size: 9px;
padding: 1px 6px;
border-radius: 8px;
border: 1px solid var(--orange);
color: var(--orange);
opacity: 0.6;
flex-shrink: 0;
}
.tool-block.tool-error .tool-block-badge { border-color: var(--red); color: var(--red); }
.tool-block-body {
display: none;
padding: 0 10px 8px 10px;
border-top: 1px solid rgba(255,184,108,0.12);
}
.tool-block.open .tool-block-body { display: block; }
.tool-block-section {
margin-top: 6px;
}
.tool-block-section-label {
font-size: 9px;
color: var(--comment);
letter-spacing: 0.1em;
text-transform: uppercase;
margin-bottom: 3px;
}
.tool-block-code {
background: var(--bg-darker);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 8px;
font-size: 11px;
color: var(--cyan);
white-space: pre-wrap;
word-break: break-all;
max-height: 180px;
overflow-y: auto;
line-height: 1.5;
}
.tool-block-code.result-text { color: var(--fg-dim); }
.tool-block.tool-error .tool-block-code.result-text { color: var(--red); }
/* history hint in input row */
.hist-hint {
font-size: 9.5px;
color: var(--border);
flex-shrink: 0;
padding-right: 4px;
white-space: nowrap;
transition: color .15s;
}
#inputRow:focus-within .hist-hint { color: var(--comment); }
/* ─── RESPONSIVE ──────────────────────────────────────────────── */
@media (max-width: 600px) {
#titleText { display: none; }
.bar-actions-group { gap: 5px; }
.hist-hint { display: none; }
}
</style>
</head>
<body>
<div id="app">
<!-- Title Bar -->
<div id="titleBar">
<div class="traffic-lights">
<div class="tl tl-red" title="Close"></div>
<div class="tl tl-yellow" title="Minimize"></div>
<div class="tl tl-green" title="Maximize"></div>
</div>
<span id="titleText">ollama-terminal — dracula edition</span>
<span class="title-badge">MCP</span>
</div>
<!-- Session / Multiplexer Bar -->
<div id="sessionBar"></div>
<!-- Status Bar -->
<div id="statusBar">
<span class="status-item">
<span class="status-dot offline" id="mcpDot"></span>
<span id="mcpStatus" class="offline">MCP offline</span>
</span>
<span class="status-sep">│</span>
<span class="status-item">model: <span id="statusModel" style="color:var(--cyan);margin-left:4px;">—</span></span>
<span class="status-sep">│</span>
<span class="status-item" id="toolCount" style="color:var(--orange);">0 tools</span>
<span class="status-sep">│</span>
<span class="status-item" id="msgCount" style="color:var(--comment);">0 msgs</span>
</div>
<!-- Output Log -->
<div id="log"></div>
<!-- Input Row -->
<div id="inputRow">
<span id="terminalPrompt">user@ollama:~$</span>
<input id="input" type="text" autocomplete="off" spellcheck="false" placeholder="enter a prompt or /help…" />
<span class="hist-hint" title="↑↓ history">↑↓</span>
<span id="stopBtn" title="Stop generation (Esc)">
<span class="stop-icon"></span>Stop
</span>
<div class="input-cursor-hint"></div>
</div>
</div>
<script type="module">
import { get, set, del } from 'https://cdn.jsdelivr.net/npm/idb-keyval@6/+esm';
/* ──────────────────────────────────────────────────────────────
DOM REFS
────────────────────────────────────────────────────────────── */
const log = document.getElementById("log");
const input = document.getElementById("input");
const sessionBar = document.getElementById("sessionBar");
const terminalPrompt = document.getElementById("terminalPrompt");
const mcpDot = document.getElementById("mcpDot");
const mcpStatusEl = document.getElementById("mcpStatus");
const statusModelEl = document.getElementById("statusModel");
const toolCountEl = document.getElementById("toolCount");
const msgCountEl = document.getElementById("msgCount");
const stopBtn = document.getElementById("stopBtn");
/* ──────────────────────────────────────────────────────────────
CONSTANTS
────────────────────────────────────────────────────────────── */
const OLLAMA_URL = "http://localhost:11434/api/chat";
const MCP_SERVER_URL = "http://localhost:3000";
const REGISTRY_KEY = "multiplexer_registry_config";
const SESSION_PREFIX = "multiplexer_session_";
const POPULAR_MODELS = [
"gemma4:e4b",
"gemma4:latest",
"qwen2.5-coder:14b"
];
const SYSTEM_PROMPT = {
role: "system",
content: "You are a helpful local terminal intelligence agent. You have real-time access to advanced local host platform systems tools. Invoke them whenever required to answer questions accurately."
};
/* ──────────────────────────────────────────────────────────────
STATE
────────────────────────────────────────────────────────────── */
let registry = { currentActiveId: null, activeModel: "gemma4:latest", list: [] };
let activeMessages = [SYSTEM_PROMPT];
let fullPersistentHistory = [];
let RUNTIME_AVAILABLE_TOOLS = [];
// ── Command History ──────────────────────────────────────────────
const CMD_HISTORY_MAX = 50;
let cmdHistory = []; // array of strings, oldest→newest
let historyIndex = -1; // -1 = not browsing; 0 = oldest
let historyDraft = ""; // saves current typed text while browsing
// ── Abort Controller ────────────────────────────────────────────
let currentAbortController = null;
function setGenerating(on) {
if (on) {
stopBtn.classList.add("visible");
input.disabled = true;
input.placeholder = "generating… (Esc to stop)";
} else {
stopBtn.classList.remove("visible");
input.disabled = false;
input.placeholder = "enter a prompt or /help…";
input.focus();
currentAbortController = null;
}
}
/* ──────────────────────────────────────────────────────────────
STATUS BAR HELPERS
────────────────────────────────────────────────────────────── */
function setMcpStatus(online, toolCount = 0) {
if (online) {
mcpDot.className = "status-dot online";
mcpStatusEl.className = "";
mcpStatusEl.style.color = "var(--green)";
mcpStatusEl.textContent = "MCP online";
} else {
mcpDot.className = "status-dot offline";
mcpStatusEl.className = "offline";
mcpStatusEl.style.color = "var(--red)";
mcpStatusEl.textContent = "MCP offline";
}
toolCountEl.textContent = `${toolCount} tool${toolCount !== 1 ? 's' : ''}`;
}
function updateStatusBar() {
statusModelEl.textContent = registry.activeModel || "—";
const userMsgs = activeMessages.filter(m => m.role === "user").length;
msgCountEl.textContent = `${userMsgs} msg${userMsgs !== 1 ? 's' : ''}`;
}
/* ──────────────────────────────────────────────────────────────
MCP CLIENT
────────────────────────────────────────────────────────────── */
async function fetchToolsFromMcpServer() {
try {
print(`connecting to MCP gateway at ${MCP_SERVER_URL}…`, "sys");
const response = await fetch(MCP_SERVER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", params: {}, id: 1 })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.result?.tools) {
RUNTIME_AVAILABLE_TOOLS = data.result.tools.map(t => ({
type: "function",
function: {
name: t.name,
description: t.description,
parameters: {
type: t.inputSchema?.type || "object",
properties: t.inputSchema?.properties || {},
required: t.inputSchema?.required || []
}
}
}));
setMcpStatus(true, RUNTIME_AVAILABLE_TOOLS.length);
print(`🟢 MCP connected — ${RUNTIME_AVAILABLE_TOOLS.length} tools loaded`, "sys");
}
} catch (err) {
setMcpStatus(false, 0);
print(`🔴 MCP offline (${err.message}) — text-only mode`, "sys");
RUNTIME_AVAILABLE_TOOLS = [];
}
}
async function executeMcpToolCall(name, args) {
try {
const response = await fetch(MCP_SERVER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args }, id: Date.now() })
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error?.message || `HTTP ${response.status}`);
}
const rpcResult = await response.json();
if (rpcResult.error) throw new Error(rpcResult.error.message);
const content = rpcResult.result;
if (content?.content && Array.isArray(content.content)) {
return content.content.filter(c => c.type === "text").map(c => c.text).join("\n") || "No output.";
}
return JSON.stringify(content);
} catch (err) {
return `Tool error: ${err.message}`;
}
}
/* ──────────────────────────────────────────────────────────────
CONTEXT PRUNING
────────────────────────────────────────────────────────────── */
function pruneActiveMemory() {
const sys = activeMessages[0];
let hist = activeMessages.slice(1);
while (hist.length > 16) hist.shift();
while (hist.length > 0 && (hist[0].role === "tool" || hist[0].content === "")) hist.shift();
activeMessages = [sys, ...hist];
updateStatusBar();
}
/* ──────────────────────────────────────────────────────────────
PERSISTENCE
────────────────────────────────────────────────────────────── */
async function saveAllToBrowser() {
try {
await set(REGISTRY_KEY, registry);
if (registry.currentActiveId) await set(SESSION_PREFIX + registry.currentActiveId, fullPersistentHistory);
} catch (err) { console.error("Storage write error:", err); }
}
async function loadSessionData(sessionId) {
try {
const saved = await get(SESSION_PREFIX + sessionId);
fullPersistentHistory = saved || [];
activeMessages = [SYSTEM_PROMPT, ...fullPersistentHistory.filter(m => !m.isToolActivityLog)];
pruneActiveMemory();
renderTerminalScreen();
renderTopMultiplexerBar();
} catch (err) { print("DB error: " + err.message, "err"); }
}
/* ──────────────────────────────────────────────────────────────
RENDERING
────────────────────────────────────────────────────────────── */
function renderTopMultiplexerBar() {
sessionBar.innerHTML = "";
registry.list.forEach((session, index) => {
const wrapper = document.createElement("div");
wrapper.className = "session-wrapper";
const tab = document.createElement("span");
const isActive = session.id === registry.currentActiveId;
tab.className = `session-tab ${isActive ? 'active' : ''}`;
tab.textContent = `${index}: ${session.name}`;
tab.addEventListener("click", () => { if (!isActive) switchSession(index); });
tab.addEventListener("dblclick", (e) => {
e.stopPropagation();
const editorInput = document.createElement("input");
editorInput.type = "text";
editorInput.className = "rename-input";
editorInput.value = session.name;
const finishRename = async () => {
const fresh = editorInput.value.trim();
if (fresh && fresh !== session.name) { session.name = fresh; await saveAllToBrowser(); renderTerminalScreen(); }
renderTopMultiplexerBar();
};
editorInput.addEventListener("keydown", (ke) => {
if (ke.key === "Enter") finishRename();
if (ke.key === "Escape") renderTopMultiplexerBar();
});
editorInput.addEventListener("blur", finishRename);
wrapper.replaceChild(editorInput, tab);
editorInput.focus(); editorInput.select();
});
wrapper.appendChild(tab);
const del = document.createElement("span");
del.className = "delete-btn";
del.textContent = "✕";
del.title = "Delete session";
del.addEventListener("click", (e) => { e.stopPropagation(); handleDeleteSessionConfirmation(index); });
wrapper.appendChild(del);
sessionBar.appendChild(wrapper);
});
// Right-side controls
const actions = document.createElement("div");
actions.className = "bar-actions-group";
const modelWrap = document.createElement("div");
modelWrap.className = "model-selector-wrapper";
modelWrap.innerHTML = `<span>⚙ model:</span>`;
const sel = document.createElement("select");
sel.className = "model-select-native";
const models = [...new Set([registry.activeModel, ...POPULAR_MODELS])];
models.forEach(m => {
const opt = document.createElement("option");
opt.value = m; opt.textContent = m;
if (m === registry.activeModel) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener("change", e => switchActiveModel(e.target.value));
modelWrap.appendChild(sel);
actions.appendChild(modelWrap);
const newBtn = document.createElement("span");
newBtn.className = "bar-btn new-session-btn";
newBtn.textContent = "+ New";
newBtn.title = "Create new session";
newBtn.addEventListener("click", () => handleCreateSession());
actions.appendChild(newBtn);
const wipeBtn = document.createElement("span");
wipeBtn.className = "bar-btn wipe-workspace-btn";
wipeBtn.textContent = "Wipe All";
wipeBtn.title = "Erase all sessions";
wipeBtn.addEventListener("click", () => handleWipeAllSessionsConfirmation());
actions.appendChild(wipeBtn);
sessionBar.appendChild(actions);
updateStatusBar();
}
function renderTerminalScreen() {
log.innerHTML = "";
const cur = registry.list.find(s => s.id === registry.currentActiveId);
const name = cur ? cur.name : "none";
terminalPrompt.textContent = `user@ollama:[${name}]~$`;
if (!cur) {
printBanner();
print("No active session. Click '+ New' or type /new to begin.", "sys");
return;
}
if (fullPersistentHistory.length === 0) {
printBanner();
print(`Session [${name}] ready — model: ${registry.activeModel}`, "sys");
print("Type /help for available commands.", "sys");
return;
}
// Replay history — group tool activity log pairs into collapsed blocks
let i = 0;
while (i < fullPersistentHistory.length) {
const msg = fullPersistentHistory[i];
if (msg.role === "user") {
print(`user@ollama:[${name}]~$ ${msg.content}`, "user");
i++; continue;
}
if (msg.role === "assistant" && !msg.isInternalToolCall && msg.content?.trim()) {
print(`assistant: ${msg.content}`, "ai");
i++; continue;
}
// Tool activity log pair: "🔧 executing" followed by "📦 result"
if (msg.isToolActivityLog && msg.content?.startsWith("🔧")) {
const execMsg = msg.content; // "🔧 [MCP] executing → toolName"
const resultMsg = fullPersistentHistory[i + 1]; // "📦 [MCP] result: ..."
const tName = execMsg.replace("🔧 [MCP] executing → ", "").trim();
const outcome = resultMsg?.content?.replace(/^📦 \[MCP\] result: /, "") ?? "";
const isError = outcome.startsWith("Tool error:");
// Reconstruct collapsed tool block
const block = document.createElement("div");
block.className = "tool-block" + (isError ? " tool-error" : "");
const header = document.createElement("div");
header.className = "tool-block-header";
header.innerHTML = `
<span class="tool-chevron">▶</span>
<span class="tool-block-label">🔧 ${tName}</span>
<span class="tool-block-badge">${isError ? "error" : "done"}</span>`;
block.appendChild(header);
const body = document.createElement("div");
body.className = "tool-block-body";
body.innerHTML = `
<div class="tool-block-section">
<div class="tool-block-section-label">result</div>
<div class="tool-block-code result-text${isError ? "" : ""}">${outcome}</div>
</div>`;
block.appendChild(body);
header.addEventListener("click", () => block.classList.toggle("open"));
if (isError) block.classList.add("open");
log.appendChild(block);
i += 2; // skip both the exec and result log entries
// also skip the isInternalToolCall assistant entry that follows
if (fullPersistentHistory[i]?.isInternalToolCall) i++;
continue;
}
i++; // skip anything else (isInternalToolCall, blank, etc.)
}
}
function printBanner() {
const lines = [
"╔══════════════════════════════════════════╗",
"║ 🧛 OLLAMA TERMINAL · DRACULA ║",
"║ MCP-powered local AI multiplexer ║",
"╚══════════════════════════════════════════╝",
];
lines.forEach(l => print(l, "banner-line"));
const hr = document.createElement("hr");
hr.className = "log-divider";
log.appendChild(hr);
}
function print(text, cls = "sys") {
const div = document.createElement("div");
div.className = "line " + cls;
div.textContent = text;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
return div;
}
/* ──────────────────────────────────────────────────────────────
MULTIPLEXER OPERATIONS
────────────────────────────────────────────────────────────── */
async function switchActiveModel(name) {
if (!name?.trim()) return;
registry.activeModel = name.trim();
await saveAllToBrowser();
updateStatusBar();
print(`⚙ Model switched → ${registry.activeModel}`, "sys");
renderTopMultiplexerBar();
}
async function handleCreateSession(customName = null) {
// 1. Flush the current session to IndexedDB BEFORE switching away
if (registry.currentActiveId) {
try {
await set(SESSION_PREFIX + registry.currentActiveId, fullPersistentHistory);
} catch (e) { console.error("Failed to save current session before switching:", e); }
}
// 2. Now create and switch to the new session
const id = "s_" + Date.now();
const name = customName?.trim() || `session-${registry.list.length}`;
registry.list.push({ id, name });
registry.currentActiveId = id;
fullPersistentHistory = [];
activeMessages = [SYSTEM_PROMPT];
await saveAllToBrowser();
renderTopMultiplexerBar();
renderTerminalScreen();
}
async function switchSession(index) {
const t = registry.list[index];
if (!t || t.id === registry.currentActiveId) return;
// Flush current session first, then switch
if (registry.currentActiveId) {
try {
await set(SESSION_PREFIX + registry.currentActiveId, fullPersistentHistory);
} catch (e) { console.error("Failed to save session before switch:", e); }
}
registry.currentActiveId = t.id;
await set(REGISTRY_KEY, registry);
await loadSessionData(t.id);
}
async function handleDeleteSessionConfirmation(index) {
const s = registry.list[index];
if (!s) return;
if (confirm(`Delete session "${s.name}"?`)) {
await del(SESSION_PREFIX + s.id);
registry.list.splice(index, 1);
if (registry.currentActiveId === s.id) {
if (registry.list.length > 0) {
registry.currentActiveId = registry.list[Math.max(0, index - 1)].id;
await set(REGISTRY_KEY, registry);
await loadSessionData(registry.currentActiveId);
} else {
registry.currentActiveId = null;
fullPersistentHistory = [];
activeMessages = [SYSTEM_PROMPT];
await set(REGISTRY_KEY, registry);
renderTopMultiplexerBar();
renderTerminalScreen();
}
} else {
await set(REGISTRY_KEY, registry);
renderTopMultiplexerBar();
}
}
}
async function handleWipeAllSessionsConfirmation() {
if (prompt("Type WIPE to erase all sessions:") === "WIPE") {
for (const s of registry.list) await del(SESSION_PREFIX + s.id);
await del(REGISTRY_KEY);
registry.list = [];
registry.currentActiveId = null;
fullPersistentHistory = [];
activeMessages = [SYSTEM_PROMPT];
await handleCreateSession("general");
}
}
async function handleSlashCommand(raw) {
const parts = raw.trim().split(" ");
const cmd = parts[0].toLowerCase();
const args = parts.slice(1).join(" ");
if (cmd === "/new") {
await handleCreateSession(args || null);
} else if (cmd === "/clear") {
log.innerHTML = "";
printBanner();
} else if (cmd === "/help") {
const cmds = [
[" /new [name]", "create a new session tab"],
[" /clear", "clear the terminal output"],
[" /help", "show this help message"],
];
const keys = [
[" ↑ / ↓", "cycle through command history"],
[" Esc", "stop current generation"],
[" Enter", "send prompt"],
];
print("── commands ──────────────────────────────────", "sys");
cmds.forEach(([c, d]) => print(`${c.padEnd(18)} — ${d}`, "sys"));
print("── keyboard shortcuts ────────────────────────", "sys");
keys.forEach(([c, d]) => print(`${c.padEnd(18)} — ${d}`, "sys"));
print("──────────────────────────────────────────────", "sys");
} else {
print(`unknown command: ${cmd} (try /help)`, "err");
}
}
/* ──────────────────────────────────────────────────────────────
STREAM / AGENTIC CORE
────────────────────────────────────────────────────────────── */
async function callModel() {
currentAbortController = new AbortController();
const payload = { model: registry.activeModel, stream: true, messages: activeMessages };
if (RUNTIME_AVAILABLE_TOOLS.length > 0) payload.tools = RUNTIME_AVAILABLE_TOOLS;
return await fetch(OLLAMA_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: currentAbortController.signal
});
}
async function askAI(promptText) {
const cur = registry.list.find(s => s.id === registry.currentActiveId);
const label = cur ? cur.name : "~";
print(`user@ollama:[${label}]~$ ${promptText}`, "user");
const userMsg = { role: "user", content: promptText };
activeMessages.push(userMsg);
fullPersistentHistory.push(userMsg);
updateStatusBar();
setGenerating(true);
let guard = 6;
let isProcessing = true;
let aborted = false;
try {
while (isProcessing && guard-- > 0) {
// Show thinking indicator for this iteration
const thinkLine = document.createElement("div");
thinkLine.className = "line sys";
thinkLine.innerHTML = `<span style="color:var(--purple)">assistant</span> is thinking<div class="thinking-dots"><span></span><span></span><span></span></div>`;
log.appendChild(thinkLine);
log.scrollTop = log.scrollHeight;
let stream;
try {
stream = await callModel();
} catch (fetchErr) {
thinkLine.remove();
if (fetchErr.name === "AbortError") { aborted = true; break; }
throw fetchErr;
}
const reader = stream.body.getReader();
const decoder = new TextDecoder();
let displayEl = null;
let textNode = null;
let buf = "";
let fullText = "";
let toolCalls = [];
// Remove think line as soon as first chunk arrives
let thinkRemoved = false;
const removeThink = () => { if (!thinkRemoved) { thinkLine.remove(); thinkRemoved = true; } };
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const json = JSON.parse(line);
const msg = json.message;
if (msg?.tool_calls?.length) {
removeThink();
msg.tool_calls.forEach(c => toolCalls.push(c));
} else if (msg?.content) {
removeThink();
fullText += msg.content;
if (!displayEl) {
displayEl = document.createElement("div");
displayEl.className = "line ai";
displayEl.innerHTML = `<span>assistant: </span>`;
textNode = document.createTextNode("");
displayEl.appendChild(textNode);
log.appendChild(displayEl);
}
textNode.nodeValue += msg.content;
log.scrollTop = log.scrollHeight;
}
} catch {}
}
}
} catch (readErr) {
removeThink();
if (readErr.name === "AbortError") { aborted = true; break; }
throw readErr;
}
removeThink(); // guarantee removal even on empty stream
if (aborted) break;
if (toolCalls.length > 0) {
// Discard any streamed text that was just the raw tool-call JSON Ollama leaked
if (displayEl) displayEl.remove();
displayEl = null;
fullText = ""; // never save tool-call JSON as visible assistant content
const tc = toolCalls[0];
const tName = tc.function.name;
const tArgs = tc.function.arguments || {};
// ── Collapsible tool block ──────────────────────────────
const block = document.createElement("div");
block.className = "tool-block";
const header = document.createElement("div");
header.className = "tool-block-header";
header.innerHTML = `
<span class="tool-chevron">▶</span>
<span class="tool-block-label">🔧 ${tName}</span>
<span class="tool-block-badge">executing…</span>`;
block.appendChild(header);
const body = document.createElement("div");
body.className = "tool-block-body";
// Args section
const argsSection = document.createElement("div");
argsSection.className = "tool-block-section";
argsSection.innerHTML = `<div class="tool-block-section-label">arguments</div>
<div class="tool-block-code">${JSON.stringify(tArgs, null, 2)}</div>`;
body.appendChild(argsSection);
// Result placeholder
const resultSection = document.createElement("div");
resultSection.className = "tool-block-section";
resultSection.innerHTML = `<div class="tool-block-section-label">result</div>
<div class="tool-block-code result-text" id="tool-result-${Date.now()}">running…</div>`;
body.appendChild(resultSection);
block.appendChild(body);
log.appendChild(block);
log.scrollTop = log.scrollHeight;
// Toggle open/close
header.addEventListener("click", () => block.classList.toggle("open"));
// Persist to history (compact)
const logMsg = `🔧 [MCP] executing → ${tName}`;
fullPersistentHistory.push({ role: "system", content: logMsg, isToolActivityLog: true });
fullPersistentHistory.push({ role: "assistant", content: JSON.stringify(tc), isInternalToolCall: true });
const outcome = await executeMcpToolCall(tName, tArgs);
const isError = outcome.startsWith("Tool error:");
// Update block with result
const resultCode = resultSection.querySelector(".result-text");
resultCode.textContent = outcome;
const badge = header.querySelector(".tool-block-badge");
badge.textContent = isError ? "error" : "done";
if (isError) block.classList.add("tool-error");
// Auto-open on error, auto-collapse on success
if (isError) block.classList.add("open");
const resultMsg = `📦 [MCP] result: ${outcome.substring(0, 400)}${outcome.length > 400 ? '…' : ''}`;
fullPersistentHistory.push({ role: "system", content: resultMsg, isToolActivityLog: true });
activeMessages.push({ role: "assistant", content: fullText || "", tool_calls: toolCalls });
activeMessages.push({ role: "tool", content: outcome });
} else {
isProcessing = false;
const finalMsg = { role: "assistant", content: fullText };
activeMessages.push(finalMsg);
// Only persist messages that have visible text content
if (fullText.trim()) {
fullPersistentHistory.push(finalMsg);
}
}
}
if (aborted) {
print("⚠ Generation stopped by user.", "sys");
}
await saveAllToBrowser();
pruneActiveMemory();
} catch (err) {
print(`runtime error: ${err.message}`, "err");
} finally {
setGenerating(false);
}
}
/* ──────────────────────────────────────────────────────────────
STOP BUTTON
────────────────────────────────────────────────────────────── */
stopBtn.addEventListener("click", () => {
if (currentAbortController) {
currentAbortController.abort();
}
});
/* ──────────────────────────────────────────────────────────────
INPUT HANDLER (command history + stop on Esc)
────────────────────────────────────────────────────────────── */
input.addEventListener("keydown", async (e) => {
// ── Esc → stop generation ──────────────────────────────────
if (e.key === "Escape") {
if (currentAbortController) {
currentAbortController.abort();
}
return;
}
// ── ↑ → older history ─────────────────────────────────────
if (e.key === "ArrowUp") {
e.preventDefault();
if (cmdHistory.length === 0) return;
if (historyIndex === -1) {
historyDraft = input.value; // save current draft
historyIndex = cmdHistory.length - 1;
} else if (historyIndex > 0) {
historyIndex--;
}
input.value = cmdHistory[historyIndex];
// move cursor to end
requestAnimationFrame(() => { input.selectionStart = input.selectionEnd = input.value.length; });
return;
}
// ── ↓ → newer history / back to draft ─────────────────────
if (e.key === "ArrowDown") {
e.preventDefault();
if (historyIndex === -1) return;
if (historyIndex < cmdHistory.length - 1) {
historyIndex++;
input.value = cmdHistory[historyIndex];
} else {
historyIndex = -1;
input.value = historyDraft;
}
requestAnimationFrame(() => { input.selectionStart = input.selectionEnd = input.value.length; });
return;
}
// ── Any other key resets history browsing ─────────────────
if (e.key !== "Enter") {
if (historyIndex !== -1) {
// user started editing a history entry — detach
historyIndex = -1;
}
return;
}
// ── Enter → submit ─────────────────────────────────────────
const value = input.value.trim();
if (!value) return;
input.value = "";
historyIndex = -1;
historyDraft = "";
// Push to history (dedupe consecutive identical entries)
if (cmdHistory[cmdHistory.length - 1] !== value) {
cmdHistory.push(value);
if (cmdHistory.length > CMD_HISTORY_MAX) cmdHistory.shift();
}
if (value.startsWith("/")) {
print(`client:[cmd]~$ ${value}`, "user");
await handleSlashCommand(value);
} else {
if (!registry.currentActiveId) {
print("No active session. Type /new to start one.", "err");
return;
}
await askAI(value);
}
});
/* ──────────────────────────────────────────────────────────────
BOOT
────────────────────────────────────────────────────────────── */
async function boot() {
try {
const saved = await get(REGISTRY_KEY);
if (saved?.list?.length) {
registry = saved;
} else {
const id = "s_" + Date.now();
registry.list.push({ id, name: "general" });
registry.currentActiveId = id;
}
} catch {
const id = "s_" + Date.now();
registry.list.push({ id, name: "general" });
registry.currentActiveId = id;
}
renderTopMultiplexerBar();
await loadSessionData(registry.currentActiveId);
updateStatusBar();
await fetchToolsFromMcpServer();
// Focus input
input.focus();
}
boot();
</script>
</body>
</html>
Top comments (0)