diff --git a/.gitignore b/.gitignore
index 010ced5..2301fcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,7 +9,6 @@ __pycache__/
#temp
plan-*
-ui
# Distribution / packaging
.Python
diff --git a/ui/README.md b/ui/README.md
new file mode 100644
index 0000000..d0b7bd8
--- /dev/null
+++ b/ui/README.md
@@ -0,0 +1,75 @@
+# Agent Workspace UI
+
+A stark, high-contrast monochrome terminal-style frontend for configuring and testing the AI pipeline generation agent.
+
+## Features
+
+- **High-Contrast Monochrome Design**: Stark developer-terminal theme with zero rounded corners, monospaced typography, and red/green accenting reserved strictly for execution outcomes.
+- **In-Memory Session Caching**: Chat history and active model configurations are saved inside the browser's `sessionStorage` (retains logs on page refresh but clears automatically when you close the tab or start a new session).
+- **Collapsible thinking process**: Streamed thinking tokens (`[THINKING PROCESS]`) automatically collapse when finished to keep the logs readable.
+- **Dynamic Tool Execution Cards**: Shows a real-time status spinner, turning green on success (`[OK]`) and collapsing, or red on failure (`[FAILED]`) while showing output details.
+- **Inline Human-In-The-Loop (HITL) Handling**: Allows you to approve/deny bash commands and file writes, or answer clarifying questions directly inside the chat feed.
+- **Local Credentials & Model Catalog**: Saves provider API keys locally, dynamically loads tool-calling models from `models.dev`, and supports adding custom OpenAI-compatible models.
+
+---
+
+## How to Start the System
+
+There are two ways to start the servers: using the single-command helper (recommended) or launching each server manually.
+
+### Option 1: Single-Command Launch (Recommended)
+
+The helper script `ui/run_demo.py` launches both the FastAPI backend and the static UI server in the background, handles clean shutdowns, and verifies backend health before launching your browser.
+
+From the repository root:
+
+```bash
+python ui/run_demo.py
+```
+
+If the browser does not open automatically, or if you prefer to open it yourself, use:
+
+```bash
+python ui/run_demo.py --no-open
+```
+
+You can customize port bindings:
+
+```bash
+python ui/run_demo.py --backend-port 8000 --ui-port 5173
+```
+
+---
+
+### Option 2: Manual Start
+
+If you prefer to run the backend and frontend in separate terminals:
+
+1. **Start the FastAPI Backend**:
+ From the repository root, add `src` to your python path and run `uvicorn`:
+ ```bash
+ # Windows (PowerShell)
+ $env:PYTHONPATH="src"
+ python -m uvicorn agent.api.api:app --host 127.0.0.1 --port 8000
+
+ # Linux/macOS
+ PYTHONPATH=src python -m uvicorn agent.api.api:app --host 127.0.0.1 --port 8000
+ ```
+
+2. **Start the UI Static Server**:
+ From the `ui/` directory, host the static HTML files using python's built-in server:
+ ```bash
+ python -m http.server 5173 --bind 127.0.0.1 --directory ui
+ ```
+
+Once both servers are running, navigate to:
+[http://127.0.0.1:5173/?backend=http://127.0.0.1:8000](http://127.0.0.1:5173/?backend=http://127.0.0.1:8000)
+
+---
+
+## Active API Endpoints consumed by UI
+
+- `POST /generate/stream` — Starts the execution graph for pipeline generation.
+- `POST /generate/{thread_id}/resume/permission` — Resumes a paused execution after human approval or denial of tool execution.
+- `POST /generate/{thread_id}/resume/clarification` — Resumes execution by submitting user answers to clarifying questions.
+- `POST /generate/runs/{run_id}/cancel` — Interrupts and terminates an active pipeline generation run.
diff --git a/ui/app.js b/ui/app.js
new file mode 100644
index 0000000..d6b3431
--- /dev/null
+++ b/ui/app.js
@@ -0,0 +1,1591 @@
+const state = {
+ threadId: null,
+ runId: null,
+ runState: "idle",
+ waiting: false,
+ awaitingHuman: false,
+ abortController: null,
+
+ // Models catalog and configurations
+ modelsCatalog: null,
+ providers: [],
+ providerUrls: {},
+ customModels: {},
+ apiKeys: {},
+ selectedProvider: "google",
+ selectedModel: "gemini-2.5-pro",
+
+ // UI active references
+ activeToolBlock: null,
+ activeThinkingBlock: null,
+};
+
+const DEFAULT_CATALOG = {
+ providers: {
+ openai: {
+ name: "OpenAI",
+ api: "https://api.openai.com/v1",
+ models: {
+ "gpt-4o": { id: "gpt-4o", name: "GPT-4o", reasoning: false, tool_call: true },
+ "gpt-4o-mini": { id: "gpt-4o-mini", name: "GPT-4o Mini", reasoning: false, tool_call: true },
+ "o1-mini": { id: "o1-mini", name: "o1 Mini", reasoning: true, tool_call: true },
+ "o1": { id: "o1", name: "o1", reasoning: true, tool_call: true },
+ "o3-mini": { id: "o3-mini", name: "o3 Mini", reasoning: true, tool_call: true }
+ }
+ },
+ anthropic: {
+ name: "Anthropic",
+ api: "https://api.anthropic.com/v1",
+ models: {
+ "claude-3-5-sonnet-20241022": { id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet", reasoning: false, tool_call: true },
+ "claude-3-5-haiku-20241022": { id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku", reasoning: false, tool_call: true },
+ "claude-3-7-sonnet": { id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", reasoning: true, tool_call: true }
+ }
+ },
+ google: {
+ name: "Google Gemini",
+ api: "https://generativelanguage.googleapis.com",
+ models: {
+ "gemini-2.5-pro": { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", reasoning: true, tool_call: true },
+ "gemini-2.5-flash": { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", reasoning: true, tool_call: true },
+ "gemini-1.5-pro": { id: "gemini-1.5-pro", name: "Gemini 1.5 Pro", reasoning: false, tool_call: true },
+ "gemini-1.5-flash": { id: "gemini-1.5-flash", name: "Gemini 1.5 Flash", reasoning: false, tool_call: true }
+ }
+ },
+ groq: {
+ name: "Groq",
+ api: "https://api.groq.com/openai/v1",
+ models: {
+ "llama-3.3-70b-versatile": { id: "llama-3.3-70b-versatile", name: "Llama 3.3 70B", reasoning: false, tool_call: true },
+ "llama3-70b-8192": { id: "llama3-70b-8192", name: "Llama 3 70B", reasoning: false, tool_call: true },
+ "mixtral-8x7b-32768": { id: "mixtral-8x7b-32768", name: "Mixtral 8x7B", reasoning: false, tool_call: true }
+ }
+ }
+ }
+};
+
+const elements = {
+ backendUrl: document.getElementById("backendUrl"),
+ projectPath: document.getElementById("projectPath"),
+ platform: document.getElementById("platform"),
+ promptInput: document.getElementById("promptInput"),
+ startBtn: document.getElementById("startBtn"),
+ newThreadBtn: document.getElementById("newThreadBtn"),
+ cancelBtn: document.getElementById("cancelBtn"),
+ threadId: document.getElementById("threadId"),
+ runState: document.getElementById("runState"),
+ runId: document.getElementById("runId"),
+ chatLog: document.getElementById("chatLog"),
+ sessionTitle: document.getElementById("sessionTitle"),
+ sessionSubtitle: document.getElementById("sessionSubtitle"),
+
+ // Model controls
+ modelSelectBtn: document.getElementById("modelSelectBtn"),
+ currentModelLabel: document.getElementById("currentModelLabel"),
+ modelDropdown: document.getElementById("modelDropdown"),
+ modelSearchInput: document.getElementById("modelSearchInput"),
+ modelList: document.getElementById("modelList"),
+ openaiCompatibleBtn: document.getElementById("openaiCompatibleBtn"),
+ reasoningEffort: document.getElementById("reasoningEffort"),
+ providerKeysList: document.getElementById("providerKeysList"),
+
+ // API Key Modal
+ apiKeyModal: document.getElementById("apiKeyModal"),
+ apiKeyModalTitle: document.getElementById("apiKeyModalTitle"),
+ apiKeyModalProviderText: document.getElementById("apiKeyModalProviderText"),
+ apiKeyInput: document.getElementById("apiKeyInput"),
+ saveApiKeyBtn: document.getElementById("saveApiKeyBtn"),
+ deleteApiKeyBtn: document.getElementById("deleteApiKeyBtn"),
+
+ // Custom Model Modal
+ customModelModal: document.getElementById("customModelModal"),
+ customDisplayName: document.getElementById("customDisplayName"),
+ customModelId: document.getElementById("customModelId"),
+ customBaseUrl: document.getElementById("customBaseUrl"),
+ customApiKey: document.getElementById("customApiKey"),
+ customReasoning: document.getElementById("customReasoning"),
+ saveCustomModelBtn: document.getElementById("saveCustomModelBtn"),
+};
+
+// State modifications
+function setBusy(isBusy) {
+ state.waiting = isBusy;
+ elements.startBtn.disabled = isBusy;
+ elements.promptInput.disabled = isBusy;
+ elements.modelSelectBtn.disabled = isBusy;
+ elements.projectPath.disabled = isBusy;
+ elements.platform.disabled = isBusy;
+
+ if (isBusy) {
+ elements.reasoningEffort.disabled = true;
+ } else {
+ updateReasoningEffortDropdown(state.selectedModel, state.selectedProvider);
+ }
+}
+
+function setRunState(text) {
+ state.runState = text;
+ elements.runState.textContent = text.toUpperCase();
+ elements.runState.className = `val badge badge-${text.toLowerCase()}`;
+
+ const cancellableStates = new Set(["running", "resuming", "interrupted"]);
+ elements.cancelBtn.disabled = !cancellableStates.has(text);
+ saveChatSession();
+}
+
+function setThreadId(id) {
+ state.threadId = id || null;
+ elements.threadId.textContent = state.threadId || "not started";
+ saveChatSession();
+}
+
+function setRunId(id) {
+ state.runId = id || null;
+ elements.runId.textContent = state.runId || "none";
+ saveChatSession();
+}
+
+// Model Dropdown Actions
+function toggleModelDropdown(e) {
+ e.stopPropagation();
+ elements.modelDropdown.classList.toggle("hidden");
+ if (!elements.modelDropdown.classList.contains("hidden")) {
+ elements.modelSearchInput.value = "";
+ elements.modelSearchInput.focus();
+ renderModelList();
+ }
+}
+
+function hideModelDropdown() {
+ elements.modelDropdown.classList.add("hidden");
+}
+
+function selectModel(modelId, providerId) {
+ state.selectedModel = modelId;
+ state.selectedProvider = providerId;
+
+ localStorage.setItem("agent_selected_model", modelId);
+ localStorage.setItem("agent_selected_provider", providerId);
+
+ elements.currentModelLabel.textContent = getModelDisplayName(modelId, providerId).toUpperCase();
+ updateReasoningEffortDropdown(modelId, providerId);
+
+ // If API key is not configured for this provider, prompt immediately
+ if (providerId !== "openai_compatible" && !state.customModels[modelId]) {
+ const isSet = !!state.apiKeys[providerId];
+ if (!isSet) {
+ openApiKeyModal(providerId, getProviderName(providerId));
+ }
+ }
+ saveChatSession();
+}
+
+function getModelDisplayName(modelId, providerId) {
+ if (providerId === "openai_compatible" && state.customModels[modelId]) {
+ return state.customModels[modelId].displayName;
+ }
+ const p = state.providers.find(prov => prov.id === providerId);
+ if (p) {
+ const m = p.models.find(model => model.id === modelId);
+ if (m) {
+ return `${p.name}: ${m.name}`;
+ }
+ }
+ return modelId;
+}
+
+function getProviderName(providerId) {
+ const p = state.providers.find(prov => prov.id === providerId);
+ return p ? p.name : providerId;
+}
+
+function updateReasoningEffortDropdown(modelId, providerId) {
+ let hasReasoning = false;
+ if (providerId === "openai_compatible" && state.customModels[modelId]) {
+ hasReasoning = state.customModels[modelId].reasoning;
+ } else {
+ const p = state.providers.find(prov => prov.id === providerId);
+ if (p) {
+ const m = p.models.find(model => model.id === modelId);
+ if (m) {
+ hasReasoning = m.reasoning === true;
+ }
+ }
+ }
+
+ const select = elements.reasoningEffort;
+ if (hasReasoning) {
+ select.disabled = false;
+ select.classList.remove("disabled");
+ } else {
+ select.disabled = true;
+ select.value = "none";
+ select.classList.add("disabled");
+ }
+}
+
+// Modal management
+let currentEditingProviderId = null;
+
+function openApiKeyModal(providerId, providerName) {
+ currentEditingProviderId = providerId;
+ elements.apiKeyModalTitle.textContent = `CONFIGURE API CREDENTIALS: ${providerName.toUpperCase()}`;
+ elements.apiKeyModalProviderText.textContent = `ENTER KEY VALUE FOR ${providerName.toUpperCase()}:`;
+ elements.apiKeyInput.value = state.apiKeys[providerId] || "";
+ elements.apiKeyModal.classList.remove("hidden");
+ elements.apiKeyInput.focus();
+}
+
+function closeApiKeyModal() {
+ elements.apiKeyModal.classList.add("hidden");
+ currentEditingProviderId = null;
+}
+
+// Fetch and load models from models.dev
+async function loadModelsCatalog() {
+ try {
+ const savedKeys = localStorage.getItem("agent_api_keys");
+ if (savedKeys) state.apiKeys = JSON.parse(savedKeys);
+
+ const savedCustom = localStorage.getItem("agent_custom_models");
+ if (savedCustom) state.customModels = JSON.parse(savedCustom);
+ } catch (e) {
+ console.error("Failed to load saved config from localStorage", e);
+ }
+
+ try {
+ const cached = localStorage.getItem("agent_models_catalog");
+ if (cached) {
+ state.modelsCatalog = JSON.parse(cached);
+ processCatalog(state.modelsCatalog);
+ }
+ } catch (e) {
+ console.error("Failed to parse cached catalog", e);
+ }
+
+ if (!state.modelsCatalog) {
+ state.modelsCatalog = DEFAULT_CATALOG;
+ processCatalog(state.modelsCatalog);
+ }
+
+ try {
+ const res = await fetch("https://models.dev/catalog.json");
+ if (res.ok) {
+ const data = await res.json();
+ state.modelsCatalog = data;
+ localStorage.setItem("agent_models_catalog", JSON.stringify(data));
+ processCatalog(data);
+ }
+ } catch (err) {
+ console.warn("Could not fetch models.dev, using local copy:", err);
+ }
+
+ loadSavedSelections();
+ restoreChatSession();
+}
+
+function processCatalog(catalog) {
+ state.providers = [];
+ state.providerUrls = {};
+
+ if (!catalog || !catalog.providers) return;
+
+ for (const [providerId, provider] of Object.entries(catalog.providers)) {
+ const toolModels = [];
+ if (provider.api) {
+ state.providerUrls[providerId] = provider.api;
+ }
+
+ for (const [modelId, model] of Object.entries(provider.models || {})) {
+ if (model.tool_call === true) {
+ toolModels.push({
+ id: model.id || modelId,
+ name: model.name || model.id || modelId,
+ reasoning: model.reasoning === true,
+ });
+ }
+ }
+
+ if (toolModels.length > 0) {
+ state.providers.push({
+ id: providerId,
+ name: provider.name || providerId,
+ models: toolModels
+ });
+ }
+ }
+
+ const firstClass = ["openai", "anthropic", "google", "groq"];
+ state.providers.sort((a, b) => {
+ const idxA = firstClass.indexOf(a.id);
+ const idxB = firstClass.indexOf(b.id);
+ if (idxA !== -1 && idxB !== -1) return idxA - idxB;
+ if (idxA !== -1) return -1;
+ if (idxB !== -1) return 1;
+ return a.name.localeCompare(b.name);
+ });
+
+ renderProviderKeysList();
+ renderModelList();
+}
+
+function renderProviderKeysList() {
+ const container = elements.providerKeysList;
+ container.innerHTML = "";
+
+ const popularProviders = [
+ { id: "openai", name: "OPENAI" },
+ { id: "anthropic", name: "ANTHROPIC" },
+ { id: "google", name: "GOOGLE GEMINI" },
+ { id: "groq", name: "GROQ" },
+ { id: "deepseek", name: "DEEPSEEK" },
+ { id: "openrouter", name: "OPENROUTER" }
+ ];
+
+ popularProviders.forEach(p => {
+ const isSet = !!state.apiKeys[p.id];
+ const item = document.createElement("div");
+ item.className = "provider-key-item";
+ item.innerHTML = `
+ ${p.name}
+
+
+ ${isSet ? "CONFIGURED" : "NOT SET"}
+
+ `;
+ item.addEventListener("click", () => openApiKeyModal(p.id, p.name));
+ container.appendChild(item);
+ });
+}
+
+function renderModelList(filterText = "") {
+ const container = elements.modelList;
+ container.innerHTML = "";
+ const query = filterText.toLowerCase().trim();
+
+ // Render Custom models
+ const customModelsList = Object.values(state.customModels);
+ if (customModelsList.length > 0) {
+ const matchingCustom = customModelsList.filter(m =>
+ m.displayName.toLowerCase().includes(query) || m.model.toLowerCase().includes(query)
+ );
+ if (matchingCustom.length > 0) {
+ const header = document.createElement("div");
+ header.className = "provider-header";
+ header.textContent = "CUSTOM MODELS";
+ container.appendChild(header);
+
+ matchingCustom.forEach(m => {
+ const isSelected = state.selectedModel === m.model && state.selectedProvider === "openai_compatible";
+ const item = document.createElement("div");
+ item.className = `model-item ${isSelected ? "active" : ""}`;
+ item.innerHTML = `
+
+ ${m.displayName.toUpperCase()}
+ ${m.model}
+
+
+ ${m.reasoning ? 'REASONING' : ''}
+
+ `;
+ item.addEventListener("click", () => {
+ selectModel(m.model, "openai_compatible");
+ hideModelDropdown();
+ });
+ container.appendChild(item);
+ });
+ }
+ }
+
+ // Render catalog providers
+ state.providers.forEach(p => {
+ const matchingModels = p.models.filter(m =>
+ m.name.toLowerCase().includes(query) || m.id.toLowerCase().includes(query)
+ );
+
+ if (matchingModels.length > 0) {
+ const header = document.createElement("div");
+ header.className = "provider-header";
+ header.textContent = p.name.toUpperCase();
+ container.appendChild(header);
+
+ matchingModels.forEach(m => {
+ const isSelected = state.selectedModel === m.id && state.selectedProvider === p.id;
+ const item = document.createElement("div");
+ item.className = `model-item ${isSelected ? "active" : ""}`;
+ item.innerHTML = `
+
+ ${m.name.toUpperCase()}
+ ${m.id}
+
+
+ ${m.reasoning ? 'REASONING' : ''}
+
+ `;
+ item.addEventListener("click", () => {
+ selectModel(m.id, p.id);
+ hideModelDropdown();
+ });
+ container.appendChild(item);
+ });
+ }
+ });
+}
+
+function loadSavedSelections() {
+ const savedModel = localStorage.getItem("agent_selected_model");
+ const savedProvider = localStorage.getItem("agent_selected_provider");
+ if (savedModel && savedProvider) {
+ state.selectedModel = savedModel;
+ state.selectedProvider = savedProvider;
+ }
+
+ const savedPath = localStorage.getItem("agent_project_path");
+ if (savedPath) {
+ elements.projectPath.value = savedPath;
+ }
+
+ const savedPlatform = localStorage.getItem("agent_platform");
+ if (savedPlatform) {
+ elements.platform.value = savedPlatform;
+ }
+
+ elements.currentModelLabel.textContent = getModelDisplayName(state.selectedModel, state.selectedProvider).toUpperCase();
+ updateReasoningEffortDropdown(state.selectedModel, state.selectedProvider);
+}
+
+// API and Stream Logic
+async function apiRequest(path, options = {}) {
+ const base = elements.backendUrl.value.trim().replace(/\/$/, "");
+ const response = await fetch(`${base}${path}`, {
+ ...options,
+ headers: {
+ "Content-Type": "application/json",
+ ...(options.headers || {}),
+ },
+ });
+
+ let payload;
+ try {
+ payload = await response.json();
+ } catch (parseError) {
+ payload = { detail: "Backend returned non-JSON response" };
+ }
+
+ if (!response.ok) {
+ throw new Error(payload.detail || `HTTP ${response.status}`);
+ }
+
+ return payload;
+}
+
+function abortActiveStream() {
+ if (state.abortController) {
+ state.abortController.abort();
+ state.abortController = null;
+ }
+}
+
+const eventQueue = [];
+let isProcessingQueue = false;
+
+function queueStreamEvent(event) {
+ eventQueue.push(event);
+ processEventQueue();
+}
+
+async function processEventQueue() {
+ if (isProcessingQueue) return;
+ isProcessingQueue = true;
+
+ while (eventQueue.length > 0) {
+ const event = eventQueue.shift();
+ await handleStreamEvent(event);
+
+ if (eventQueue.length > 0) {
+ await new Promise(r => setTimeout(r, 2000));
+ }
+ }
+
+ isProcessingQueue = false;
+}
+
+async function streamRequest(path, payload) {
+ const base = elements.backendUrl.value.trim().replace(/\/$/, "");
+ const controller = new AbortController();
+ state.abortController = controller;
+
+ const response = await fetch(`${base}${path}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ const text = await response.text();
+ let detail = text;
+ try {
+ const err = JSON.parse(text);
+ detail = err.detail || text;
+ } catch(e) {}
+ throw new Error(detail || `HTTP ${response.status}`);
+ }
+
+ const headerThread = response.headers.get("X-Thread-Id");
+ const headerRun = response.headers.get("X-Run-Id");
+ if (headerThread) setThreadId(headerThread);
+ if (headerRun) setRunId(headerRun);
+
+ if (!response.body) {
+ throw new Error("Streaming response body unavailable.");
+ }
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = "";
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+
+ buffer += decoder.decode(value, { stream: true });
+ const lines = buffer.split("\n");
+ buffer = lines.pop() || "";
+
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+
+ try {
+ const event = JSON.parse(trimmed);
+ queueStreamEvent(event);
+ } catch (error) {
+ console.warn("Could not parse NDJSON line:", trimmed);
+ }
+ }
+ }
+
+ const finalLine = buffer.trim();
+ if (finalLine) {
+ try {
+ queueStreamEvent(JSON.parse(finalLine));
+ } catch (e) {
+ console.warn("Could not parse final NDJSON line:", finalLine);
+ }
+ }
+}
+
+// Conversation log extraction for multi-turn history
+function getHistory() {
+ const history = [];
+ const messages = elements.chatLog.querySelectorAll(".message.user, .message.agent");
+ messages.forEach(msg => {
+ const role = msg.classList.contains("user") ? "user" : "assistant";
+ const bubble = msg.querySelector(".bubble");
+ if (bubble) {
+ const txt = bubble.textContent.trim();
+ if (txt) {
+ history.push({ role, content: txt });
+ }
+ }
+ });
+ return history;
+}
+
+function getLLMConfig() {
+ const modelId = state.selectedModel;
+ const providerId = state.selectedProvider;
+
+ const config = {
+ temperature: 0,
+ reasoning_effort: elements.reasoningEffort.value || "none"
+ };
+
+ // Custom model override
+ if (providerId === "openai_compatible" && state.customModels[modelId]) {
+ const custom = state.customModels[modelId];
+ config.provider = "openai_compatible";
+ config.model = custom.model;
+ config.base_url = custom.baseUrl;
+ config.api_key = custom.apiKey || "temp";
+ return config;
+ }
+
+ if (providerId === "openai") {
+ config.provider = "openai";
+ config.model = modelId;
+ config.api_key = state.apiKeys["openai"] || "";
+ } else if (providerId === "anthropic") {
+ config.provider = "anthropic";
+ config.model = modelId;
+ config.api_key = state.apiKeys["anthropic"] || "";
+ } else if (providerId === "google") {
+ config.provider = "gemini";
+ config.model = modelId;
+ config.api_key = state.apiKeys["google"] || "";
+ } else if (providerId === "groq") {
+ config.provider = "groq";
+ config.model = modelId;
+ config.api_key = state.apiKeys["groq"] || "";
+ } else {
+ config.provider = "openai_compatible";
+ config.model = modelId;
+ config.api_key = state.apiKeys[providerId] || "temp";
+
+ let baseUrl = state.providerUrls[providerId] || "";
+ if (baseUrl && !baseUrl.includes("/v1") && !baseUrl.includes("/v1/")) {
+ baseUrl = baseUrl.replace(/\/$/, "") + "/v1";
+ }
+ config.base_url = baseUrl;
+ }
+
+ return config;
+}
+
+async function runInitialGeneration() {
+ const projectPath = elements.projectPath.value.trim();
+ const prompt = elements.promptInput.value.trim();
+
+ if (!projectPath) {
+ addErrorMessage("Project path is required in the sidebar.");
+ return;
+ }
+ if (!prompt) {
+ addErrorMessage("Please enter a prompt instruction.");
+ return;
+ }
+
+ const provider = state.selectedProvider;
+ if (provider !== "openai_compatible" && !state.customModels[state.selectedModel]) {
+ if (!state.apiKeys[provider]) {
+ openApiKeyModal(provider, getProviderName(provider));
+ return;
+ }
+ }
+
+ localStorage.setItem("agent_project_path", projectPath);
+ localStorage.setItem("agent_platform", elements.platform.value);
+
+ abortActiveStream();
+ setBusy(true);
+ hideWelcome();
+
+ addMessage("user", prompt);
+ elements.promptInput.value = "";
+
+ const llmConfig = getLLMConfig();
+ const history = getHistory();
+ if (history.length > 0 && history[history.length - 1].content === prompt) {
+ history.pop();
+ }
+
+ if (!state.threadId) {
+ state.threadId = "thread_" + Math.random().toString(36).substring(2, 15);
+ setThreadId(state.threadId);
+ }
+
+ setRunState("running");
+
+ try {
+ await streamRequest("/generate/stream", {
+ project_path: projectPath,
+ prompt: prompt,
+ target_platform: elements.platform.value,
+ thread_id: state.threadId,
+ history: history,
+ llm: llmConfig,
+ });
+ } catch (error) {
+ if (error && error.name === "AbortError") return;
+ setRunState("failed");
+ addErrorMessage(`Request error: ${error.message}`);
+ setBusy(false);
+ }
+}
+
+async function resumePermission(decision, requestId) {
+ abortActiveStream();
+ setBusy(true);
+ setRunState("resuming");
+
+ try {
+ await streamRequest(`/generate/${encodeURIComponent(state.threadId)}/resume/permission`, {
+ decision: decision,
+ request_id: requestId,
+ llm: getLLMConfig()
+ });
+ } catch (error) {
+ if (error && error.name === "AbortError") return;
+ setRunState("failed");
+ addErrorMessage(`Resume error: ${error.message}`);
+ setBusy(false);
+ }
+}
+
+async function resumeClarification(answer) {
+ abortActiveStream();
+ setBusy(true);
+ setRunState("resuming");
+
+ try {
+ await streamRequest(`/generate/${encodeURIComponent(state.threadId)}/resume/clarification`, {
+ answer: answer,
+ llm: getLLMConfig()
+ });
+ } catch (error) {
+ if (error && error.name === "AbortError") return;
+ setRunState("failed");
+ addErrorMessage(`Resume error: ${error.message}`);
+ setBusy(false);
+ }
+}
+
+async function cancelRun() {
+ if (!state.runId) {
+ addSystemMessage("No active run to cancel.");
+ return;
+ }
+
+ const previousState = state.runState;
+ setRunState("cancelling");
+
+ try {
+ await apiRequest(`/generate/runs/${encodeURIComponent(state.runId)}/cancel`, {
+ method: "POST"
+ });
+ addSystemMessage("Cancel requested.");
+ } catch (error) {
+ addErrorMessage(`Cancel error: ${error.message}`);
+ setRunState(previousState);
+ }
+}
+
+// In-memory sessionStorage caching
+function saveChatSession() {
+ if (state.threadId) {
+ sessionStorage.setItem("current_thread_id", state.threadId);
+ sessionStorage.setItem("chat_history_" + state.threadId, elements.chatLog.innerHTML);
+ sessionStorage.setItem("chat_state_" + state.threadId, JSON.stringify({
+ runId: state.runId,
+ runState: state.runState,
+ selectedModel: state.selectedModel,
+ selectedProvider: state.selectedProvider
+ }));
+ }
+}
+
+function restoreChatSession() {
+ const currentThreadId = sessionStorage.getItem("current_thread_id");
+ if (currentThreadId) {
+ state.threadId = currentThreadId;
+ elements.threadId.textContent = currentThreadId;
+
+ const historyHtml = sessionStorage.getItem("chat_history_" + currentThreadId);
+ if (historyHtml) {
+ elements.chatLog.innerHTML = historyHtml;
+ rebindRestoredElements();
+ hideWelcome();
+ }
+
+ const stateData = sessionStorage.getItem("chat_state_" + currentThreadId);
+ if (stateData) {
+ try {
+ const parsed = JSON.parse(stateData);
+ if (parsed.runId) setRunId(parsed.runId);
+ if (parsed.runState) setRunState(parsed.runState);
+ if (parsed.selectedModel && parsed.selectedProvider) {
+ selectModel(parsed.selectedModel, parsed.selectedProvider);
+ }
+ } catch (e) {
+ console.error("Failed to restore session state", e);
+ }
+ }
+ }
+}
+
+function rebindRestoredElements() {
+ // Re-bind collapsible thinking process cards
+ elements.chatLog.querySelectorAll(".thinking-block").forEach(block => {
+ const header = block.querySelector(".thinking-header");
+ const chevron = block.querySelector(".chevron");
+ if (header && chevron) {
+ header.addEventListener("click", () => {
+ block.classList.toggle("collapsed");
+ chevron.textContent = block.classList.contains("collapsed") ? "[+]" : "[-]";
+ });
+ }
+ });
+
+ // Re-bind collapsible tool execution cards
+ elements.chatLog.querySelectorAll(".tool-card").forEach(card => {
+ const header = card.querySelector(".tool-card-header");
+ const chevron = card.querySelector(".tool-toggle-icon");
+ if (header && chevron) {
+ header.addEventListener("click", () => {
+ card.classList.toggle("collapsed");
+ chevron.textContent = card.classList.contains("collapsed") ? "[+]" : "[-]";
+ });
+ }
+ });
+}
+
+// Event Stream Handlers
+async function handleStreamEvent(event) {
+ if (!event || typeof event !== "object") return;
+
+ const type = event.type || "";
+ const data = event.data && typeof event.data === "object" ? event.data : {};
+
+ // Release active thinking block when moving to a different event type
+ // so we don't append to it anymore, but leave it spread/open.
+ if (type !== "assistant.thinking" && state.activeThinkingBlock) {
+ state.activeThinkingBlock = null;
+ }
+
+ switch (type) {
+ case "run.started":
+ setRunId(data.run_id);
+ setThreadId(data.thread_id);
+ setRunState("running");
+ hideWelcome();
+ addSystemMessage(`Run started (Run ID: ${data.run_id})`);
+ break;
+
+ case "title.generated":
+ if (data.title) {
+ elements.sessionTitle.textContent = data.title.toUpperCase();
+ elements.sessionSubtitle.textContent = "ACTIVE WORKSPACE SESSION";
+ }
+ break;
+
+ case "yaml.draft":
+ handleYamlDraft(data);
+ break;
+
+ case "assistant.thinking":
+ await handleThinkingEvent(data);
+ break;
+
+ case "assistant.update":
+ await handleAssistantUpdate(data);
+ break;
+
+ case "plan.created":
+ handlePlanCreated(data);
+ break;
+
+ case "tool.started":
+ handleToolStarted(data);
+ break;
+
+ case "tool.completed":
+ handleToolCompleted(data);
+ break;
+
+ case "tool.failed":
+ handleToolFailed(data);
+ break;
+
+ case "tool.interrupted":
+ handleToolInterrupted(data);
+ break;
+
+ case "permission.required":
+ setRunState("interrupted");
+ handlePermissionRequired(data);
+ break;
+
+ case "clarification.required":
+ setRunState("interrupted");
+ handleClarificationRequired(data);
+ break;
+
+ case "validation.result":
+ handleValidationResult(data);
+ break;
+
+ case "llm.error":
+ addErrorMessage(`LLM ERROR: ${data.message || "Unknown error"}`);
+ break;
+
+ case "run.completed":
+ setRunState("completed");
+ addRunCompletedMessage(data);
+ setBusy(false);
+ break;
+
+ case "run.failed":
+ setRunState("failed");
+ addRunFailedMessage(data);
+ setBusy(false);
+ break;
+
+ case "run.cancelled":
+ setRunState("cancelled");
+ addSystemMessage("Run cancelled by user.");
+ setBusy(false);
+ break;
+
+ default:
+ console.log("Unhandled event type:", type, data);
+ }
+
+ saveChatSession();
+}
+
+// Detail handlers
+async function handleThinkingEvent(data) {
+ const text = data.message || "";
+ if (!text) return;
+
+ if (state.activeThinkingBlock) {
+ const body = state.activeThinkingBlock.querySelector(".thinking-body");
+ await typeText(body, text, true, true);
+ } else {
+ const block = document.createElement("div");
+ block.className = "thinking-block";
+ block.innerHTML = `
+
+
+ `;
+
+ const header = block.querySelector(".thinking-header");
+ const chevron = block.querySelector(".chevron");
+
+ header.addEventListener("click", () => {
+ block.classList.toggle("collapsed");
+ chevron.textContent = block.classList.contains("collapsed") ? "[+]" : "[-]";
+ });
+
+ elements.chatLog.appendChild(block);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+ state.activeThinkingBlock = block;
+
+ const body = block.querySelector(".thinking-body");
+ await typeText(body, text, true, true);
+ }
+}
+
+async function handleAssistantUpdate(data) {
+ const message = data.message || "";
+ if (!message) return;
+ await addMessage("agent", message);
+}
+
+let toolCounter = 0;
+function handleToolStarted(data) {
+ toolCounter++;
+ const toolId = data.run_id || data.id || `tool-${toolCounter}`;
+ const toolName = data.tool || data.name || "tool";
+ const label = data.label || `RUNNING ${toolName.toUpperCase()}`;
+ const args = data.args || {};
+
+ const card = document.createElement("div");
+ card.className = "tool-card running";
+ card.id = toolId;
+ card.dataset.toolName = toolName;
+ card.innerHTML = `
+
+
+ `;
+
+ const header = card.querySelector(".tool-card-header");
+ const chevron = card.querySelector(".tool-toggle-icon");
+
+ header.addEventListener("click", () => {
+ card.classList.toggle("collapsed");
+ chevron.textContent = card.classList.contains("collapsed") ? "[+]" : "[-]";
+ });
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function findMatchingToolCard(data) {
+ if (data.run_id && document.getElementById(data.run_id)) {
+ return document.getElementById(data.run_id);
+ }
+ if (data.id && document.getElementById(data.id)) {
+ return document.getElementById(data.id);
+ }
+
+ const toolName = data.tool || data.name || "tool";
+ const runningCards = elements.chatLog.querySelectorAll(`.tool-card.running[data-tool-name="${toolName}"]`);
+ if (runningCards.length > 0) {
+ return runningCards[0];
+ }
+
+ return elements.chatLog.querySelector(".tool-card.running");
+}
+
+function handleToolCompleted(data) {
+ const card = findMatchingToolCard(data);
+ if (!card) return;
+
+ card.classList.remove("running");
+ card.classList.add("completed");
+
+ const statusIcon = card.querySelector(".tool-status-icon");
+ statusIcon.innerHTML = `[OK]`;
+
+ const duration = data.duration_ms ? ` (${(data.duration_ms / 1000).toFixed(2)}s)` : "";
+ const title = card.querySelector(".tool-title");
+ title.textContent += ` COMPLETED${duration}`;
+
+ const outputContainer = card.querySelector(".tool-output-container");
+ const outputText = card.querySelector(".tool-output-text");
+
+ let resultStr = "";
+ if (data.result !== undefined) {
+ if (typeof data.result === "object") {
+ resultStr = JSON.stringify(data.result, null, 2);
+ } else {
+ resultStr = String(data.result);
+ }
+ }
+
+ if (resultStr) {
+ outputText.textContent = resultStr;
+ outputContainer.classList.remove("hidden");
+ }
+
+ card.classList.add("collapsed");
+ const chevron = card.querySelector(".tool-toggle-icon");
+ if (chevron) chevron.textContent = "[+]";
+
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function handleToolFailed(data) {
+ const card = findMatchingToolCard(data);
+ if (!card) return;
+
+ card.classList.remove("running");
+ card.classList.add("failed");
+
+ const statusIcon = card.querySelector(".tool-status-icon");
+ statusIcon.innerHTML = `[FAILED]`;
+
+ const duration = data.duration_ms ? ` (${(data.duration_ms / 1000).toFixed(2)}s)` : "";
+ const title = card.querySelector(".tool-title");
+ title.textContent += ` FAILED${duration}`;
+
+ const outputContainer = card.querySelector(".tool-output-container");
+ const outputText = card.querySelector(".tool-output-text");
+
+ outputText.textContent = data.error || "Unknown error";
+ outputContainer.classList.remove("hidden");
+
+ card.classList.remove("collapsed");
+ const chevron = card.querySelector(".tool-toggle-icon");
+ if (chevron) chevron.textContent = "[-]";
+
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function handleToolInterrupted(data) {
+ const card = findMatchingToolCard(data);
+ if (!card) return;
+
+ card.classList.remove("running");
+ card.classList.add("interrupted");
+
+ const statusIcon = card.querySelector(".tool-status-icon");
+ statusIcon.innerHTML = `[PAUSED]`;
+
+ const title = card.querySelector(".tool-title");
+ title.textContent += " INTERRUPTED";
+
+ const outputContainer = card.querySelector(".tool-output-container");
+ const outputText = card.querySelector(".tool-output-text");
+
+ outputText.textContent = "Execution paused. Awaiting user response...";
+ outputContainer.classList.remove("hidden");
+
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function handlePermissionRequired(data) {
+ const requestId = data.request_id;
+ const command = data.command || "";
+ const message = data.message || "Permission required to proceed";
+ const source = data.source || "";
+
+ const card = document.createElement("div");
+ card.className = "hitl-card";
+ card.innerHTML = `
+
+
+
${message}
+
${command}
+
+
+ `;
+
+ const allowBtn = card.querySelector(".hitl-allow-btn");
+ const denyBtn = card.querySelector(".hitl-deny-btn");
+
+ const respond = async (decision) => {
+ allowBtn.disabled = true;
+ denyBtn.disabled = true;
+
+ const statusText = document.createElement("div");
+ statusText.style.fontSize = "0.75rem";
+ statusText.style.fontWeight = "700";
+ statusText.style.color = decision === "allow" ? "var(--success)" : "var(--danger)";
+ statusText.textContent = `DECISION: ${decision.toUpperCase()}`;
+
+ card.querySelector(".hitl-card-footer").replaceWith(statusText);
+
+ await resumePermission(decision, requestId);
+ };
+
+ allowBtn.addEventListener("click", () => respond("allow"));
+ denyBtn.addEventListener("click", () => respond("deny"));
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+
+ setBusy(false);
+}
+
+function handleClarificationRequired(data) {
+ const question = data.question || "Clarification needed";
+ const source = data.source || "";
+
+ const card = document.createElement("div");
+ card.className = "hitl-card";
+ card.innerHTML = `
+
+
+
+ `;
+
+ const textarea = card.querySelector(".hitl-textarea");
+ const submitBtn = card.querySelector(".hitl-submit-btn");
+
+ const respond = async () => {
+ const answer = textarea.value.trim();
+ if (!answer) return;
+
+ textarea.disabled = true;
+ submitBtn.disabled = true;
+
+ const answerDisplay = document.createElement("div");
+ answerDisplay.style.fontSize = "0.75rem";
+ answerDisplay.style.marginTop = "0.5rem";
+ answerDisplay.style.color = "var(--text-secondary)";
+ answerDisplay.innerHTML = `ANSWER: ${answer}`;
+
+ card.querySelector(".hitl-card-body").appendChild(answerDisplay);
+ card.querySelector(".hitl-card-footer").remove();
+
+ await resumeClarification(answer);
+ };
+
+ submitBtn.addEventListener("click", respond);
+ textarea.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ respond();
+ }
+ });
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+ textarea.focus();
+
+ setBusy(false);
+}
+
+function handlePlanCreated(data) {
+ const steps = data.steps || [];
+ const filepath = data.planned_filepath || "";
+ if (steps.length === 0) return;
+
+ const card = document.createElement("div");
+ card.className = "validation-card passed";
+
+ const stepsHtml = steps.map((step, idx) => `STEP ${idx + 1}: ${step}`).join("");
+
+ card.innerHTML = `
+
+ [PIPELINE EXECUTION PLAN]
+
+
+ ${filepath ? `
TARGET FILE: ${filepath}
` : ""}
+
+ ${stepsHtml}
+
+
+ `;
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function handleYamlDraft(data) {
+ const content = data.content || "";
+ const filepath = data.filepath || "draft.yml";
+
+ const card = document.createElement("div");
+ card.className = "tool-card completed";
+ card.innerHTML = `
+
+
+ `;
+
+ const header = card.querySelector(".tool-card-header");
+ const chevron = card.querySelector(".tool-toggle-icon");
+
+ header.addEventListener("click", () => {
+ card.classList.toggle("collapsed");
+ chevron.textContent = card.classList.contains("collapsed") ? "[+]" : "[-]";
+ });
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function handleValidationResult(data) {
+ const passed = data.passed === true;
+ const errors = data.errors || [];
+ const warnings = data.warnings || [];
+ const retryCount = data.retry_count || 0;
+
+ const card = document.createElement("div");
+ card.className = `validation-card ${passed ? "passed" : "failed"}`;
+
+ let detailsHtml = "";
+ if (errors.length > 0) {
+ detailsHtml += `ERRORS:${errors.map(e => `- ${e}
`).join("")}
`;
+ }
+ if (warnings.length > 0) {
+ detailsHtml += `WARNINGS:${warnings.map(w => `- ${w}
`).join("")}
`;
+ }
+ if (retryCount > 0) {
+ detailsHtml += `RETRY COUNT: ${retryCount}
`;
+ }
+
+ card.innerHTML = `
+
+ ${passed ? "VALIDATION PASSED" : "VALIDATION FAILED"}
+
+ ${detailsHtml ? `${detailsHtml}
` : ""}
+ `;
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function addRunCompletedMessage(data) {
+ const outputPath = data.output_path || "";
+ const card = document.createElement("div");
+ card.className = "validation-card passed";
+
+ card.innerHTML = `
+
+ [SUCCESS] PIPELINE GENERATED SUCCESSFULLY
+
+
+
The agent has written the CI/CD pipeline code successfully.
+ ${outputPath ? `
FILE LOCATION: ${outputPath}
` : ""}
+
+ `;
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+function addRunFailedMessage(data) {
+ const message = data.message || "An unexpected error occurred during execution.";
+ const detail = data.detail || "";
+
+ const card = document.createElement("div");
+ card.className = "validation-card failed";
+
+ card.innerHTML = `
+
+ [FAILED] GENERATION ERROR
+
+
+
ERROR: ${message}
+ ${detail ? `
DETAILS: ${detail}
` : ""}
+
+ `;
+
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+}
+
+// Helpers
+async function typeText(bubble, text, append = false, isFast = false) {
+ const charDelay = isFast ? 1 : 6;
+ const chunkSize = isFast ? 5 : 1;
+ let currentText = append ? bubble.textContent : "";
+ bubble.classList.add("typing");
+ for (let i = 0; i < text.length; i += chunkSize) {
+ currentText += text.substring(i, i + chunkSize);
+ bubble.textContent = currentText;
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+ await new Promise(r => setTimeout(r, charDelay));
+ }
+ bubble.classList.remove("typing");
+ saveChatSession();
+}
+
+async function addMessage(role, text) {
+ hideWelcome();
+
+ const node = document.createElement("article");
+ node.className = `message ${role}`;
+ node.innerHTML = `
+ ${role.toUpperCase()}
+
+ `;
+ const bubble = node.querySelector(".bubble");
+ elements.chatLog.appendChild(node);
+
+ if (role === "agent") {
+ await typeText(bubble, text);
+ } else {
+ bubble.textContent = text;
+ }
+
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+ saveChatSession();
+ return node;
+}
+
+// System logging
+function addSystemMessage(text) {
+ const node = document.createElement("article");
+ node.className = "message system";
+ node.innerHTML = `${text.toUpperCase()}
`;
+ elements.chatLog.appendChild(node);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+ saveChatSession();
+ return node;
+}
+
+function addErrorMessage(text) {
+ const card = document.createElement("div");
+ card.className = "validation-card failed";
+ card.innerHTML = `
+ [ERROR]
+ ${text.toUpperCase()}
+ `;
+ elements.chatLog.appendChild(card);
+ elements.chatLog.scrollTop = elements.chatLog.scrollHeight;
+ saveChatSession();
+}
+
+function hideWelcome() {
+ const welcome = elements.chatLog.querySelector(".welcome-card");
+ if (welcome) welcome.remove();
+}
+
+function resetSession() {
+ eventQueue.length = 0;
+ abortActiveStream();
+ if (state.threadId) {
+ sessionStorage.removeItem("chat_history_" + state.threadId);
+ sessionStorage.removeItem("chat_state_" + state.threadId);
+ }
+ sessionStorage.removeItem("current_thread_id");
+
+ setThreadId(null);
+ setRunId(null);
+ setRunState("idle");
+
+ elements.chatLog.innerHTML = `
+
+
READY TO GENERATE PIPELINE
+
Configure the target project directory in the sidebar, select a model below, and input generation instructions.
+
+
Create a GitHub actions workflow to build Node.js and run jest tests
+
Create CI to run python linting and pytest, then deploy to AWS on main branch
+
+
+ `;
+
+ setupExampleChips();
+ elements.sessionTitle.textContent = "PIPELINE WORKSPACE";
+ elements.sessionSubtitle.textContent = "Awaiting run requests...";
+ setBusy(false);
+}
+
+function setupExampleChips() {
+ elements.chatLog.querySelectorAll(".example-chip").forEach(chip => {
+ chip.addEventListener("click", () => {
+ elements.promptInput.value = chip.textContent.trim();
+ elements.promptInput.focus();
+ });
+ });
+}
+
+function applyBackendUrlFromQuery() {
+ const params = new URLSearchParams(window.location.search);
+ const backendFromQuery = (params.get("backend") || "").trim();
+ if (backendFromQuery) {
+ try {
+ const parsed = new URL(backendFromQuery);
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") {
+ elements.backendUrl.value = backendFromQuery.replace(/\/$/, "");
+ }
+ } catch (e) {
+ console.warn("Ignoring invalid backend URL from query:", backendFromQuery);
+ }
+ }
+}
+
+// Event bindings
+elements.startBtn.addEventListener("click", runInitialGeneration);
+elements.newThreadBtn.addEventListener("click", resetSession);
+elements.cancelBtn.addEventListener("click", cancelRun);
+
+elements.modelSelectBtn.addEventListener("click", toggleModelDropdown);
+elements.modelSearchInput.addEventListener("input", (e) => {
+ renderModelList(e.target.value);
+});
+
+document.addEventListener("click", (e) => {
+ if (!elements.modelSelectBtn.contains(e.target) && !elements.modelDropdown.contains(e.target)) {
+ hideModelDropdown();
+ }
+});
+
+elements.promptInput.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ if (!state.waiting) {
+ runInitialGeneration();
+ }
+ }
+});
+
+// API Key Modal Listeners
+elements.saveApiKeyBtn.addEventListener("click", () => {
+ if (currentEditingProviderId) {
+ const val = elements.apiKeyInput.value.trim();
+ if (val) {
+ state.apiKeys[currentEditingProviderId] = val;
+ } else {
+ delete state.apiKeys[currentEditingProviderId];
+ }
+ localStorage.setItem("agent_api_keys", JSON.stringify(state.apiKeys));
+ renderProviderKeysList();
+ closeApiKeyModal();
+ }
+});
+
+elements.deleteApiKeyBtn.addEventListener("click", () => {
+ if (currentEditingProviderId) {
+ delete state.apiKeys[currentEditingProviderId];
+ localStorage.setItem("agent_api_keys", JSON.stringify(state.apiKeys));
+ renderProviderKeysList();
+ closeApiKeyModal();
+ }
+});
+
+// Close Modals on close buttons
+document.querySelectorAll(".close-modal-btn").forEach(btn => {
+ btn.addEventListener("click", () => {
+ closeApiKeyModal();
+ elements.customModelModal.classList.add("hidden");
+ });
+});
+
+// Close Modals on backdrop click
+document.querySelectorAll(".modal-overlay").forEach(overlay => {
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) {
+ closeApiKeyModal();
+ overlay.classList.add("hidden");
+ }
+ });
+});
+
+// Custom Model Button Trigger
+elements.openaiCompatibleBtn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ hideModelDropdown();
+ elements.customDisplayName.value = "";
+ elements.customModelId.value = "";
+ elements.customBaseUrl.value = "";
+ elements.customApiKey.value = "";
+ elements.customReasoning.checked = false;
+ elements.customModelModal.classList.remove("hidden");
+});
+
+// Custom Model Save
+elements.saveCustomModelBtn.addEventListener("click", () => {
+ const displayName = elements.customDisplayName.value.trim();
+ const modelId = elements.customModelId.value.trim();
+ const baseUrl = elements.customBaseUrl.value.trim();
+ const apiKey = elements.customApiKey.value.trim();
+ const reasoning = elements.customReasoning.checked;
+
+ if (!displayName || !modelId || !baseUrl) {
+ alert("Display Name, Model ID and Base URL are required.");
+ return;
+ }
+
+ state.customModels[modelId] = {
+ displayName,
+ model: modelId,
+ baseUrl,
+ apiKey,
+ reasoning
+ };
+
+ localStorage.setItem("agent_custom_models", JSON.stringify(state.customModels));
+ elements.customModelModal.classList.add("hidden");
+ processCatalog(state.modelsCatalog); // Re-render lists
+ selectModel(modelId, "openai_compatible");
+});
+
+// Init
+applyBackendUrlFromQuery();
+loadModelsCatalog();
+resetSession();
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..388a690
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,220 @@
+
+
+
+
+
+ AutoPipeline Agent
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
READY TO GENERATE PIPELINE
+
Configure the target project directory in the sidebar, select a model below, and input generation instructions.
+
+
Create a GitHub actions workflow to build Node.js and run jest tests
+
Create CI to run python linting and pytest, then deploy to AWS on main branch
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Enter key value:
+
+
+
+
Credentials are held locally in your browser session storage cache.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/run_demo.py b/ui/run_demo.py
new file mode 100644
index 0000000..aa5ed52
--- /dev/null
+++ b/ui/run_demo.py
@@ -0,0 +1,175 @@
+"""Run backend API and static UI server for the demo in one command.
+
+Usage:
+ python ui/run_demo.py
+ python ui/run_demo.py --backend-port 8000 --ui-port 5173
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import signal
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+from urllib.error import URLError
+from urllib.parse import quote
+from urllib.request import urlopen
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Start agent backend and demo UI servers")
+ parser.add_argument("--host", default="127.0.0.1", help="Host for both servers")
+ parser.add_argument("--backend-port", type=int, default=8000, help="Backend API port")
+ parser.add_argument("--ui-port", type=int, default=5173, help="UI static server port")
+ parser.add_argument("--no-open", action="store_true", help="Do not auto-open browser")
+ return parser.parse_args()
+
+
+def wait_for_health(url: str, timeout_seconds: float = 45.0) -> bool:
+ deadline = time.time() + timeout_seconds
+ while time.time() < deadline:
+ try:
+ with urlopen(url, timeout=2) as response:
+ if response.status == 200:
+ return True
+ except URLError:
+ pass
+ time.sleep(0.5)
+ return False
+
+
+def terminate_process(proc: subprocess.Popen[bytes] | None) -> None:
+ if not proc or proc.poll() is not None:
+ return
+
+ try:
+ if os.name == "nt":
+ proc.send_signal(signal.CTRL_BREAK_EVENT)
+ else:
+ proc.terminate()
+ proc.wait(timeout=5)
+ except Exception:
+ proc.kill()
+
+
+def open_browser_async(url: str) -> None:
+ """Try to open the UI URL without blocking demo startup."""
+
+ def _open() -> None:
+ try:
+ import webbrowser
+
+ opened = webbrowser.open(url, new=2)
+ if not opened:
+ print(f"Could not auto-open browser. Open manually: {url}")
+ except Exception as exc:
+ print(f"Could not auto-open browser ({exc}). Open manually: {url}")
+
+ thread = threading.Thread(target=_open, name="browser-open", daemon=True)
+ thread.start()
+
+
+def main() -> int:
+ args = parse_args()
+
+ repo_root = Path(__file__).resolve().parents[1]
+ ui_dir = repo_root / "ui"
+
+ backend_env = os.environ.copy()
+ src_path = str(repo_root / "src")
+ existing_pythonpath = backend_env.get("PYTHONPATH", "")
+ backend_env["PYTHONPATH"] = src_path if not existing_pythonpath else f"{src_path}{os.pathsep}{existing_pythonpath}"
+
+ backend_cmd = [
+ sys.executable,
+ "-m",
+ "uvicorn",
+ "agent.api.api:app",
+ "--host",
+ args.host,
+ "--port",
+ str(args.backend_port),
+ ]
+
+ ui_cmd = [
+ sys.executable,
+ "-m",
+ "http.server",
+ str(args.ui_port),
+ "--bind",
+ args.host,
+ "--directory",
+ str(ui_dir),
+ ]
+
+ creationflags = subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
+
+ health_url = f"http://{args.host}:{args.backend_port}/health"
+ backend_proc: subprocess.Popen[bytes] | None = None
+ backend_owned = False
+
+ if wait_for_health(health_url, timeout_seconds=1.5):
+ print("Using existing backend API...")
+ else:
+ print("Starting backend API...")
+ backend_proc = subprocess.Popen(
+ backend_cmd,
+ cwd=str(repo_root),
+ env=backend_env,
+ creationflags=creationflags,
+ )
+ backend_owned = True
+
+ if not wait_for_health(health_url):
+ print("Backend did not become healthy in time. Stopping.")
+ if backend_owned:
+ terminate_process(backend_proc)
+ return 1
+
+ print("Starting UI server...")
+ ui_proc = subprocess.Popen(
+ ui_cmd,
+ cwd=str(repo_root),
+ creationflags=creationflags,
+ )
+
+ backend_base = f"http://{args.host}:{args.backend_port}"
+ ui_url = f"http://{args.host}:{args.ui_port}/?backend={quote(backend_base, safe='')}"
+ print("")
+ print("Demo is running")
+ print(f"Backend: {health_url}")
+ print(f"UI: {ui_url}")
+ print("Press Ctrl+C to stop both servers.")
+
+ if not args.no_open:
+ open_browser_async(ui_url)
+
+ try:
+ while True:
+ backend_code = backend_proc.poll() if backend_owned and backend_proc else None
+ ui_code = ui_proc.poll()
+
+ if backend_code is not None:
+ print(f"Backend exited with code {backend_code}. Stopping UI.")
+ break
+ if ui_code is not None:
+ print(f"UI server exited with code {ui_code}. Stopping backend.")
+ break
+
+ time.sleep(0.5)
+ except KeyboardInterrupt:
+ print("\nStopping demo servers...")
+ finally:
+ terminate_process(ui_proc)
+ if backend_owned:
+ terminate_process(backend_proc)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/ui/styles.css b/ui/styles.css
new file mode 100644
index 0000000..35837dd
--- /dev/null
+++ b/ui/styles.css
@@ -0,0 +1,1031 @@
+:root {
+ --bg-dark: #000000;
+ --bg-sidebar: #0a0a0a;
+ --bg-chat: #000000;
+ --bg-card: #0c0c0c;
+ --bg-card-hover: #141414;
+
+ --border-color: #262626;
+ --border-focus: #ffffff;
+
+ --text-primary: #ffffff;
+ --text-secondary: #8a8a8a;
+ --text-muted: #555555;
+
+ --success: #00ff66;
+ --success-bg: rgba(0, 255, 102, 0.05);
+ --success-border: #00ff66;
+
+ --warning: #ffcc00;
+ --warning-bg: rgba(255, 204, 0, 0.05);
+ --warning-border: #ffcc00;
+
+ --danger: #ff3333;
+ --danger-bg: rgba(255, 51, 51, 0.05);
+ --danger-border: #ff3333;
+
+ --font-mono: "IBM Plex Mono", Consolas, Monaco, monospace;
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border-radius: 0 !important; /* Force no rounded corners everywhere */
+}
+
+body {
+ margin: 0;
+ font-family: var(--font-mono);
+ background-color: var(--bg-dark);
+ color: var(--text-primary);
+ min-height: 100vh;
+ overflow-x: hidden;
+}
+
+/* App Shell Structure */
+.app-shell {
+ display: grid;
+ grid-template-columns: 260px 1fr; /* Compacted from 320px */
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+}
+
+/* Sidebar / Control Panel */
+.control-panel {
+ background: var(--bg-sidebar);
+ border-right: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+ max-height: 100vh;
+ overflow: hidden;
+}
+
+.sidebar-header {
+ padding: 1rem; /* Compacted from 1.5rem */
+ border-bottom: 1px solid var(--border-color);
+}
+
+.logo-text h2 {
+ font-size: 0.95rem; /* Compacted from 1.1rem */
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ color: var(--text-primary);
+}
+
+.logo-sub {
+ font-size: 0.65rem; /* Compacted from 0.7rem */
+ color: var(--text-secondary);
+ letter-spacing: 0.1em;
+}
+
+.sidebar-scrollable {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+ padding: 1rem 1rem 1.5rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.9rem;
+}
+
+/* Scrollbars */
+::-webkit-scrollbar {
+ width: 6px; /* Compacted from 8px */
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: #000000;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #262626;
+ border: 1px solid #000000;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #404040;
+}
+
+/* Form Groups */
+.settings-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.45rem; /* Compacted from 0.75rem */
+}
+
+.settings-group h3 {
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ letter-spacing: 0.1em;
+ color: var(--text-secondary);
+ font-weight: 600;
+ border-left: 2px solid #ffffff;
+ padding-left: 0.4rem;
+}
+
+.section-desc {
+ font-size: 0.65rem;
+ color: var(--text-muted);
+ line-height: 1.3;
+}
+
+.input-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem; /* Compacted from 0.35rem */
+}
+
+.input-wrapper label {
+ font-size: 0.65rem; /* Compacted from 0.7rem */
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+input[type="text"],
+input[type="password"],
+select,
+textarea {
+ width: 100%;
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.45rem 0.5rem; /* Compacted from 0.6rem */
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 0.75rem; /* Compacted from 0.8rem */
+ outline: none;
+ transition: border-color 0.1s;
+}
+
+input[type="text"]:focus,
+input[type="password"]:focus,
+select:focus,
+textarea:focus {
+ border-color: var(--border-focus);
+}
+
+/* Provider List */
+.provider-keys-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem; /* Compacted from 0.4rem */
+}
+
+.provider-key-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.4rem 0.5rem; /* Compacted from 0.5rem */
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.provider-key-item:hover {
+ background: var(--bg-card-hover);
+ border-color: #404040;
+}
+
+.provider-key-item .provider-name {
+ font-weight: 600;
+}
+
+.provider-key-item .key-status {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+}
+
+.dot {
+ width: 4px; /* Compacted from 5px */
+ height: 4px;
+ display: inline-block;
+}
+
+.dot-set {
+ background-color: var(--success);
+}
+
+.dot-unset {
+ background-color: var(--text-muted);
+}
+
+/* Status Panel */
+.status-meta {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem; /* Compacted from 0.5rem */
+}
+
+.status-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+}
+
+.status-row .label {
+ color: var(--text-secondary);
+}
+
+.status-row .val {
+ font-weight: 500;
+}
+
+.code-font {
+ font-size: 0.7rem;
+ color: var(--text-primary);
+}
+
+.badge {
+ padding: 0.1rem 0.35rem; /* Compacted from 0.15rem 0.4rem */
+ font-size: 0.65rem; /* Compacted from 0.7rem */
+ font-weight: 600;
+ border: 1px solid var(--border-color);
+}
+
+.badge-idle { color: var(--text-secondary); border-color: var(--text-secondary); }
+.badge-running { color: #ffffff; border-color: #ffffff; }
+.badge-resuming { color: #ffffff; border-color: #ffffff; }
+.badge-interrupted { color: var(--warning); border-color: var(--warning); }
+.badge-completed { color: var(--success); border-color: var(--success); }
+.badge-failed { color: var(--danger); border-color: var(--danger); }
+.badge-cancelled { color: var(--text-muted); border-color: var(--text-muted); }
+
+/* Chat Panel */
+.chat-panel {
+ background: var(--bg-chat);
+ display: grid;
+ grid-template-rows: 46px 1fr auto; /* Compacted from 60px */
+ height: 100vh;
+ position: relative;
+}
+
+.chat-header {
+ border-bottom: 1px solid var(--border-color);
+ padding: 0 1rem; /* Compacted from 1.5rem */
+ display: flex;
+ align-items: center;
+}
+
+.chat-header-info h2 {
+ font-size: 0.85rem; /* Compacted from 0.95rem */
+ font-weight: 700;
+ letter-spacing: 0.05em;
+}
+
+.chat-header-info p {
+ font-size: 0.65rem; /* Compacted from 0.7rem */
+ color: var(--text-secondary);
+}
+
+/* Chat feed log */
+.chat-log {
+ padding: 1rem; /* Compacted from 1.5rem */
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem; /* Compacted from 1rem */
+}
+
+.welcome-card {
+ max-width: 600px; /* Compacted from 650px */
+ margin: 1rem auto; /* Compacted from 2rem */
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ padding: 1rem; /* Compacted from 1.5rem */
+}
+
+.welcome-card h3 {
+ font-size: 0.8rem; /* Compacted from 0.9rem */
+ margin-bottom: 0.4rem;
+ font-weight: 700;
+}
+
+.welcome-card p {
+ color: var(--text-secondary);
+ font-size: 0.75rem; /* Compacted from 0.8rem */
+ line-height: 1.45;
+ margin-bottom: 0.75rem;
+}
+
+.welcome-examples {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.example-chip {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.4rem 0.6rem; /* Compacted from 0.5rem 0.75rem */
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.1s;
+}
+
+.example-chip:hover {
+ border-color: #ffffff;
+ color: #ffffff;
+}
+
+/* Message Bubbles */
+.message {
+ display: flex;
+ flex-direction: column;
+ max-width: 90%;
+}
+
+.message.user {
+ align-self: flex-end;
+}
+
+.message.agent {
+ align-self: flex-start;
+}
+
+.message.system {
+ align-self: center;
+ max-width: 100%;
+}
+
+.message .role {
+ font-size: 0.65rem; /* Compacted from 0.7rem */
+ font-weight: 700;
+ color: var(--text-secondary);
+ margin-bottom: 0.2rem;
+}
+
+.message.user .role {
+ align-self: flex-end;
+}
+
+.message .bubble {
+ background: var(--bg-card);
+ border: 1px solid var(--border-color);
+ padding: 0.5rem 0.75rem; /* Compacted from 0.75rem 1rem */
+ font-size: 0.75rem; /* Compacted from 0.8rem */
+ line-height: 1.4;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.message.user .bubble {
+ background: #000000;
+ border-color: #ffffff;
+}
+
+.message.system .bubble {
+ background: transparent;
+ border: none;
+ color: var(--text-muted);
+ font-size: 0.7rem;
+ padding: 0;
+ text-align: center;
+}
+
+/* Thinking Blocks */
+.thinking-block {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ margin: 0.3rem 0; /* Compacted from 0.5rem */
+ width: 100%;
+ max-width: 800px;
+}
+
+.thinking-header {
+ padding: 0.4rem 0.6rem; /* Compacted from 0.5rem 0.75rem */
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ color: var(--text-secondary);
+ font-weight: 600;
+ cursor: pointer;
+ user-select: none;
+ background: #0a0a0a;
+}
+
+.thinking-header:hover {
+ background: #141414;
+}
+
+.thinking-header .chevron {
+ margin-left: auto;
+ font-size: 0.65rem;
+}
+
+.thinking-body {
+ padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ color: var(--text-secondary);
+ white-space: pre-wrap;
+ border-top: 1px solid var(--border-color);
+ line-height: 1.35;
+}
+
+.thinking-block.collapsed .thinking-body {
+ display: none;
+}
+
+/* Tool execution card */
+.tool-card {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ margin: 0.3rem 0; /* Compacted from 0.5rem */
+ width: 100%;
+ max-width: 800px;
+}
+
+.tool-card-header {
+ padding: 0.4rem 0.6rem; /* Compacted from 0.5rem 0.75rem */
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ cursor: pointer;
+ user-select: none;
+}
+
+.tool-card-header:hover {
+ background: #0a0a0a;
+}
+
+.tool-status-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.7rem;
+ font-weight: 700;
+}
+
+.tool-title {
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ font-weight: 600;
+ color: var(--text-primary);
+ flex: 1;
+}
+
+.tool-toggle-icon {
+ font-size: 0.65rem;
+ color: var(--text-secondary);
+}
+
+.tool-card-body {
+ padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ font-size: 0.7rem;
+}
+
+.tool-card.collapsed .tool-card-body {
+ display: none;
+}
+
+.tool-meta pre {
+ margin-top: 0.2rem;
+ padding: 0.4rem;
+ background: #050505;
+ border: 1px solid var(--border-color);
+ font-family: var(--font-mono);
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ overflow-x: auto;
+ color: var(--text-secondary);
+}
+
+.tool-output-container pre {
+ margin-top: 0.2rem;
+ padding: 0.4rem;
+ background: #050505;
+ border: 1px solid var(--border-color);
+ font-family: var(--font-mono);
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ max-height: 180px; /* Compacted from 250px */
+ overflow: auto;
+ color: var(--text-primary);
+}
+
+/* Tool execution status states */
+.tool-card.running {
+ border-color: var(--text-muted);
+}
+
+.tool-card.running .tool-title {
+ color: var(--text-muted);
+}
+
+.tool-card.completed {
+ border-color: var(--success-border);
+}
+
+.tool-card.completed .tool-status-icon {
+ color: var(--success);
+}
+
+.tool-card.failed {
+ border-color: var(--danger-border);
+}
+
+.tool-card.failed .tool-status-icon {
+ color: var(--danger);
+}
+
+.tool-card.failed .tool-output-container pre {
+ color: var(--danger);
+}
+
+.tool-card.interrupted {
+ border-color: var(--warning-border);
+}
+
+.tool-card.interrupted .tool-status-icon {
+ color: var(--warning);
+}
+
+/* Square retro spinner */
+.spinner {
+ width: 9px; /* Compacted from 10px */
+ height: 9px;
+ border: 2px solid #555555;
+ border-top-color: #ffffff;
+ animation: spin 0.8s linear infinite;
+ display: inline-block;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Interactive HITL Cards (Inline) */
+.hitl-card {
+ background: #0c0c0c;
+ border: 1px solid var(--warning-border);
+ padding: 0.75rem; /* Compacted from 1rem */
+ width: 100%;
+ max-width: 600px;
+ margin: 0.5rem 0; /* Compacted from 0.75rem */
+ align-self: flex-start;
+}
+
+.hitl-card-header {
+ font-weight: 700;
+ font-size: 0.75rem; /* Compacted from 0.8rem */
+ color: var(--warning);
+ margin-bottom: 0.4rem;
+}
+
+.hitl-card-body {
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ line-height: 1.35;
+ margin-bottom: 0.6rem;
+}
+
+.hitl-command-block {
+ font-family: var(--font-mono);
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ padding: 0.4rem;
+ background: #000000;
+ border: 1px solid var(--border-color);
+ margin-top: 0.3rem;
+ color: var(--warning);
+ overflow-x: auto;
+}
+
+.hitl-card-footer {
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+}
+
+.hitl-textarea {
+ flex: 1;
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.35rem;
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ resize: none;
+}
+
+/* Validation Card */
+.validation-card {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ margin: 0.3rem 0; /* Compacted from 0.5rem */
+ width: 100%;
+ max-width: 800px;
+ padding: 0.5rem 0.6rem; /* Compacted from 0.75rem */
+}
+
+.validation-card.passed { border-color: var(--success-border); }
+.validation-card.failed { border-color: var(--danger-border); }
+
+.validation-title {
+ font-weight: 700;
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ margin-bottom: 0.35rem;
+}
+
+.validation-card.passed .validation-title { color: var(--success); }
+.validation-card.failed .validation-title { color: var(--danger); }
+
+.validation-details {
+ font-family: var(--font-mono);
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ padding: 0.4rem;
+ background: #050505;
+ border: 1px solid var(--border-color);
+ overflow-x: auto;
+ white-space: pre-wrap;
+}
+
+/* Prompt Box */
+.prompt-box-container {
+ padding: 0.5rem 1rem 0.75rem; /* Compacted from 1rem 1.5rem 1.5rem */
+ border-top: 1px solid var(--border-color);
+ background: #000000;
+}
+
+.prompt-input-card {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.4rem; /* Compacted from 0.5rem */
+ display: flex;
+ flex-direction: column;
+}
+
+.prompt-input-card:focus-within {
+ border-color: #ffffff;
+}
+
+.prompt-input-card textarea {
+ background: transparent;
+ border: none;
+ padding: 0.15rem; /* Compacted from 0.25rem */
+ resize: none;
+ font-size: 0.75rem; /* Compacted from 0.8rem */
+ color: var(--text-primary);
+ line-height: 1.35;
+}
+
+.prompt-input-card textarea:focus {
+ border: none;
+ box-shadow: none;
+}
+
+.prompt-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-top: 1px solid var(--border-color);
+ padding-top: 0.35rem; /* Compacted from 0.5rem */
+ margin-top: 0.15rem; /* Compacted from 0.25rem */
+}
+
+.toolbar-left {
+ display: flex;
+ gap: 0.35rem;
+ align-items: center;
+}
+
+.toolbar-right {
+ display: flex;
+ gap: 0.35rem;
+ align-items: center;
+}
+
+/* Toolbar Controls */
+.toolbar-btn {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.3rem 0.5rem; /* Compacted from 0.4rem 0.6rem */
+ color: var(--text-secondary);
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ font-weight: 600;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.toolbar-btn:hover {
+ border-color: #ffffff;
+ color: #ffffff;
+}
+
+.chevron-arrow {
+ font-size: 0.65rem;
+ color: var(--text-secondary);
+}
+
+.toolbar-select {
+ background: #000000;
+ border: 1px solid var(--border-color);
+ padding: 0.3rem 0.5rem; /* Compacted from 0.4rem 0.6rem */
+ color: var(--text-secondary);
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ font-weight: 600;
+ width: auto;
+ cursor: pointer;
+}
+
+.toolbar-select:hover {
+ border-color: #ffffff;
+ color: #ffffff;
+}
+
+.toolbar-select:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+/* Button Classes */
+.btn {
+ font-family: var(--font-mono);
+ font-weight: 700;
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ padding: 0.3rem 0.6rem; /* Compacted from 0.4rem 0.8rem */
+ border: 1px solid var(--border-color);
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ background: #000000;
+ color: #ffffff;
+ transition: all 0.1s;
+}
+
+.primary-btn {
+ background: #ffffff;
+ color: #000000;
+ border-color: #ffffff;
+}
+
+.primary-btn:hover:not(:disabled) {
+ background: #cccccc;
+}
+
+.primary-btn:disabled {
+ background: #1c1c1c;
+ color: #555555;
+ border-color: #262626;
+ cursor: not-allowed;
+}
+
+.danger-btn {
+ border-color: var(--danger-border);
+ color: var(--danger);
+}
+
+.danger-btn:hover:not(:disabled) {
+ background: var(--danger);
+ color: #000000;
+}
+
+.danger-btn:disabled {
+ opacity: 0.3;
+ cursor: not-allowed;
+}
+
+.ghost-btn {
+ background: #000000;
+ border-color: var(--border-color);
+ color: var(--text-secondary);
+}
+
+.ghost-btn:hover:not(:disabled) {
+ border-color: #ffffff;
+ color: #ffffff;
+}
+
+.w-100 { width: 100%; }
+.mt-sm { margin-top: 0.4rem; }
+.flex-center { display: flex; align-items: center; justify-content: center; gap: 0.35rem; }
+
+/* Model Picker Wrapper and Dropdown */
+.model-picker-wrapper {
+ position: relative;
+}
+
+.model-dropdown {
+ position: absolute;
+ bottom: calc(100% + 5px);
+ left: 0;
+ width: 280px; /* Compacted from 320px */
+ background: #000000;
+ border: 1px solid #ffffff;
+ display: flex;
+ flex-direction: column;
+ max-height: 300px; /* Compacted from 380px */
+ z-index: 1000;
+}
+
+.model-search-box {
+ padding: 0.4rem;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.model-list {
+ flex: 1;
+ overflow-y: auto;
+ max-height: 180px; /* Compacted from 250px */
+}
+
+.provider-header {
+ font-size: 0.65rem;
+ color: #ffffff;
+ font-weight: 700;
+ padding: 0.4rem 0.6rem 0.2rem;
+ border-bottom: 1px dotted var(--border-color);
+}
+
+.model-item {
+ padding: 0.35rem 0.6rem; /* Compacted from 0.4rem 0.75rem */
+ font-size: 0.7rem; /* Compacted from 0.75rem */
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+}
+
+.model-item:hover {
+ background: #141414;
+}
+
+.model-item.active {
+ background: #ffffff;
+ color: #000000;
+}
+
+.model-item.active .model-item-id {
+ color: #555555;
+}
+
+.model-item-details {
+ display: flex;
+ flex-direction: column;
+}
+
+.model-item-name {
+ font-weight: 600;
+}
+
+.model-item-id {
+ font-size: 0.65rem; /* Compacted from 0.7rem */
+ color: var(--text-secondary);
+}
+
+.model-badges {
+ display: flex;
+ gap: 0.3rem;
+}
+
+.model-badge {
+ font-size: 0.6rem; /* Compacted from 0.65rem */
+ padding: 0.05rem 0.2rem;
+ border: 1px solid var(--border-color);
+ font-weight: 600;
+}
+
+.model-item.active .model-badge {
+ border-color: #000000;
+}
+
+.model-dropdown-footer {
+ border-top: 1px solid var(--border-color);
+ padding: 0.4rem;
+ background: #0a0a0a;
+}
+
+/* Modals Overlay */
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 10000;
+}
+
+.modal {
+ background: #000000;
+ border: 1px solid #ffffff;
+ width: min(400px, 92vw); /* Compacted from 450px */
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.modal-header {
+ padding: 0.75rem 1rem; /* Compacted from 1rem 1.25rem */
+ border-bottom: 1px solid var(--border-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.modal-header h3 {
+ font-size: 0.75rem; /* Compacted from 0.85rem */
+ font-weight: 700;
+}
+
+.close-modal-btn {
+ background: transparent;
+ border: none;
+ color: var(--text-secondary);
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.close-modal-btn:hover {
+ color: #ffffff;
+}
+
+.modal-body {
+ padding: 1rem; /* Compacted from 1.25rem */
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem; /* Compacted from 1rem */
+}
+
+.modal-hint {
+ font-size: 0.65rem;
+ color: var(--text-muted);
+ line-height: 1.35;
+}
+
+.modal-footer {
+ padding: 0.6rem 1rem; /* Compacted from 0.75rem 1.25rem */
+ background: #0a0a0a;
+ border-top: 1px solid var(--border-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.row-actions {
+ display: flex;
+ gap: 0.4rem;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.form-group label {
+ font-size: 0.65rem; /* Compacted from 0.7rem */
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.checkbox-row {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.4rem;
+ margin-top: 0.15rem;
+}
+
+.checkbox-row label {
+ cursor: pointer;
+ user-select: none;
+ font-size: 0.65rem;
+}
+
+.checkbox-row input[type="checkbox"] {
+ width: 12px;
+ height: 12px;
+ accent-color: #ffffff;
+ cursor: pointer;
+}
+
+/* Hidden Class */
+.hidden {
+ display: none !important;
+}
+
+/* Responsive Overrides */
+@media (max-width: 900px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .control-panel {
+ display: none;
+ }
+
+ .chat-panel {
+ height: 100vh;
+ }
+}