DEV Community

Cover image for Multi-Session AI Multiplexer That Runs Entirely in Your Browser
artydev
artydev

Posted on

Multi-Session AI Multiplexer That Runs Entirely in Your Browser

#ai

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>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)