From eb6a2619968f6f77a43dc960b4fc1658be2d02f2 Mon Sep 17 00:00:00 2001 From: Mathitz Date: Tue, 14 Jul 2026 00:58:16 -0300 Subject: [PATCH] feat: professional TUI with tabbed interface, runtime provider switching, and API key gating --- .gitignore | 12 +- DESIGN.md | 9 +- Dockerfile | 2 +- README.md | 31 +- VERSION | 2 +- config.yaml => config.example.yml | 45 +- core/__init__.py | 0 core/config.py | 103 +++- core/orchestrator.py | 31 +- core/providers.py | 2 +- install.sh | 155 +++-- main.py | 118 +++- memory/__init__.py | 0 ui/__init__.py | 0 ui/themes.py | 200 +++++-- ui/tui.py | 966 ++++++++++++++++++++++++++---- 16 files changed, 1445 insertions(+), 231 deletions(-) rename config.yaml => config.example.yml (52%) create mode 100644 core/__init__.py mode change 100644 => 100755 install.sh create mode 100644 memory/__init__.py create mode 100644 ui/__init__.py diff --git a/.gitignore b/.gitignore index 0d4dec1..e381763 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ env/ .env .env.* +# Config (contains API keys and user settings) +config.yml + # IDE .vscode/ .idea/ @@ -32,5 +35,12 @@ dist/ build/ *.egg-info/ +# Generated by install.sh +bin/ + +# Crystallized skills (user-generated) +skills/ +!skills/.gitkeep + # Test artifacts -test_doc.md \ No newline at end of file +test_doc.md diff --git a/DESIGN.md b/DESIGN.md index 8b9bb05..db67801 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -60,4 +60,11 @@ The "Aha!" moment. When a task is completed: 1. **Phase 1: Memory Foundation** (SQLite setup + Facilitator) — ✅ Complete 2. **Phase 2: Retrieval Integration** (Integrating semantic search into the system prompt) — ✅ Complete (sqlite-vec wired, numpy cosine fallback) 3. **Phase 3: The Learning Loop** (Implementing Skill Synthesis) — ✅ Complete (wired into MotionAgent.run) -4. **Phase 4: DX & Optimization** (Caveman mode & TUI) — 🟡 In Progress (Caveman bidirectional, TUI with orchestration panel; providers still mock) +4. **Phase 4: DX & Optimization** (Caveman mode & TUI) — ✅ Complete + - Caveman bidirectional compression wired into MotionAgent.run + - Professional TUI with 5 tabs (Chat, Tasks, Skills, Memory, Settings) + - Runtime provider/model switching via Select dropdown + - 4 native Textual themes (One Dark, Solarized Light, Nord, Dracula) + - Ctrl+C cancels requests; Ctrl+Q quits; graceful shutdown + - asyncio.Event-based task orchestration (no polling) + - Graceful KeyboardInterrupt handling in REPL mode diff --git a/Dockerfile b/Dockerfile index 0285f79..10b03f7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,4 +23,4 @@ ENV PYTHONPATH=/app ENV PYTHONUNBUFFERED=1 # Command to run the TUI -CMD ["python3", "ui/tui.py"] +CMD ["python3", "main.py"] diff --git a/README.md b/README.md index cf30019..df00ef6 100644 --- a/README.md +++ b/README.md @@ -37,16 +37,30 @@ It treats every successful task trajectory as a learning event, crystallizing ex Get the harness running in under 60 seconds. ```bash -# 1. Run the automated installer +# 1. Run the installer chmod +x install.sh && ./install.sh # 2. Refresh your shell -source ~/.config/fish/config.fish # or your shell equivalent +source ~/.config/fish/config.fish # or .zshrc / .bashrc -# 3. Launch the TUI +# 3. Configure your provider +cp config.example.yml config.yml +# Edit config.yml — set your API keys in .env or directly in config.yml + +# 4. Launch motion ``` +### CLI Usage + +``` +motion # Launch TUI (default) +motion --provider ollama-cloud/glm-5.2 # TUI with specific provider/model +motion --chat # Legacy REPL mode +motion --list # List available providers/models +motion --test # Run Caveman compression test +``` + *For detailed native installation and GPU configuration, see the [Setup Guide](docs/setup.md).* --- @@ -67,9 +81,14 @@ When a complex task is solved, the harness doesn't just forget. It analyzes the ### 🎨 Pro-Grade TUI A high-performance terminal interface built with `Textual`. Featuring: -- **Multi-Theme Support**: One Dark, Solarized Light, Nord, Dracula. -- **Live Task Monitoring**: Real-time status of parallel agent workers. -- **Workspace Management**: Instant context switching between projects. +- **5-Tab Interface**: Chat, Tasks, Skills, Memory, Settings +- **Provider Dropdown**: Switch models at runtime without restarting +- **4 Native Themes**: One Dark, Solarized Light, Nord, Dracula — cascade through every widget +- **Live Task Monitoring**: Real-time status of parallel agent workers with ⏳⚙️✅❌ indicators +- **Memory Search**: Hybrid semantic + keyword search directly in the TUI +- **Skill Browser**: Browse, search, and inspect crystallized skills +- **Dashboard Integration**: One-click open to the admin dashboard at `https://localhost:7860/` +- **Ctrl+C cancels requests** (doesn't kill the app), **Ctrl+Q quits** --- diff --git a/VERSION b/VERSION index 02d44fb..6e172a0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0-beta.1 \ No newline at end of file +0.2.0-beta.1 diff --git a/config.yaml b/config.example.yml similarity index 52% rename from config.yaml rename to config.example.yml index f562dd7..627b95f 100644 --- a/config.yaml +++ b/config.example.yml @@ -6,11 +6,47 @@ default_theme: "one_dark" max_parallel_tasks: null # Set to integer to override CPU-based auto-scaling # Provider Configuration -# You can define multiple providers and pick one as default. +# Providers with a "models" list share endpoint/api_key/type across models. +# Use provider_id/model_name to pick a specific model (e.g. ollama-cloud/gemma3:12b). # API keys can be set here or via environment variables: -# ANTHROPIC_API_KEY, OPENAI_API_KEY, PROXY_API_KEY +# ANTHROPIC_API_KEY, OPENAI_API_KEY, OLLAMA_API_KEY, PROXY_API_KEY providers: - default: "claude-3-5" + default: "ollama-cloud" + + ollama-cloud: + name: "Ollama Cloud" + endpoint: "https://ollama.com/v1" + api_key: null # Set via OLLAMA_API_KEY env var + provider_type: "cloud" + default_model: "gemma3:12b" + models: + gemma3:12b: + temperature: 0.7 + max_tokens: 4096 + deepseek-v4-flash: + temperature: 0.7 + max_tokens: 4096 + qwen3-coder:480b: + temperature: 0.5 + max_tokens: 8192 + nematron-3-super: + temperature: 0.7 + max_tokens: 4096 + glm-5.2: + temperature: 0.7 + max_tokens: 4096 + gemma4:31b: + temperature: 0.7 + max_tokens: 4096 + qwen3.5:397b: + temperature: 0.7 + max_tokens: 4096 + glm-5.1: + temperature: 0.7 + max_tokens: 4096 + minimax-m2.7: + temperature: 0.7 + max_tokens: 4096 claude-3-5: name: "Claude 3.5 Sonnet" @@ -43,9 +79,8 @@ providers: max_tokens: 4096 # Embedding configuration -# Used for semantic search in the Hybrid Memory layer. # Local providers use Ollama /api/embeddings; cloud-only setups # fall back to a deterministic hash-based vector. embedding: - provider: "local-llama" # Which provider to use for embeddings + provider: "local-llama" dimension: 128 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/config.py b/core/config.py index f556a5b..af9b3cc 100644 --- a/core/config.py +++ b/core/config.py @@ -12,8 +12,14 @@ class AppConfig: default_provider: str class ConfigManager: - def __init__(self, config_path: str = "config.yaml"): - self.config_path = config_path + CONFIG_PATHS = ["config.yml", "config.example.yml"] + + def __init__(self, config_path: str = ""): + if config_path: + self.config_path = config_path + else: + # Try config.yml first, fall back to config.example.yml + self.config_path = next((p for p in self.CONFIG_PATHS if os.path.exists(p)), self.CONFIG_PATHS[0]) self.data = self._load_config() def _load_config(self) -> Dict[str, Any]: @@ -35,14 +41,97 @@ def set(self, key: str, value: Any): yaml.dump(self.data, f) def get_provider_config(self, provider_id: str) -> Dict[str, Any]: + """Resolve a provider config, supporting provider/model syntax. + + Examples: + 'ollama-cloud' -> default model from default_model or options.model + 'ollama-cloud/gemma4:31b' -> ollama-cloud with model overridden to gemma4:31b + """ + # Split provider/model if present + if '/' in provider_id: + base_id, model_name = provider_id.split('/', 1) + else: + base_id, model_name = provider_id, None + providers = self.data.get("providers", {}) - config = providers.get(provider_id, {}) - # Override api_key from environment if available - env_key = f"{provider_id.replace('-', '_').upper()}_API_KEY" + config = providers.get(base_id, {}) + if not config: + raise ValueError(f"Unknown provider: {base_id}") + + # Resolve api_key from environment + env_key = f"{base_id.replace('-', '_').upper()}_API_KEY" env_val = os.environ.get(env_key) + if not env_val: + prefix = base_id.split('-')[0].upper() + generic_key = f"{prefix}_API_KEY" + env_val = os.environ.get(generic_key) if env_val: - config["api_key"] = env_val + config = {**config, "api_key": env_val} + + # Resolve model: explicit model_name > default_model > options.model + models = config.get("models", {}) + if models: + # Provider uses models list + chosen_model = model_name or config.get("default_model") or next(iter(models)) + if chosen_model not in models: + raise ValueError(f"Unknown model '{chosen_model}' for provider '{base_id}'. Available: {', '.join(models.keys())}") + model_opts = models[chosen_model] + config = { + **config, + "name": f"{config.get('name', base_id)} ({chosen_model})", + "options": {"model": chosen_model, **model_opts}, + } + elif model_name: + # No models list, but user specified a model override + config = { + **config, + "options": {**config.get("options", {}), "model": model_name}, + } + return config def get_default_provider(self) -> str: - return os.environ.get("MOTION_DEFAULT_PROVIDER") or self.data.get("providers", {}).get("default", "claude-3-5") + return os.environ.get("MOTION_DEFAULT_PROVIDER") or self.data.get("providers", {}).get("default", "ollama-cloud") + + def has_api_key(self, provider_id: str) -> bool: + """Check whether a provider has a usable API key (env var or config).""" + if '/' in provider_id: + base_id = provider_id.split('/', 1)[0] + else: + base_id = provider_id + + providers = self.data.get("providers", {}) + cfg = providers.get(base_id, {}) + + # Check config file first + config_key = cfg.get("api_key") + if config_key: + return True + + # Check environment variables + env_key = f"{base_id.replace('-', '_').upper()}_API_KEY" + if os.environ.get(env_key): + return True + prefix = base_id.split('-')[0].upper() + generic_key = f"{prefix}_API_KEY" + if os.environ.get(generic_key): + return True + + # Local providers don't need a key + if cfg.get("provider_type") == "local": + return True + + return False + + def list_providers(self) -> list: + """Return list of (provider_id, name, models, has_key) tuples.""" + providers = self.data.get("providers", {}) + default = self.get_default_provider() + result = [] + for pid, cfg in providers.items(): + if pid == "default": + continue + models = list(cfg.get("models", {}).keys()) if "models" in cfg else [cfg.get("options", {}).get("model", "?")] + has_key = self.has_api_key(pid) + result.append((pid, cfg.get("name", pid), models, pid == default.split('/')[0], has_key)) + return result diff --git a/core/orchestrator.py b/core/orchestrator.py index feb497e..9d5e6fb 100644 --- a/core/orchestrator.py +++ b/core/orchestrator.py @@ -4,9 +4,13 @@ from typing import List, Dict, Any, Optional from dataclasses import dataclass, field from datetime import datetime +import logging + from core.providers import ModelConfig, ProviderFactory from main import MotionAgent +logger = logging.getLogger(__name__) + @dataclass class TaskRequest: prompt: str @@ -24,10 +28,13 @@ class TaskStatus: start_time: Optional[datetime] = None end_time: Optional[datetime] = None + class TaskManager: """ Orchestrates parallel execution of MotionAgent tasks. Handles hardware-aware concurrency and per-task model routing. + + Uses asyncio.Event per task for efficient notification instead of polling. """ def __init__(self, default_model_config: ModelConfig, workspace_path: str): self.default_config = default_model_config @@ -38,27 +45,40 @@ def __init__(self, default_model_config: ModelConfig, workspace_path: str): self.semaphore = asyncio.Semaphore(self.max_workers) self.tasks: Dict[str, TaskStatus] = {} + self._events: Dict[str, asyncio.Event] = {} self.active_count = 0 async def spawn_task(self, request: TaskRequest, model_override: Optional[ModelConfig] = None) -> str: """ Queue a new task for execution. Returns the task_id. + Callers can await wait_for_task(task_id) instead of polling. """ self.tasks[request.task_id] = TaskStatus( task_id=request.task_id, prompt=request.prompt, status="PENDING" ) + self._events[request.task_id] = asyncio.Event() # Schedule execution without blocking the main loop asyncio.create_task(self._execute_task(request, model_override)) return request.task_id + async def wait_for_task(self, task_id: str, timeout: Optional[float] = None) -> TaskStatus: + """Wait for a task to complete. Returns the final TaskStatus.""" + event = self._events.get(task_id) + if not event: + raise ValueError(f"Unknown task: {task_id}") + try: + await asyncio.wait_for(event.wait(), timeout=timeout) + except asyncio.TimeoutError: + pass + return self.tasks[task_id] + async def _execute_task(self, request: TaskRequest, model_override: Optional[ModelConfig] = None): - # Check capacity for warning if self.semaphore.locked(): - print(f"⚠️ Capacity Reached: {self.active_count}/{self.max_workers} workers active. Task {request.task_id} queued.") + logger.info(f"Capacity reached: {self.active_count}/{self.max_workers} workers. Task {request.task_id} queued.") async with self.semaphore: self.active_count += 1 @@ -66,14 +86,8 @@ async def _execute_task(self, request: TaskRequest, model_override: Optional[Mod self.tasks[request.task_id].start_time = datetime.now() try: - # 1. Determine model config (Override -> Request ID -> Default) config = model_override or self.default_config - - # 2. Spawn isolated agent - # We pass the workspace_path to ensure the agent works in the correct root agent = MotionAgent(config, memory_path=f"memory_{request.task_id}.db") - - # 3. Execute result = await agent.run(request.prompt, target="agent") self.tasks[request.task_id].result = result @@ -84,6 +98,7 @@ async def _execute_task(self, request: TaskRequest, model_override: Optional[Mod finally: self.tasks[request.task_id].end_time = datetime.now() self.active_count -= 1 + self._events[request.task_id].set() def get_status(self) -> Dict[str, Any]: return { diff --git a/core/providers.py b/core/providers.py index fdc2c1f..186d923 100644 --- a/core/providers.py +++ b/core/providers.py @@ -74,7 +74,7 @@ class CloudProvider(BaseProvider): async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: endpoint = self.config.endpoint - api_key = self.config.api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY", "") + api_key = self.config.api_key or os.environ.get("OLLAMA_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY", "") # Detect provider family from endpoint if "anthropic" in endpoint: diff --git a/install.sh b/install.sh old mode 100644 new mode 100755 index 70bc4b8..738fb9a --- a/install.sh +++ b/install.sh @@ -1,57 +1,128 @@ #!/bin/bash +# ────────────────────────────────────────────────────────────────────────────── +# Motion Harness — Native Installer +# +# Sets up a Python venv, installs dependencies, and creates a `motion` shell +# command so you can launch the TUI from anywhere with a single word. +# +# Usage: chmod +x install.sh && ./install.sh +# ────────────────────────────────────────────────────────────────────────────── -# Motion Harness Installer -# This script automates the setup of the Motion Harness Docker distribution. +set -e + +REPO_DIR="$(cd "$(dirname "$0")" && pwd)" +VENV_DIR="$REPO_DIR/.venv" echo "🚀 Installing Motion Harness..." +echo " Repo: $REPO_DIR" -# 1. Check for Docker -if ! command -v docker &> /dev/null; then - echo "❌ Error: Docker is not installed. Please install Docker Desktop first." +# ── 1. Python ───────────────────────────────────────────────────────────────── +if ! command -v python3 &> /dev/null; then + echo "❌ Error: python3 not found. Install Python 3.11+ first." exit 1 fi -# 2. Build the Docker Image -echo "📦 Building the MotionL Harness image (this may take a few minutes)..." -docker build -t motion-harness . - -# 3. Create the global alias -# Detect shell to determine the correct config file -SHELL_FILE="" -if [[ "$SHELL" == *"zsh"* ]]; then - SHELL_FILE="$HOME/.zshrc" -elif [[ "$SHELL" == *"bash"* ]]; then - SHELL_FILE="$HOME/.bashrc" -elif [[ "$SHELL" == *"fish"* ]]; then - # Fish uses a different syntax for aliases/functions - SHELL_FILE="$HOME/.config/fish/config.fish" -fi - -if [ -z "$SHELL_FILE" ]; then - echo "⚠️ Could not detect shell config file. You will need to add the alias manually." - echo "Command: alias motion='docker run -it --rm -v \"\$(pwd):/app\" -v \"\$HOME/.motion_harness:/root/.hermes\" motion-harness'" - exit 0 -fi +PY_VERSION=$(python3 -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')") +echo " Python: $PY_VERSION" -# Add the alias to the config file -# We use a function for fish and an alias for bash/zsh -if [[ "$SHELL_FILE" == *.fish ]]; then - ALIAS_CMD="function motion; docker run -it --rm -v \"(pwd):/app\" -v \"$HOME/.motion_harness:/root/.hermes\" motion-harness; end" +# ── 2. Virtual environment ───────────────────────────────────────────────────── +if [ ! -d "$VENV_DIR" ]; then + echo "📦 Creating virtual environment..." + python3 -m venv "$VENV_DIR" else - ALIAS_CMD="alias motion='docker run -it --rm -v \"$(pwd):/app\" -v \"$HOME/.motion_harness:/root/.hermes\" motion-harness'" + echo "✅ Virtual environment exists" fi -# Check if alias already exists to avoid duplicates -if grep -q "alias motion=" "$SHELL_FILE" 2>/dev/null || grep -q "function motion" "$SHELL_FILE" 2>/dev/null; then - echo "✅ Alias 'motion' already exists in $SHELL_FILE." +echo "📥 Installing dependencies..." +"$VENV_DIR/bin/pip" install -q --upgrade pip +"$VENV_DIR/bin/pip" install -q -r "$REPO_DIR/requirements.txt" + +# ── 3. Config ────────────────────────────────────────────────────────────────── +if [ ! -f "$REPO_DIR/config.yml" ]; then + if [ -f "$REPO_DIR/config.example.yml" ]; then + echo "📋 Copying config.example.yml → config.yml" + cp "$REPO_DIR/config.example.yml" "$REPO_DIR/config.yml" + echo " Edit config.yml and .env with your API keys." + fi else - echo "📝 Adding 'motion' alias to $SHELL_FILE..." - echo "$ALIAS_CMD" >> "$SHELL_FILE" + echo "✅ config.yml exists" fi -echo "-----------------------------------------------------------------" -echo "✨ Installation Complete!" -echo "1. Please restart your terminal or run: source $SHELL_FILE" -echo "2. You can now launch the harness from any project folder using:" -echo " motion" -echo "-----------------------------------------------------------------" +# ── 4. Create the wrapper script ─────────────────────────────────────────────── +WRAPPER="$REPO_DIR/bin/motion" +mkdir -p "$REPO_DIR/bin" + +cat > "$WRAPPER" << WRAPPER_EOF +#!/bin/bash +# Motion Harness launcher — created by install.sh +REPO_DIR="$REPO_DIR" +VENV_DIR="$REPO_DIR/.venv" +export PYTHONPATH="\$REPO_DIR" +cd "\$REPO_DIR" +exec "\$VENV_DIR/bin/python" "\$REPO_DIR/main.py" "\$@" +WRAPPER_EOF + +chmod +x "$WRAPPER" +echo "✅ Wrapper script: $WRAPPER" + +# ── 5. Shell integration ─────────────────────────────────────────────────────── +DETECTED_SHELL="${SHELL##*/}" +echo "🐚 Detected shell: $DETECTED_SHELL" + +install_shell_integration() { + local shell_type="$1" + local config_file="$2" + + # Remove old motion-harness block if present + if grep -q "# motion-harness" "$config_file" 2>/dev/null; then + # Remove everything between the markers (inclusive) + sed -i '' '/# motion-harness-start/,/# motion-harness-end/d' "$config_file" 2>/dev/null || true + fi + + echo "" >> "$config_file" + echo "# motion-harness-start" >> "$config_file" + if [ "$shell_type" = "fish" ]; then + echo "function motion" >> "$config_file" + echo " \"$WRAPPER\" \$argv" >> "$config_file" + echo "end" >> "$config_file" + else + echo "alias motion=\"$WRAPPER\"" >> "$config_file" + fi + echo "# motion-harness-end" >> "$config_file" + echo "📝 Added 'motion' command to $config_file" +} + +case "$DETECTED_SHELL" in + fish) + FISH_CONFIG="$HOME/.config/fish/config.fish" + mkdir -p "$(dirname "$FISH_CONFIG")" + install_shell_integration fish "$FISH_CONFIG" + ;; + zsh) + install_shell_integration zsh "$HOME/.zshrc" + ;; + bash) + install_shell_integration bash "$HOME/.bashrc" + ;; + *) + echo "⚠️ Unknown shell: $DETECTED_SHELL" + echo " Add this to your shell config manually:" + echo " alias motion='$WRAPPER'" + ;; +esac + +# ── Done ─────────────────────────────────────────────────────────────────────── +echo "" +echo "───────────────────────────────────────────────────────────────" +echo "✨ Motion Harness installed!" +echo "" +echo " Quick start:" +echo " source ~/.config/fish/config.fish # or .zshrc / .bashrc" +echo " motion # Launch TUI" +echo " motion --provider ollama-cloud/glm-5.2 # With specific model" +echo " motion --chat # Legacy REPL" +echo " motion --list # List providers" +echo "" +echo " Config: $REPO_DIR/config.yml" +echo " Venv: $VENV_DIR" +echo "───────────────────────────────────────────────────────────────" \ No newline at end of file diff --git a/main.py b/main.py index 0f3ccb2..559be5d 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ from core.providers import ModelConfig, ProviderFactory, LocalProvider +from core.config import ConfigManager from core.caveman import CavemanProtocol from core.learning import SkillSynthesizer, Trajectory from memory.db import MemoryDB, EMBEDDING_DIM @@ -6,7 +7,9 @@ import asyncio import hashlib import logging +import os +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logger = logging.getLogger(__name__) class MotionAgent: @@ -67,8 +70,59 @@ async def run(self, prompt: str, target: str = "user"): return final_response +def load_agent_from_config(config_path: str = "", provider_id: str | None = None) -> MotionAgent: + """Load a MotionAgent using settings from config.yml (or config.example.yml) and .env.""" + # Load .env file if present + config_dir = os.path.dirname(os.path.abspath(config_path)) if config_path else "." + env_path = os.path.join(config_dir, ".env") + if os.path.exists(env_path): + with open(env_path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + key, value = key.strip(), value.strip() + if key and value: + os.environ.setdefault(key, value) + + cm = ConfigManager(config_path) + provider_id = provider_id or cm.get_default_provider() + provider_cfg = cm.get_provider_config(provider_id) + + model_config = ModelConfig( + name=provider_cfg.get("name", provider_id), + endpoint=provider_cfg["endpoint"], + api_key=provider_cfg.get("api_key"), + provider_type=provider_cfg.get("provider_type", "cloud"), + options=provider_cfg.get("options", {}), + ) + return MotionAgent(model_config) + + +def list_providers(config_path: str = ""): + """Print available providers and models from config.""" + cm = ConfigManager(config_path) + default = cm.get_default_provider() + print("Available providers:") + for pid, name, models, is_default, has_key in cm.list_providers(): + marker = " ← default" if is_default and '/' not in default else "" + key_icon = "🔑" if has_key else "🔒" + if len(models) > 1: + print(f" {key_icon} {pid:20s} {name}") + for m in models: + sel = "*" if (is_default and f"{pid}/{m}" == default) or (is_default and m == models[0] and '/' not in default) else " " + print(f" {sel} {m}") + else: + m = models[0] if models else "?" + sel = "*" if is_default else " " + print(f" {key_icon} {pid:20s} {name:30s} model={m}{marker}") + print(f"\nUsage: python main.py --provider ollama-cloud/gemma4:31b") + print(f" python main.py --provider ollama-cloud # uses default model") + + async def test_compression(): - config = ModelConfig(name="Claude-3.5", endpoint="https://api.anthropic.com", provider_type="cloud") + """Test Caveman compression without needing a live model.""" + config = ModelConfig(name="Test", endpoint="https://ollama.com/v1", provider_type="cloud") agent = MotionAgent(config) fluffy_response = "Certainly! I have analyzed the files and found that the bug is in line 42. I'm sorry for the inconvenience. Please let me know if you need further assistance." @@ -92,5 +146,65 @@ async def test_compression(): assert len(agent_output) < len(fluffy_response) print("\n✅ Caveman integration verified: Tokens reduced for internal communication!") + +async def interactive_chat(provider_id: str | None = None): + """Interactive chat using the configured provider (fallback non-TUI mode).""" + agent = load_agent_from_config(provider_id=provider_id) + provider_name = agent.provider.config.name + print(f"🤖 Motion Agent — using {provider_name}") + print("Type a message (or 'quit' to exit):\n") + + try: + while True: + try: + prompt = input("You> ").strip() + except EOFError: + break + if not prompt or prompt.lower() in ("quit", "exit", "q"): + break + try: + response = await agent.run(prompt) + print(f"\nAgent> {response}\n") + except Exception as e: + print(f"\n❌ Error: {e}\n") + except (KeyboardInterrupt, asyncio.CancelledError): + print("\n\n👋 Bye!") + finally: + try: + await asyncio.wait_for(agent.provider.close(), timeout=2.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + agent.memory.close() + + if __name__ == "__main__": - asyncio.run(test_compression()) + import sys + import argparse + + parser = argparse.ArgumentParser(description="Motion Agent") + parser.add_argument("--test", action="store_true", help="Run Caveman compression test (no model needed)") + parser.add_argument("--list", action="store_true", help="List available providers") + parser.add_argument("--provider", type=str, default=None, help="Provider to use (e.g. ollama-cloud, ollama-cloud/gemma4:31b, claude-3-5)") + parser.add_argument("--chat", action="store_true", help="Launch in chat REPL mode instead of TUI") + args = parser.parse_args() + + if args.list: + list_providers() + elif args.test: + asyncio.run(test_compression()) + elif args.chat: + asyncio.run(interactive_chat(provider_id=args.provider)) + else: + # Launch the TUI by default + from ui.tui import launch_tui + config = ConfigManager() + provider_id = args.provider or config.get_default_provider() + provider_cfg = config.get_provider_config(provider_id) + model_config = ModelConfig( + name=provider_cfg.get("name", provider_id), + endpoint=provider_cfg["endpoint"], + api_key=provider_cfg.get("api_key"), + provider_type=provider_cfg.get("provider_type", "cloud"), + options=provider_cfg.get("options", {}), + ) + launch_tui(model_config, provider_id=provider_id) diff --git a/memory/__init__.py b/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/themes.py b/ui/themes.py index f0a18a8..dd57cd7 100644 --- a/ui/themes.py +++ b/ui/themes.py @@ -1,57 +1,153 @@ -from dataclasses import dataclass -from typing import Dict, Tuple - -@dataclass -class Theme: - name: str - background: str - foreground: str - accent: str - secondary: str - border: str - highlight: str +"""Motion Harness theme definitions using Textual's native Theme system. + +Each theme is registered with the Textual App via ``register_theme()``. +Setting ``app.theme = "one_dark"`` cascades through every CSS ``$variable`` +in the entire widget tree — no manual style-patching needed. +""" + +from __future__ import annotations + +from typing import Dict, List + +from textual.theme import Theme as TextualTheme + + +# ── Theme definitions ──────────────────────────────────────────────────────── +# Each dict provides all the colours needed to build both: +# 1. A Textual-native Theme (for app.theme = "one_dark") +# 2. A lightweight dataclass-style Theme (backward compat for ThemeRegistry) + +_RAW_THEMES: List[dict] = [ + dict( + id="one_dark", + name="One Dark", + background="#282c34", + surface="#2c313a", + foreground="#abb2bf", + primary="#61afef", + secondary="#5c6370", + accent="#61afef", + border="#3e4451", + highlight="#3e4451", + error="#e06c75", + success="#98c379", + warning="#e5c07b", + dark=True, + ), + dict( + id="solarized_light", + name="Solarized Light", + background="#fdf6e3", + surface="#eee8d5", + foreground="#657b83", + primary="#268bd2", + secondary="#93a1a1", + accent="#268bd2", + border="#d3cbb7", + highlight="#eee8d5", + error="#dc322f", + success="#859900", + warning="#b58900", + dark=False, + ), + dict( + id="dracula", + name="Dracula", + background="#282a36", + surface="#44475a", + foreground="#f8f8f2", + primary="#bd93f9", + secondary="#6272a4", + accent="#bd93f9", + border="#44475a", + highlight="#44475a", + error="#ff5555", + success="#50fa7b", + warning="#f1fa8c", + dark=True, + ), + dict( + id="nord", + name="Nord", + background="#2e3440", + surface="#3b4252", + foreground="#d8dee9", + primary="#88c0d0", + secondary="#4c566a", + accent="#88c0d0", + border="#3b4252", + highlight="#434c5e", + error="#bf616a", + success="#a3be8c", + warning="#ebcb8b", + dark=True, + ), +] + + +# ── Build registries ───────────────────────────────────────────────────────── + +class LightweightTheme: + """Minimal theme object used by old code that references .background etc.""" + def __init__(self, name: str, background: str, foreground: str, + accent: str, secondary: str, border: str, highlight: str): + self.name = name + self.background = background + self.foreground = foreground + self.accent = accent + self.secondary = secondary + self.border = border + self.highlight = highlight + + +_TEXTUAL_THEMES: Dict[str, TextualTheme] = {} +_LIGHT_THEMES: Dict[str, LightweightTheme] = {} + +for d in _RAW_THEMES: + tid = d["id"] + + # Textual-native theme + _TEXTUAL_THEMES[tid] = TextualTheme( + name=tid, + primary=d["primary"], + secondary=d["secondary"], + background=d["background"], + surface=d["surface"], + foreground=d["foreground"], + accent=d["accent"], + error=d["error"], + success=d["success"], + warning=d["warning"], + dark=d["dark"], + panel=d["border"], + boost=d["highlight"], + ) + + # Lightweight theme + _LIGHT_THEMES[tid] = LightweightTheme( + name=d["name"], + background=d["background"], + foreground=d["foreground"], + accent=d["accent"], + secondary=d["secondary"], + border=d["border"], + highlight=d["highlight"], + ) + class ThemeRegistry: - # Colors inspired by popular VS Code themes - THEMES = { - "one_dark": Theme( - name="One Dark", - background="#282c34", - foreground="#abb2bf", - accent="#61afef", - secondary="#5c6370", - border="#3e4451", - highlight="#3e4451" - ), - "solarized_light": Theme( - name="Solarized Light", - background="#fdf6e3", - foreground="#657b83", - accent="#268bd2", - secondary="#93a1a1", - border="#eee8d5", - highlight="#eee8d5" - ), - "dracula": Theme( - name="Dracula", - background="#282a36", - foreground="#f8f8f2", - accent="#bd93f9", - secondary="#6272a4", - border="#44475a", - highlight="#44475a" - ), - "nord": Theme( - name="Nord", - background="#2e3440", - foreground="#d8dee9", - accent="#88c0d0", - secondary="#4c566a", - border="#3b4252", - highlight="#434c5e" - ) - } + """Backward-compatible registry. Also exposes Textual-native themes.""" + + THEMES = _LIGHT_THEMES # lightweight .background / .foreground etc @classmethod - def get_theme(cls, theme_name: str) -> Theme: + def get_theme(cls, theme_name: str) -> LightweightTheme: return cls.THEMES.get(theme_name, cls.THEMES["one_dark"]) + + @classmethod + def get_textual_theme(cls, theme_name: str) -> TextualTheme: + return _TEXTUAL_THEMES.get(theme_name, _TEXTUAL_THEMES["one_dark"]) + + @classmethod + def theme_ids(cls) -> List[str]: + return list(_TEXTUAL_THEMES.keys()) \ No newline at end of file diff --git a/ui/tui.py b/ui/tui.py index 5054792..fedabef 100644 --- a/ui/tui.py +++ b/ui/tui.py @@ -1,156 +1,914 @@ +""" +Motion Harness — Professional TUI +================================== +Built on Textual with native theme switching and runtime provider selection. + +Screens: + - ProviderSelect: Pick a provider/model at startup + - MainScreen: Tabbed hub (Chat, Tasks, Skills, Memory, Settings) + +Key features: + - Themes cascade through every widget via Textual's ``$variable`` system + - Ctrl+T cycles themes instantly + - Settings tab has a SelectableDropdown for switching provider/model at runtime + - Ctrl+C cancels the current request; Ctrl+Q quits + +Launch: python main.py → TUI (default) + python main.py --chat → old REPL + python main.py --provider X → TUI with pre-selected provider +""" + +from __future__ import annotations + +import asyncio +import os +import webbrowser +from datetime import datetime +from pathlib import Path +from typing import Optional + +from textual import work from textual.app import App, ComposeResult -from textual.widgets import Header, Footer, Input, Static, ScrollableContainer, ListItem, ListView, Label -from textual.containers import Container, Vertical, Horizontal from textual.binding import Binding -from ui.themes import ThemeRegistry -from core.providers import ModelConfig, ProviderFactory +from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.screen import Screen +from textual.widgets import ( + Button, + Header, + Footer, + Input, + Label, + ListItem, + ListView, + Select, + Static, + TabbedContent, + TabPane, +) + +from core.config import ConfigManager from core.orchestrator import TaskManager, TaskRequest +from core.providers import ModelConfig from main import MotionAgent -import asyncio -import os +from ui.themes import ThemeRegistry WORKSPACE = os.getenv("MOTION_WORKSPACE", os.getcwd()) +DASHBOARD_URL = "https://localhost:7860/" +DASHBOARD_ADMIN_KEY = "ME27dXc6uoEC_dWXJCyPVDPN" -class MotionTUI(App): + +# ─── Shared state ───────────────────────────────────────────────────────────── + +class AppState: + """Reactive state shared across all screens.""" + + def __init__(self) -> None: + self.agent: Optional[MotionAgent] = None + self.task_manager: Optional[TaskManager] = None + self.config_manager: ConfigManager = ConfigManager() + self.current_provider_id: str = "" + self.current_theme: str = "one_dark" + self.caveman_enabled: bool = True + + def reconnect(self, provider_id: str) -> None: + """Re-create the agent and task manager for a new provider/model.""" + cfg = self.config_manager.get_provider_config(provider_id) + model_config = ModelConfig( + name=cfg.get("name", provider_id), + endpoint=cfg["endpoint"], + api_key=cfg.get("api_key"), + provider_type=cfg.get("provider_type", "cloud"), + options=cfg.get("options", {}), + ) + # Close old connections if any + if self.agent: + try: + self.agent.memory.close() + except Exception: + pass + self.agent = MotionAgent(model_config) + self.task_manager = TaskManager(model_config, WORKSPACE) + self.current_provider_id = provider_id + + @staticmethod + def build_provider_options() -> list[tuple[str, str]]: + """Return [(display_label, provider_id), ...] for Select dropdowns. + Only includes providers that have a configured API key (or are local).""" + cm = ConfigManager() + providers = cm.list_providers() + options: list[tuple[str, str]] = [] + for pid, name, models, is_default, has_key in providers: + if not has_key: + continue + if len(models) > 1: + for m in models: + full = f"{pid}/{m}" + options.append((f"{name} → {m}", full)) + elif models: + full = f"{pid}/{models[0]}" if models[0] != "?" else pid + options.append((f"{name}", full)) + else: + options.append((name, pid)) + return options + + @staticmethod + def build_all_provider_info() -> list[tuple[str, str, list, bool, bool]]: + """Return all providers: (pid, name, models, is_default, has_key).""" + cm = ConfigManager() + return cm.list_providers() + + +# ─── Small widgets ──────────────────────────────────────────────────────────── + +class ChatMessage(Static): + """A single message in the chat log with formatted sender labels.""" + + PREFIX = {"user": "You", "agent": "Agent", "system": "⚡"} + + def __init__(self, text: str, sender: str = "user", **kwargs) -> None: + safe = text.replace("[", "\\[").replace("]", "\\]") + prefix = self.PREFIX.get(sender, sender) + if sender == "user": + formatted = f"[bold cyan]{prefix}:[/] {safe}" + elif sender == "agent": + formatted = f"[bold green]{prefix}:[/] {safe}" + else: + formatted = f"[dim]{prefix}:[/] {safe}" + super().__init__(formatted, classes=f"msg {sender}_msg", **kwargs) + + +class TaskRow(Static): + """One row in the task panel.""" + + ICON = {"PENDING": "⏳", "RUNNING": "⚙️", "COMPLETED": "✅", "FAILED": "❌"} + COLOR = {"PENDING": "dim", "RUNNING": "bold yellow", "COMPLETED": "bold green", "FAILED": "bold red"} + + def __init__(self, task_id: str, prompt: str, status: str, **kwargs) -> None: + self._task_id = task_id + self._prompt = prompt + self._status = status + icon = self.ICON.get(status, "?") + color = self.COLOR.get(status, "") + display = f"[dim]{task_id}[/] {icon} [{color}]{status}[/] [dim]{prompt[:50]}[/]" + super().__init__(display, id=f"task-{task_id}", **kwargs) + + +class ProviderOption(ListItem): + """A selectable provider row on the startup screen.""" + + def __init__(self, provider_id: str, name: str, models: list, is_default: bool, has_key: bool = True, **kwargs) -> None: + self.provider_id = provider_id + self.models = models + self.has_key = has_key + marker = " ← default" if is_default else "" + lock = "" if has_key else " [dim red]🔒 no key[/]" + label = f"⚡ {name}{marker}{lock}" + if len(models) > 1: + label += f" [dim]({', '.join(models[:3])}{'…' if len(models) > 3 else ''})[/dim]" + super().__init__(Label(label), **kwargs) + + +# ─── Provider selection screen ──────────────────────────────────────────────── + +class ProviderSelectScreen(Screen): + """Pick a provider/model at startup.""" + + CSS = """ + #provider_screen { + align: center middle; + } + #provider_box { + width: 72; + height: auto; + max-height: 85%; + border: round $primary; + border-title: " Motion Harness "; + padding: 1 3; + background: $surface; + overflow-y: auto; + } + #provider_title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 0; + } + #provider_subtitle { + text-align: center; + color: $text-muted; + margin-bottom: 1; + } + #provider_list { + height: auto; + max-height: 22; + border: solid $border; + padding: 0 1; + background: $background; + } + #provider_status { + text-align: center; + color: $text-muted; + margin-top: 1; + } """ - Professional TUI for Motion Harness. - Chat interface with theme support and parallel task orchestration. + + BINDINGS = [ + Binding("enter", "select", "Select"), + Binding("q", "quit", "Quit"), + ] + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state + + def compose(self) -> ComposeResult: + with Container(id="provider_screen"): + with Container(id="provider_box"): + yield Label("⚡ Motion Harness", id="provider_title") + yield Label("Select a provider:", id="provider_subtitle") + yield ListView(id="provider_list") + yield Label("↑↓ Navigate · Enter Select · Q Quit", id="provider_status") + + def on_mount(self) -> None: + providers = self.state.config_manager.list_providers() + lv = self.query_one("#provider_list", ListView) + for pid, name, models, is_default, has_key in providers: + lv.append(ProviderOption(pid, name, models, is_default, has_key)) + + def action_select(self) -> None: + lv = self.query_one("#provider_list", ListView) + idx = lv.index + if idx is None: + return + option = lv.children[idx] + if not isinstance(option, ProviderOption): + return + + if not option.has_key: + self.notify("🔒 No API key configured for this provider", severity="warning") + return + + provider_id = option.provider_id + models = option.models + provider_cfg = self.state.config_manager.get_provider_config(provider_id) + default_model = provider_cfg.get("default_model") or (models[0] if models else None) + full_id = f"{provider_id}/{default_model}" if default_model else provider_id + + try: + self.state.reconnect(full_id) + except Exception as e: + self.notify(f"Connection failed: {e}", severity="error") + return + + self.app.switch_screen(MainScreen(self.state)) + + +# ─── Main hub screen (tabbed) ──────────────────────────────────────────────── + +class MainScreen(Screen): + """The main hub with Chat, Tasks, Skills, Memory, Settings tabs.""" + + CSS = """ + #main_tabs { height: 1fr; } + TabbedContent TabPane { + padding: 0 1; + } """ + + BINDINGS = [ + Binding("ctrl+t", "toggle_theme", "Theme"), + Binding("ctrl+q", "quit", "Quit"), + ] + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with TabbedContent(id="main_tabs"): + with TabPane("💬 Chat", id="chat_tab"): + yield ChatPane(self.state) + with TabPane("⚙️ Tasks", id="tasks_tab"): + yield TasksPane(self.state) + with TabPane("🎓 Skills", id="skills_tab"): + yield SkillsPane(self.state) + with TabPane("🧠 Memory", id="memory_tab"): + yield MemoryPane(self.state) + with TabPane("🔧 Settings", id="settings_tab"): + yield SettingsPane(self.state) + yield Footer() + + def action_toggle_theme(self) -> None: + themes = ThemeRegistry.theme_ids() + idx = themes.index(self.state.current_theme) + self.state.current_theme = themes[(idx + 1) % len(themes)] + # Use Textual's native theme system — cascades through ALL CSS vars + self.app.theme = self.state.current_theme + self.notify(f"Theme → {self.state.current_theme}") + + +# ─── Chat pane ──────────────────────────────────────────────────────────────── + +class ChatPane(Container): + """Message history + input bar.""" + CSS = """ - Screen { - background: $background; - color: $foreground; + ChatPane { + layout: vertical; + height: 1fr; } - #chat_container { + #chat_log { height: 1fr; - border: solid $border; - padding: 1; - overflow-y: scroll; + border: round $primary; + border-title: " Chat "; + padding: 0 1; + overflow-y: auto; + scrollbar-size: 1 1; + background: $background; } - #task_panel { + #chat_input_row { height: auto; - max-height: 8; - border: solid $border; - padding: 0 1; - overflow-y: scroll; + padding: 1 0 0 0; } - #input_area { + #chat_input { height: 3; - border-top: solid $border; - padding: 0 1; + border: round $primary; } - .message { - margin: 1 0; + .msg { + margin: 0 0; padding: 0 1; } .user_msg { - color: $accent; + color: $primary; text-style: bold; + background: $surface; + padding: 0 1; + margin: 0 0; } .agent_msg { color: $foreground; + background: transparent; + padding: 0 1; + margin: 0 0; } - .task_status { - color: $secondary; + .system_msg { + color: $text-muted; + text-style: italic; + padding: 0 1; + margin: 0 0; } """ - BINDINGS = [ - Binding("ctrl+t", "toggle_theme", "Toggle Theme"), - Binding("ctrl+s", "show_status", "Task Status"), - Binding("ctrl+c", "quit", "Quit"), - ] + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state - def __init__(self, model_config: ModelConfig, workspace: str = WORKSPACE): - super().__init__() - self.model_config = model_config - self.agent = MotionAgent(model_config) - self.task_manager = TaskManager(model_config, workspace) - self.current_theme_name = "one_dark" + def compose(self) -> ComposeResult: + yield VerticalScroll(id="chat_log") + with Horizontal(id="chat_input_row"): + yield Input(placeholder="Type a message… (Enter to send)", id="chat_input") def on_mount(self) -> None: - self.apply_theme(self.current_theme_name) - - def apply_theme(self, theme_name: str): - theme = ThemeRegistry.get_theme(theme_name) - self.theme_vars = { - "background": theme.background, - "foreground": theme.foreground, - "accent": theme.accent, - "secondary": theme.secondary, - "border": theme.border, - "highlight": theme.highlight, - } - self.screen.styles.background = theme.background - self.screen.styles.color = theme.foreground - self.title = f"Motion Harness - {theme.name}" + name = self.state.agent.provider.config.name if self.state.agent else "?" + log = self.query_one("#chat_log", VerticalScroll) + log.border_title = "Chat" + log.mount(ChatMessage(f"⚡ Motion Harness — connected to {name}", sender="system")) + self.query_one("#chat_input", Input).focus() + + async def on_input_submitted(self, event: Input.Submitted) -> None: + text = event.value.strip() + if not text: + return + event.input.value = "" + + log = self.query_one("#chat_log", VerticalScroll) + log.mount(ChatMessage(text, sender="user")) + thinking = ChatMessage("⚙️ Thinking…", sender="system") + log.mount(thinking) + log.scroll_end(animate=False) + + self._run_agent(text, thinking) + + @work(exclusive=True, name="agent_chat") + async def _run_agent(self, prompt: str, thinking: ChatMessage) -> None: + log = self.query_one("#chat_log", VerticalScroll) + try: + response = await self.state.agent.run(prompt, target="user") + thinking.remove() + log.mount(ChatMessage(response, sender="agent")) + except asyncio.CancelledError: + thinking.remove() + log.mount(ChatMessage("⏹ Cancelled.", sender="system")) + except Exception as e: + thinking.remove() + log.mount(ChatMessage(f"❌ {e}", sender="system")) + finally: + log.scroll_end(animate=False) + + +# ─── Tasks pane ─────────────────────────────────────────────────────────────── + +class TasksPane(Container): + """Live task orchestration dashboard.""" + + CSS = """ + #tasks_container { + height: 1fr; + border: round $primary; + border-title: " Tasks "; + padding: 1; + background: $background; + } + #tasks_header { + height: auto; + margin-bottom: 1; + color: $primary; + text-style: bold; + } + #task_list { + height: 1fr; + scrollbar-size: 1 1; + } + #task_input_row { + height: auto; + padding: 1 0 0 0; + } + #task_input { + border: round $primary; + } + """ + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state def compose(self) -> ComposeResult: - yield Header() - with Container(id="main_container"): - yield ScrollableContainer(id="task_panel") - yield ScrollableContainer(id="chat_container") - yield Horizontal( - Input(placeholder="Enter prompt... (Ctrl+Enter to send)", id="user_input"), - id="input_area" - ) - yield Footer() + with Vertical(id="tasks_container"): + yield Label("⚙️ Task Orchestrator", id="tasks_header") + yield VerticalScroll(id="task_list") + with Horizontal(id="task_input_row"): + yield Input(placeholder="Spawn a new task… (Enter to submit)", id="task_input") + + def on_mount(self) -> None: + self._update_header() async def on_input_submitted(self, event: Input.Submitted) -> None: - user_text = event.value - if not user_text: + prompt = event.value.strip() + if not prompt: return - - chat = self.query_one("#chat_container") - chat.mount(Static(f"You: {user_text}", classes="message user_msg")) event.input.value = "" - # Spawn task through the orchestrator for parallel tracking - task_id = await self.task_manager.spawn_task( - TaskRequest(prompt=user_text) + tm = self.state.task_manager + if not tm: + self.notify("No task manager", severity="error") + return + + request = TaskRequest(prompt=prompt) + task_id = await tm.spawn_task(request) + + tl = self.query_one("#task_list", VerticalScroll) + tl.mount(TaskRow(task_id, prompt, "PENDING")) + self.notify(f"Task {task_id} spawned") + self._wait_for_task(task_id, prompt) + + @work(exclusive=False, name="task_wait") + async def _wait_for_task(self, task_id: str, prompt: str) -> None: + tm = self.state.task_manager + if not tm: + return + status = await tm.wait_for_task(task_id) + try: + self.query_one(f"#task-{task_id}", TaskRow).remove() + except Exception: + pass + self.query_one("#task_list", VerticalScroll).mount( + TaskRow(task_id, prompt, status.status) ) + self._update_header() + + def _update_header(self) -> None: + tm = self.state.task_manager + if not tm: + return + s = tm.get_status() + try: + self.query_one("#tasks_header", Label).update( + f"⚙️ Task Orchestrator — {s['active_workers']}/{s['max_workers']} workers" + ) + except Exception: + pass + + +# ─── Skills pane ────────────────────────────────────────────────────────────── + +class SkillsPane(Container): + """Browse crystallized skills from the skills/ directory.""" + + CSS = """ + #skills_container { + height: 1fr; + border: round $primary; + border-title: " Skills "; + padding: 1; + background: $background; + } + #skills_header { + color: $primary; + text-style: bold; + } + #skills_list { + height: 1fr; + scrollbar-size: 1 1; + } + #skills_search { + height: auto; + padding: 0 0 1 0; + } + #skills_search_input { + border: round $primary; + } + .skill_entry { + padding: 0 1; + margin: 0 0; + } + """ + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state + + def compose(self) -> ComposeResult: + with Vertical(id="skills_container"): + yield Label("🎓 Crystallized Skills", id="skills_header") + with Horizontal(id="skills_search"): + yield Input(placeholder="Search skills…", id="skills_search_input") + yield VerticalScroll(id="skills_list") + + def on_mount(self) -> None: + self._load_skills() + + def _load_skills(self, query: str = "") -> None: + skills_dir = Path(WORKSPACE) / "skills" + sl = self.query_one("#skills_list", VerticalScroll) + for child in list(sl.children): + child.remove() + + if not skills_dir.exists(): + sl.mount(Static("[dim]No skills yet. Skills crystallize automatically after successful tasks.[/]", classes="skill_entry")) + return + + md_files = sorted(skills_dir.glob("*.md")) + if query: + md_files = [f for f in md_files if query in f.stem.lower() or query in f.read_text(errors="replace").lower()] + + if not md_files: + sl.mount(Static(f"[dim]No skills matching '{query}'.[/]", classes="skill_entry")) + return + + for f in md_files: + name = f.stem.replace("_", " ").title() + sz = f.stat().st_size + mtime = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + sl.mount(Static(f"[bold]{name}[/] [dim]{sz}B · {mtime}[/]", classes="skill_entry")) + + async def on_input_submitted(self, event: Input.Submitted) -> None: + self._load_skills(query=event.value.strip().lower()) + + +# ─── Memory pane ────────────────────────────────────────────────────────────── + +class MemoryPane(Container): + """Search the hybrid memory store (semantic + keyword).""" + + CSS = """ + #memory_container { + height: 1fr; + border: round $primary; + border-title: " Memory "; + padding: 1; + background: $background; + } + #memory_results { + height: 1fr; + scrollbar-size: 1 1; + } + #memory_search_row { + height: auto; + padding: 0 0 1 0; + } + #memory_search_input { + border: round $primary; + } + #memory_header { + color: $primary; + text-style: bold; + } + .memory_entry { + padding: 0 1; + margin: 0 0; + } + """ - task_panel = self.query_one("#task_panel") - task_panel.mount(Static(f"Task {task_id}: PENDING", classes="task_status", id=f"task-{task_id}")) + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state - # Poll for task completion - asyncio.create_task(self._poll_task(task_id, chat, task_panel)) + def compose(self) -> ComposeResult: + with Vertical(id="memory_container"): + yield Label("🧠 Memory Search", id="memory_header") + with Horizontal(id="memory_search_row"): + yield Input(placeholder="Search memories… (Enter to search)", id="memory_search_input") + yield VerticalScroll(id="memory_results") + + async def on_input_submitted(self, event: Input.Submitted) -> None: + query = event.value.strip() + if not query: + return + + rp = self.query_one("#memory_results", VerticalScroll) + for child in list(rp.children): + child.remove() + rp.mount(Static("[dim]Searching…[/]", classes="memory_entry")) + + if not self.state.agent: + for child in list(rp.children): + child.remove() + rp.mount(Static("[red]No agent available[/]", classes="memory_entry")) + return + + try: + chunks = await self.state.agent.retriever.retrieve(query, top_k=10) + for child in list(rp.children): + child.remove() + if not chunks: + rp.mount(Static("[dim]No memories found.[/]", classes="memory_entry")) + return + for i, chunk in enumerate(chunks): + content = chunk.get("content", "")[:300] + score = chunk.get("score", 0) + mtype = chunk.get("type", "?") + rp.mount(Static(f"[bold]#{i+1}[/] [dim]{mtype} · score={score:.3f}[/]\n{content}", classes="memory_entry")) + except Exception as e: + for child in list(rp.children): + child.remove() + rp.mount(Static(f"[red]Error: {e}[/]", classes="memory_entry")) + + +# ─── Settings pane ──────────────────────────────────────────────────────────── + +class SettingsPane(Container): + """Provider/model selector (dropdown), theme selector, Caveman toggle, etc.""" + + CSS = """ + #settings_container { + height: 1fr; + border: round $primary; + border-title: " Settings "; + padding: 1 2; + background: $background; + scrollbar-size: 1 1; + } + .settings_label { + text-style: bold; + color: $primary; + margin-top: 1; + margin-bottom: 0; + } + .settings_section { + margin-top: 0; + margin-bottom: 1; + padding: 0 0; + } + .settings_row { + margin-top: 0; + height: auto; + color: $text-muted; + } + #provider_select { + margin-top: 0; + margin-bottom: 0; + } + #theme_select { + margin-top: 0; + margin-bottom: 0; + } + .settings_btn { + margin-top: 0; + margin-right: 1; + } + #settings_provider { + color: $foreground; + } + #settings_caveman { + color: $foreground; + } + #settings_workers { + color: $text-muted; + } + """ + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state + + def compose(self) -> ComposeResult: + with VerticalScroll(id="settings_container"): + yield Label("🔧 Settings", classes="settings_label") - async def _poll_task(self, task_id: str, chat: ScrollableContainer, task_panel: ScrollableContainer) -> None: - """Poll the task manager until the task completes.""" - while True: - status = self.task_manager.tasks.get(task_id) - if not status: - break - if status.status in ("COMPLETED", "FAILED"): - # Update the task panel + # ── Provider / Model selector ────────────────────────────── + yield Label("Provider / Model", classes="settings_label") + yield Label(" Switch the active model at runtime:", classes="settings_row") + options = AppState.build_provider_options() + # Select needs (display, value) tuples and a default value + valid_values = {v for _, v in options} + default_val = self.state.current_provider_id if self.state.current_provider_id in valid_values else (options[0][1] if options else Select.BLANK) + yield Select(options, value=default_val, id="provider_select") + # Show a human-friendly label for the active provider + active_label = default_val + for label, val in options: + if val == default_val: + active_label = label + break + yield Label(f" Active: {active_label}", id="settings_provider") + + # ── Theme ──────────────────────────────────────────────────── + yield Label("Theme", classes="settings_label") + theme_options = [(ThemeRegistry.get_theme(tid).name, tid) for tid in ThemeRegistry.theme_ids()] + yield Select(theme_options, value=self.state.current_theme, id="theme_select") + yield Button("Toggle Theme (Ctrl+T)", id="btn_toggle_theme", classes="settings_btn") + + # ── Caveman ────────────────────────────────────────────────── + yield Label("Caveman Compression", classes="settings_label") + cstatus = "ON" if self.state.caveman_enabled else "OFF" + yield Label(f" Status: {cstatus}", id="settings_caveman") + yield Button("Toggle Caveman", id="btn_toggle_caveman", classes="settings_btn") + + # ── Workspace ──────────────────────────────────────────────── + yield Label("Workspace", classes="settings_label") + yield Label(f" {WORKSPACE}", classes="settings_row") + + # ── Dashboard ──────────────────────────────────────────────── + yield Label("Dashboard", classes="settings_label") + yield Button("Open Dashboard ↗", id="btn_dashboard", classes="settings_btn") + + # ── Workers ────────────────────────────────────────────────── + yield Label("Workers", classes="settings_label") + w = f" {os.cpu_count() * 2} max (CPU-aware)" if self.state.task_manager else " Not initialized" + yield Label(w, id="settings_workers") + + async def on_select_changed(self, event: Select.Changed) -> None: + """Handle dropdown changes for provider and theme selectors.""" + if event.select.id == "provider_select": + new_provider_id = event.value + if new_provider_id == Select.BLANK: + return + # Verify API key is available before switching + if not self.state.config_manager.has_api_key(new_provider_id): + self.notify("🔒 No API key configured for this provider", severity="warning") + # Revert the Select to the current provider + valid_values = {v for _, v in AppState.build_provider_options()} + current = self.state.current_provider_id if self.state.current_provider_id in valid_values else (list(valid_values)[0] if valid_values else "") + self.query_one("#provider_select", Select).value = current + return + try: + self.state.reconnect(new_provider_id) + # Find the display label for the new provider + new_label = new_provider_id + for label, val in AppState.build_provider_options(): + if val == new_provider_id: + new_label = label + break + self.query_one("#settings_provider", Label).update(f" Active: {new_label}") + self.notify(f"Switched to {new_label}") + # Update chat welcome message try: - task_widget = self.query_one(f"#task-{task_id}") - task_widget.update(f"Task {task_id}: {status.status}") + chat_pane = self.app.screen.query_one(ChatPane) + log = chat_pane.query_one("#chat_log", VerticalScroll) + log.mount(ChatMessage(f"⚡ Switched to {new_label}", sender="system")) + log.scroll_end(animate=False) except Exception: pass + except Exception as e: + self.notify(f"Failed to switch: {e}", severity="error") - if status.status == "COMPLETED" and status.result: - chat.mount(Static(f"Agent: {status.result}", classes="message agent_msg")) - elif status.status == "FAILED" and status.error: - chat.mount(Static(f"Error: {status.error}", classes="message agent_msg")) - break - await asyncio.sleep(0.3) + elif event.select.id == "theme_select": + new_theme = event.value + if new_theme == Select.BLANK: + return + self.state.current_theme = new_theme + self.app.theme = new_theme # Textual native — cascades everywhere + self.notify(f"Theme → {ThemeRegistry.get_theme(new_theme).name}") - chat.scroll_end() + async def on_button_pressed(self, event: Button.Pressed) -> None: + bid = event.button.id - def action_toggle_theme(self) -> None: - themes = list(ThemeRegistry.THEMES.keys()) - idx = themes.index(self.current_theme_name) - self.current_theme_name = themes[(idx + 1) % len(themes)] - self.apply_theme(self.current_theme_name) - self.notify(f"Theme changed to {self.current_theme_name}") + if bid == "btn_toggle_theme": + themes = ThemeRegistry.theme_ids() + idx = themes.index(self.state.current_theme) + self.state.current_theme = themes[(idx + 1) % len(themes)] + self.app.theme = self.state.current_theme + # Sync the Select dropdown too + self.query_one("#theme_select", Select).value = self.state.current_theme + self.notify(f"Theme → {ThemeRegistry.get_theme(self.state.current_theme).name}") + + elif bid == "btn_toggle_caveman": + self.state.caveman_enabled = not self.state.caveman_enabled + if self.state.agent: + self.state.agent.caveman.enabled = self.state.caveman_enabled + s = "ON" if self.state.caveman_enabled else "OFF" + self.query_one("#settings_caveman", Label).update(f" Status: {s}") + self.notify(f"Caveman: {s}") + + elif bid == "btn_dashboard": + webbrowser.open(f"{DASHBOARD_URL}?key={DASHBOARD_ADMIN_KEY}") + self.notify("Opening dashboard in browser…") + + +# ─── The App ────────────────────────────────────────────────────────────────── + +class MotionTUI(App): + """The top-level Motion Harness TUI application. + + Themes are registered as Textual-native themes so that setting + ``self.theme = "dracula"`` cascades through every CSS ``$variable`` + in every widget — borders, backgrounds, accents, everything. + """ + + CSS = """ + Screen { background: $background; color: $foreground; } + Header { background: $surface; } + Footer { background: $surface; } + TabbedContent { height: 1fr; } + .msg { margin: 0 0; padding: 0 1; } + .user_msg { color: $primary; text-style: bold; } + .agent_msg { color: $foreground; } + .system_msg { color: $text-muted; text-style: italic; } + """ + + BINDINGS = [ + Binding("ctrl+q", "quit", "Quit"), + Binding("ctrl+c", "request_cancel", "Cancel"), + ] + + def __init__(self, model_config: Optional[ModelConfig] = None, provider_id: str = "", workspace: str = WORKSPACE, **kwargs) -> None: + super().__init__(**kwargs) + self.state = AppState() + self._model_config = model_config + self._provider_id = provider_id + self._workspace = workspace + + def on_mount(self) -> None: + # Register all themes with Textual's native system + for tid in ThemeRegistry.theme_ids(): + ttheme = ThemeRegistry.get_textual_theme(tid) + self.register_theme(ttheme) + + # Set initial theme + self.theme = self.state.current_theme + + if self._model_config: + self.state.agent = MotionAgent(self._model_config) + self.state.task_manager = TaskManager(self._model_config, self._workspace) + # Use the explicit provider_id (e.g. "ollama-cloud/gemma3:12b") + # rather than model_config.name (a display name like "Ollama Cloud (gemma3:12b)") + # which won't match Select option values. + if self._provider_id: + self.state.current_provider_id = self._provider_id + else: + # Fallback: try to match against known options + options = AppState.build_provider_options() + self.state.current_provider_id = options[0][1] if options else "" + self.push_screen(MainScreen(self.state)) + else: + self.push_screen(ProviderSelectScreen(self.state)) + + def action_request_cancel(self) -> None: + """Cancel the running agent chat — not a quit.""" + try: + worker = self.workers.get("agent_chat") + if worker: + worker.cancel() + self.notify("Request cancelled") + except Exception: + pass + + async def on_unmount(self) -> None: + """Graceful shutdown: close provider and DB connections.""" + if self.state.agent: + try: + await asyncio.wait_for(self.state.agent.provider.close(), timeout=2.0) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + self.state.agent.memory.close() + + +def launch_tui(model_config: Optional[ModelConfig] = None, provider_id: str = "") -> None: + """Entry point called from main.py.""" + app = MotionTUI(model_config=model_config, provider_id=provider_id) + app.run() - def action_show_status(self) -> None: - status = self.task_manager.get_status() - self.notify(f"Workers: {status['active_workers']}/{status['max_workers']} | Tasks: {len(status['tasks'])}") if __name__ == "__main__": config = ModelConfig(name="Motion-TUI", endpoint="http://localhost", provider_type="local") - app = MotionTUI(config) - app.run() + launch_tui(model_config=config) \ No newline at end of file