diff --git a/.github/workflows/beta_release.yml b/.github/workflows/beta_release.yml index 33af894..6d42e96 100644 --- a/.github/workflows/beta_release.yml +++ b/.github/workflows/beta_release.yml @@ -40,10 +40,10 @@ jobs: body: | ## πŸ§ͺ Beta Release v${{ env.VERSION }} Automated release from the beta branch. - + ### Changes ${{ github.event.head_commit.message }} draft: false prerelease: true env: - GITHUB_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} diff --git a/README.md b/README.md index df00ef6..011547d 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Quick Start Β· Setup Guide Β· Architecture Β· - Skills Engine + Skills Engine Β· + Roadmap

--- @@ -36,18 +37,36 @@ It treats every successful task trajectory as a learning event, crystallizing ex Get the harness running in under 60 seconds. +### For complete beginners + +**1. Install Python 3.10+** if you don't have it. On macOS: `brew install python@3.14`. On Linux: `sudo apt install python3 python3-venv`. + +**2. Clone and install the harness:** ```bash -# 1. Run the installer +git clone motion-harness +cd motion-harness chmod +x install.sh && ./install.sh +``` -# 2. Refresh your shell -source ~/.config/fish/config.fish # or .zshrc / .bashrc +**3. Refresh your shell** so the `motion` command is available: +```bash +source ~/.config/fish/config.fish # if you use fish +source ~/.zshrc # if you use zsh +source ~/.bashrc # if you use bash +``` -# 3. Configure your provider +**4. Configure your provider (API key):** +```bash cp config.example.yml config.yml -# Edit config.yml β€” set your API keys in .env or directly in config.yml +``` +Then open `config.yml` and set your API key. The easiest way is to create a `.env` file: +```bash +echo "OLLAMA_API_KEY=your-key-here" > .env +``` +> **No need to add models one by one.** The harness ships with a built-in catalog of Anthropic, OpenAI, and Ollama Cloud models. Just add the API key and they're all available. -# 4. Launch +**5. Launch:** +```bash motion ``` @@ -80,15 +99,93 @@ A bidirectional compression layer that strips conversational fluff. When a complex task is solved, the harness doesn't just forget. It analyzes the trajectory and "crystallizes" the steps into a `.md` skill, allowing the agent to execute the same complex workflow in the future with a single reference. ### 🎨 Pro-Grade TUI -A high-performance terminal interface built with `Textual`. Featuring: -- **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** +A high-performance terminal interface built with `Textual`, designed for daily-driver clarity rather than an engineering dashboard. + +**Focused single-chat view (opencode-style)**: no top tab bar. The screen is a top bar, the conversation canvas, a compact bottom composer, and a right-hand **Context panel**. Skills, settings, and model switching are all reachable via the command palette. + +**Workspace regions**: +- **Conversation canvas** β€” markdown-first message cards with author/time headers and compact metadata; response code blocks carry theme-aware syntax highlighting. +- **Right Context panel** β€” the rolling session context and most recent turns, so you always see what the model is grounded against (`Ctrl+B` to toggle). +- **Composer** β€” a compact, opencode-style prompt: auto-growing (soft-wraps instead of scrolling), a thin left accent border colored by agent mode, prompt history (↑/↓), and an inline `agent Β· model Β· provider` meta row. Enter sends, Shift+Enter adds a newline. + +**Agent mode colors**: `build` is **blue**, `plan` is **orange** (matches opencode). + +**Grounded responses**: on every turn the harness passes the prior conversation (user + assistant) as history and the rolling session context as a memory-recall query, so the model references what was actually said instead of hallucinating. + +**Theme token model**: semantic tokens (`$background`, `$surface`, `$panel`, `$border`, `$primary`, `$secondary`, `$accent`, `$text`, `$text-muted`, `$success`, `$warning`, `$error`) cascade through every widget via Textual's theme system. + +**6 Native Themes**: OpenCode (default), One Dark, Solarized Light, Nord, Dracula, Omni Dark β€” cycle with `Ctrl+T`. + +**Keyboard shortcuts**: +| Key | Action | +| :-- | :-- | +| `Ctrl+T` | Cycle theme | +| `Ctrl+B` | Toggle context panel | +| `Ctrl+O` | Switch model (provider β†’ model) | +| `Ctrl+K` | Command palette (all commands) | +| `Ctrl+E` | Open external editor for the message | +| `F8` / `Ctrl+Shift+T` | Toggle interaction trace panel | +| `F9` / `Ctrl+Shift+C` | Copy last assistant response | +| `Tab` | Toggle agent (build / plan) | +| `?` | Show shortcuts overlay (generated from live bindings) | +| `Enter` (chat input) | Send message | +| `Shift+Enter` (chat input) | New line | +| `↑` / `↓` (chat input) | Prompt history | +| `/skill save ` | Save last reply as a skill | +| `Ctrl+C` / `Ctrl+X` | Cancel current request (does not quit) | +| `Ctrl+Q` | Quit (kills the process) | + +**Dashboard integration**: one-click open to the admin dashboard at `https://localhost:7860/`. + +### ⚠️ Known Limitations (v2 TUI) +- Trace persistence is per-session (not yet written to disk). +- Theme contrast validation is manual; the bundled themes are tuned for readability but very-low-contrast combinations are not auto-corrected. +- Shortcut help overlay (`?`) reflects MainScreen + ChatPane bindings; the Tasks/Skills/KB/Memory/Settings panes are defined but not mounted in the current focused-chat layout. +- Clipboard copy falls back to inserting the response into the input box when the terminal lacks clipboard support. +- File/document ingestion (image / PDF / DOCX / XLSX) is on the roadmap β€” see [Roadmap](docs/roadmap.md). + +--- + +## πŸ—ΊοΈ Roadmap + +```mermaid +%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#fab283', 'primaryBorderColor': '#484848', 'primaryTextColor': '#eeeeee', 'lineColor': '#5c9cf5' }}}%% +flowchart LR + subgraph P1[Phase 1 β€” Core Chat UX] + direction TB + A[opencode-style prompt panel] --> B[Agent mode colors: build=blue, plan=orange] + B --> C[Ctrl+K palette Β· Ctrl+O model] + C --> D[Right-hand Context panel] + D --> E[Drop top tabs] + E --> F[Grounded responses via history + context_query] + end + + subgraph P2[Phase 2 β€” File Ingestion] + direction TB + G[Images β†’ base64 vision parts] --> H[Capability gating] + H --> I[PDF as own modality] + I --> J[DOC/DOCX β†’ text extraction β†’ memory] + J --> K[XLSX β†’ sheets β†’ memory] + K --> L[Unified /attach pipeline + graceful errors] + end + + subgraph P3[Phase 3 β€” Providers & Models] + direction TB + M[Multimodal payloads in providers] --> N[Per-model capability manifest] + N --> O[OCR fallback for scanned pages] + end + + subgraph P4[Phase 4 β€” Memory & Orchestration] + direction TB + P[Document-level memory namespaces] --> Q[Auto-compact context] + Q --> R[Persistent multi-session context] + R --> S[Attachment-aware parallel orchestration] + end + + P1 --> P2 --> P3 --> P4 +``` + +Detailed tracking lives in [docs/roadmap.md](docs/roadmap.md). --- @@ -118,4 +215,38 @@ Ensure all changes are validated against the integration suite: pytest tests/test_integration.py ``` +**Visual + TUI smoke checks** (headless, no terminal required): +```bash +# Parse/import sanity +python -c "from ui import tui; print('Import OK')" + +# Headless TUI smoke: compose MainScreen, Ctrl+K palette, F8 trace toggle, ? overlay +python - <<'PY' +import asyncio, sys +from textual.app import App +from ui.tui import MainScreen, AppState, CommandPalette +from ui.themes import ThemeRegistry + +class SmokeApp(App): pass +async def run(): + app = SmokeApp() + for tid in ThemeRegistry.theme_ids(): + app.register_theme(ThemeRegistry.get_textual_theme(tid)) + async with app.run_test() as pilot: + app.push_screen(MainScreen(AppState())) + await pilot.pause() + await pilot.press("ctrl+k"); await pilot.pause() # open command palette + assert isinstance(app.screen, CommandPalette) + await pilot.press("escape"); await pilot.pause() # close palette + await pilot.press("f8"); await pilot.pause() # toggle trace + await pilot.press("f8"); await pilot.pause() + await pilot.press("question_sign"); await pilot.pause() # shortcuts overlay + await pilot.press("escape"); await pilot.pause() + sys.stderr.write("SMOKE OK\n") +asyncio.run(run()) +PY +``` + +Checks cover: MainScreen compose, the `Ctrl+K` command palette, trace disclosure toggle (`F8`), and the shortcuts overlay (`?`/`Escape`). + For more details on our versioning and changelog, see [RELEASES.md](RELEASES.md). diff --git a/core/catalog.py b/core/catalog.py new file mode 100644 index 0000000..0da4417 --- /dev/null +++ b/core/catalog.py @@ -0,0 +1,202 @@ +"""Built-in provider/model catalog. + +Mirrors opencode's approach: ship a pre-configured catalog of providers and +models so users only need to add API keys (or point at a local endpoint), +instead of configuring every model by hand. + +The catalog is merged with the user's ``config.yml``: user-defined providers +override catalog entries with the same id, and catalog providers are always +available as a fallback. +""" + +from typing import Any, Dict, List, Optional + +import httpx +import re + +from core.providers import LocalProvider, ModelConfig + +# ── Built-in catalog ───────────────────────────────────────────────────────── +# Each provider: id -> {name, endpoint, provider_type, models: {model: opts}} +# ``api_key`` is intentionally omitted β€” it comes from config.yml or env vars. + +BUILTIN_CATALOG: Dict[str, Dict[str, Any]] = { + "ollama-cloud": { + "name": "Ollama Cloud", + "endpoint": "https://ollama.com/v1", + "provider_type": "cloud", + "default_model": "deepseek-v4-flash", + "models": { + "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": { + "name": "Claude (Anthropic)", + "endpoint": "https://api.anthropic.com", + "provider_type": "cloud", + "default_model": "claude-opus-5", + "models": { + "claude-opus-5": {"temperature": 0.7, "max_tokens": 128000}, + "claude-sonnet-5": {"temperature": 0.7, "max_tokens": 128000}, + "claude-haiku-4-5": {"temperature": 0.7, "max_tokens": 64000}, + "claude-fable-5": {"temperature": 0.7, "max_tokens": 128000}, + "claude-opus-4-8": {"temperature": 0.7, "max_tokens": 128000}, + "claude-opus-4-7": {"temperature": 0.7, "max_tokens": 128000}, + "claude-opus-4-6": {"temperature": 0.7, "max_tokens": 128000}, + "claude-sonnet-4-6": {"temperature": 0.7, "max_tokens": 128000}, + "claude-sonnet-4-5": {"temperature": 0.7, "max_tokens": 64000}, + "claude-opus-4-5": {"temperature": 0.7, "max_tokens": 64000}, + }, + }, + "openai": { + "name": "OpenAI", + "endpoint": "https://api.openai.com/v1", + "provider_type": "cloud", + "default_model": "gpt-5.6-sol", + "models": { + "gpt-5.6-sol": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.6-terra": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.6-luna": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.5": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.4": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.4-mini": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.4-nano": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.3": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.2": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5.1": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5-mini": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-5-nano": {"temperature": 0.8, "max_tokens": 128000}, + "gpt-4.1": {"temperature": 0.8, "max_tokens": 32000}, + "gpt-4.1-mini": {"temperature": 0.8, "max_tokens": 32000}, + "gpt-4.1-nano": {"temperature": 0.8, "max_tokens": 32000}, + "gpt-4o": {"temperature": 0.8, "max_tokens": 16000}, + "gpt-4o-mini": {"temperature": 0.8, "max_tokens": 16000}, + "o3": {"temperature": 0.8, "max_tokens": 100000}, + "o3-mini": {"temperature": 0.8, "max_tokens": 100000}, + "o4-mini": {"temperature": 0.8, "max_tokens": 100000}, + "o1": {"temperature": 0.8, "max_tokens": 100000}, + "o1-pro": {"temperature": 0.8, "max_tokens": 100000}, + }, + }, + "local-llama": { + "name": "Llama 3 (Local)", + "endpoint": "http://localhost:11434", + "provider_type": "local", + "options": { + "model": "llama3", + "temperature": 0.6, + "embed_model": "nomic-embed-text", + }, + }, +} + + +def merge_catalog(user_providers: Dict[str, Any]) -> Dict[str, Any]: + """Merge the built-in catalog with user-defined providers. + + User providers win on id collision; catalog providers fill in anything the + user hasn't defined. The ``default`` key (if present) is preserved. + """ + merged: Dict[str, Any] = {} + # Start with the catalog. + for pid, cfg in BUILTIN_CATALOG.items(): + merged[pid] = dict(cfg) + # Overlay user providers. + for pid, cfg in (user_providers or {}).items(): + if pid == "default": + continue + if pid in merged and isinstance(cfg, dict): + # Deep-merge: user options/models override catalog defaults. + base = merged[pid] + for key, value in cfg.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + base[key] = {**base[key], **value} + else: + base[key] = value + else: + merged[pid] = cfg + return merged + + +async def discover_local_models(provider_id: str, cfg: Dict[str, Any]) -> List[str]: + """Query a local provider for installed models (Ollama /api/tags). + + Returns the list of model names, or an empty list if the provider is not + local or the endpoint is unreachable. + """ + if cfg.get("provider_type") != "local": + return [] + endpoint = cfg.get("endpoint", "") + if not endpoint: + return [] + model_config = ModelConfig( + name=provider_id, + endpoint=endpoint, + provider_type="local", + options=cfg.get("options", {}), + ) + provider = LocalProvider(model_config) + try: + return await provider.list_models() + finally: + await provider.close() + + +# ── Ollama Cloud model scraper ──────────────────────────────────────────────── + +OLLAMA_SEARCH_URL = "https://ollama.com/search" + + +async def scrape_ollama_cloud_models() -> List[str]: + """Fetch the list of models available on Ollama Cloud. + + Scrapes the ollama.com search page and extracts model names. Returns a + list of model ids (e.g. ``deepseek-v4-flash``, ``glm-5.2``). Returns an + empty list if the fetch or parse fails. + """ + try: + async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client: + resp = await client.get(OLLAMA_SEARCH_URL) + resp.raise_for_status() + html = resp.text + except Exception: + return [] + + # Model names appear as links to /library/ or /search/. + # Match the model name tokens in the page. + names: List[str] = [] + seen = set() + # Pattern for model links: href="/library/" or "/search/" + for m in re.finditer(r'href="/(?:library|search)/([a-z0-9][a-z0-9._-]*)"', html): + name = m.group(1) + if name not in seen: + seen.add(name) + names.append(name) + return names + + +async def update_ollama_cloud_models() -> int: + """Scrape Ollama Cloud models and merge them into the built-in catalog. + + Returns the number of models added/updated. The catalog is updated in + memory (BUILTIN_CATALOG) so the model dialog reflects the latest list. + """ + names = await scrape_ollama_cloud_models() + if not names: + return 0 + provider = BUILTIN_CATALOG.get("ollama-cloud", {}) + models = provider.setdefault("models", {}) + added = 0 + for name in names: + if name not in models: + models[name] = {"temperature": 0.7, "max_tokens": 4096} + added += 1 + return added diff --git a/core/config.py b/core/config.py index af9b3cc..9607491 100644 --- a/core/config.py +++ b/core/config.py @@ -3,6 +3,8 @@ from typing import Any, Dict, Optional from dataclasses import dataclass +from core.catalog import merge_catalog + @dataclass class AppConfig: workspace_path: str @@ -26,7 +28,13 @@ def _load_config(self) -> Dict[str, Any]: if not os.path.exists(self.config_path): return {} with open(self.config_path, 'r') as f: - return yaml.safe_load(f) or {} + data = yaml.safe_load(f) or {} + # Merge the built-in catalog so pre-configured providers/models are + # always available, with user config taking precedence. + providers = data.get("providers", {}) + merged_providers = merge_catalog(providers) + data["providers"] = merged_providers + return data def _env(self, key: str, default: Any = None) -> Any: """Resolve a value from environment variables first, then config.""" diff --git a/core/orchestrator.py b/core/orchestrator.py index 50ebce9..7b25744 100644 --- a/core/orchestrator.py +++ b/core/orchestrator.py @@ -227,6 +227,17 @@ async def on_stream_chunk(chunk: str) -> None: finally: task.end_time = datetime.now() self.active_count -= 1 + # Clean up the per-task memory DB file (created above). + try: + agent.memory.close() + except Exception: + pass + try: + db_path = f"memory_{request.task_id}.db" + if os.path.exists(db_path): + os.remove(db_path) + except Exception: + pass # Save artifact try: task.artifact_path = self._save_artifact(task) diff --git a/core/providers.py b/core/providers.py index bbf6864..209b01c 100644 --- a/core/providers.py +++ b/core/providers.py @@ -26,13 +26,17 @@ def __init__(self, config: ModelConfig): self._client = httpx.AsyncClient(timeout=120.0) @abstractmethod - async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: - """Generate a completion from the model.""" + async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str: + """Generate a completion from the model. + + ``history`` is an optional list of prior turns as + ``[{"role": "user"|"assistant", "content": "..."}, ...]``. + """ pass - async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]: + async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]: """Optional streaming completion. Defaults to one-shot completion.""" - result = await self.complete(prompt, system_prompt=system_prompt, **kwargs) + result = await self.complete(prompt, system_prompt=system_prompt, history=history, **kwargs) if result: yield result @@ -43,7 +47,7 @@ async def close(self): class LocalProvider(BaseProvider): """Provider for local LLMs via Ollama-compatible /api/chat endpoint.""" - async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: + async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str: url = f"{self.config.endpoint.rstrip('/')}/api/chat" model = self.config.options.get("model", self.config.name.lower()) payload = { @@ -54,6 +58,8 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: } if system_prompt: payload["messages"].append({"role": "system", "content": system_prompt}) + if history: + payload["messages"].extend(history) payload["messages"].append({"role": "user", "content": prompt}) if "temperature" in self.config.options: @@ -66,7 +72,7 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: data = resp.json() return data.get("message", {}).get("content", "") - async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]: + async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]: url = f"{self.config.endpoint.rstrip('/')}/api/chat" model = self.config.options.get("model", self.config.name.lower()) payload = { @@ -77,6 +83,8 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) } if system_prompt: payload["messages"].append({"role": "system", "content": system_prompt}) + if history: + payload["messages"].extend(history) payload["messages"].append({"role": "user", "content": prompt}) if "temperature" in self.config.options: @@ -105,27 +113,47 @@ async def embed(self, text: str) -> List[float]: resp.raise_for_status() return resp.json().get("embedding", []) + async def list_models(self) -> List[str]: + """Discover installed models via Ollama /api/tags. + + Returns a list of model names (e.g. ``llama3:latest``). Falls back to + the configured model if the endpoint is unreachable or not Ollama. + """ + url = f"{self.config.endpoint.rstrip('/')}/api/tags" + try: + resp = await self._client.get(url) + resp.raise_for_status() + data = resp.json() + models = [m.get("name", "") for m in data.get("models", [])] + models = [m for m in models if m] + if models: + return models + except Exception: + pass + configured = self.config.options.get("model") + return [configured] if configured else [] + class CloudProvider(BaseProvider): """Provider for cloud LLMs (Anthropic, OpenAI) using their native chat APIs.""" - async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: + async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str: endpoint = self.config.endpoint 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", "") if "anthropic" in endpoint: - return await self._anthropic_complete(prompt, system_prompt, api_key) + return await self._anthropic_complete(prompt, system_prompt, api_key, history) elif "openai" in endpoint: - return await self._openai_complete(prompt, system_prompt, api_key) + return await self._openai_complete(prompt, system_prompt, api_key, history) else: - return await self._openai_complete(prompt, system_prompt, api_key) + return await self._openai_complete(prompt, system_prompt, api_key, history) - async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]: + async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]: endpoint = self.config.endpoint 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", "") if "anthropic" in endpoint: - async for chunk in super().stream_complete(prompt, system_prompt=system_prompt, **kwargs): + async for chunk in super().stream_complete(prompt, system_prompt=system_prompt, history=history, **kwargs): yield chunk return @@ -140,6 +168,8 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) messages = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) + if history: + messages.extend(history) messages.append({"role": "user", "content": prompt}) payload = { "model": model, @@ -166,7 +196,7 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) if chunk: yield chunk - async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: str) -> str: + async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: str, history: Optional[List[Dict[str, str]]] = None) -> str: url = "https://api.anthropic.com/v1/messages" model = self.config.options.get("model", "claude-3-5-sonnet-20241022") max_tokens = self.config.options.get("max_tokens", 4096) @@ -176,11 +206,13 @@ async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: st "anthropic-version": "2023-06-01", "content-type": "application/json", } + messages = list(history) if history else [] + messages.append({"role": "user", "content": prompt}) payload = { "model": model, "max_tokens": max_tokens, "temperature": temperature, - "messages": [{"role": "user", "content": prompt}], + "messages": messages, } if system_prompt: payload["system"] = system_prompt @@ -190,7 +222,7 @@ async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: st data = resp.json() return data.get("content", [{}])[0].get("text", "") - async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str) -> str: + async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str, history: Optional[List[Dict[str, str]]] = None) -> str: url = f"{self.config.endpoint.rstrip('/')}/chat/completions" model = self.config.options.get("model", "gpt-4o") max_tokens = self.config.options.get("max_tokens", 4096) @@ -202,6 +234,8 @@ async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str) messages = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) + if history: + messages.extend(history) messages.append({"role": "user", "content": prompt}) payload = { "model": model, @@ -218,7 +252,7 @@ async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str) class ProxyProvider(BaseProvider): """Provider for custom proxy/gateway endpoints (OpenAI-compatible).""" - async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: + async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str: url = f"{self.config.endpoint.rstrip('/')}/chat/completions" api_key = self.config.api_key or os.environ.get("PROXY_API_KEY", "") headers = { @@ -228,6 +262,8 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: messages = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) + if history: + messages.extend(history) messages.append({"role": "user", "content": prompt}) payload = { "model": self.config.options.get("model", "default"), @@ -239,7 +275,7 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: data = resp.json() return data.get("choices", [{}])[0].get("message", {}).get("content", "") - async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]: + async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]: url = f"{self.config.endpoint.rstrip('/')}/chat/completions" api_key = self.config.api_key or os.environ.get("PROXY_API_KEY", "") headers = { @@ -249,6 +285,8 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) messages = [] if system_prompt: messages.append({"role": "system", "content": system_prompt}) + if history: + messages.extend(history) messages.append({"role": "user", "content": prompt}) payload = { "model": self.config.options.get("model", "default"), diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..c73d583 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,82 @@ +# Motion Harness Roadmap + +This is the living roadmap for Motion Harness. Items are ordered roughly by +priority within each phase, not strict release order. The guiding principle is +the **Cognitive Loop** (Input β†’ Hybrid Recall β†’ Model Execution β†’ Compression β†’ +Output): every feature must keep the agent grounded in real context and cheap to +operate. + +--- + +## Phase 1 β€” Core Chat UX (done) + +- [x] opencode-style compact prompt panel (input + agent Β· model Β· provider meta) +- [x] Agent mode coloring β€” build (blue), plan (orange) +- [x] Ctrl+K command palette, Ctrl+O model dialog +- [x] Right-hand Context panel (rolling session context) replacing the activity rail +- [x] Drop top tabs; single focused chat view +- [x] Soft-wrapping, auto-growing composer (no horizontal scroll on long input) +- [x] Grounded responses β€” pass prior conversation (history) + session context + (`context_query`) to the model on every turn to reduce hallucination +- [x] Remove "Thinking…" placeholder from the response area +- [x] Full theme-aware response rendering (code syntax colors follow theme) + +## Phase 2 β€” Document & File Ingestion (next) + +Adopts the opencode model of **base64 data-URL content parts** with **capability +gating**, rather than blind OCR/text extraction. The model either reads the file +natively or the harness says it can't and tells the user why. + +**Goal:** drop a file (image / PDF / DOCX / XLSX) and the agent understands it, +without hallucinating or faking content. + +- [ ] **Image support (vision)** + - [ ] Convert attached images to base64 `data:;base64,...` content parts + - [ ] Detect a vision-capable model (`capabilities.input.image`) + - [ ] If unsupported, degrade gracefully: replace with + `ERROR: Cannot read image (this model does not support image input)` + - [ ] Empty / corrupt image guard before sending +- [ ] **PDF support** + - [ ] Treat `application/pdf` as its own modality (`pdf`) + - [ ] Pass natively to models that accept PDFs; capability-gate otherwise +- [ ] **DOC / DOCX support** + - [ ] Text extraction via `python-docx` (paragraphs + tables) when the model + cannot ingest DOCX natively + - [ ] Chunk + embed extracted text into `MemoryDB` for Hybrid Recall +- [ ] **XLSX (Excel) support** + - [ ] Sheet β†’ CSV-like text via `openpyxl` + - [ ] Chunk + embed into `MemoryDB` for Hybrid Recall +- [ ] **Unified attachment pipeline** + - [ ] `/attach ` (or drag-in) in the composer + - [ ] Route by MIME: image β†’ vision part; pdf β†’ pdf part; doc/docx/xlsx β†’ text + extraction β†’ memory + - [ ] Capability gating mirrors opencode (`mimeToModality` + `input[modality]`) + - [ ] Graceful error messaging that informs the user (never silently drops) + +## Phase 3 β€” Provider & Model Enhancements + +- [ ] Multimodal payload support in `core/providers.py` (image/pdf content parts) +- [ ] Per-model capability manifest (`input.image`, `input.pdf`, …) +- [ ] Model switching preserves attachments (re-attach on provider change) +- [ ] Optional vision-model fallback path for non-vision models (OCR for scanned + pages via `pytesseract`) + +## Phase 4 β€” Memory & Orchestration + +- [ ] Document-level memory (per-file retrieval namespaces) +- [ ] Auto-compact conversation context under configurable token thresholds +- [ ] Persistent multi-session context across restarts +- [ ] Parallel orchestration with attachment-aware task scheduling + +--- + +## Notes + +- The **opencode reference** for this approach is `anomalyco/opencode`: + - `packages/opencode/src/provider/transform.ts` β€” `mimeToModality()`, + `unsupportedParts()` (capability gating + empty-image guard) + - `packages/opencode/src/acp/content.ts` β€” `filePartToContentChunks()` + + `decodeDataUrl()` (base64 data-URL decoding) + - `packages/opencode/src/tool/code-mode.ts` β€” `dataUrl()` helper +- Principle: **never pretend to read a file.** If the active model can't ingest a + modality, surface a clear message and inform the user. diff --git a/main.py b/main.py index 6a8ed88..ecd5727 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ import hashlib import logging import os +from typing import Optional logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logger = logging.getLogger(__name__) @@ -41,7 +42,7 @@ async def get_embedding(self, text: str): norm = sum(v * v for v in vec) ** 0.5 or 1.0 return [v / norm for v in vec] - async def run(self, prompt: str, target: str = "user", on_stream_chunk=None, on_trace_event=None): + async def run(self, prompt: str, target: str = "user", on_stream_chunk=None, on_trace_event=None, history: Optional[list] = None, context_query: Optional[str] = None): async def emit_trace(stage: str, message: str, **extra): if not on_trace_event: return @@ -58,6 +59,18 @@ async def emit_trace(stage: str, message: str, **extra): # 1. Memory Recall await emit_trace("memory_recall_start", "Running retriever.retrieve") context_chunks = await self.retriever.retrieve(prompt) + # Augment recall with the session context query so we don't repeat ourselves. + if context_query: + context_chunks += await self.retriever.retrieve(context_query) + # De-duplicate by content, keep order. + seen = set() + deduped = [] + for c in context_chunks: + key = c["content"] + if key not in seen: + seen.add(key) + deduped.append(c) + context_chunks = deduped[:5] await emit_trace("memory_recall_done", "Memory recall complete", chunks=len(context_chunks)) context_text = "\n".join([c["content"] for c in context_chunks]) @@ -75,7 +88,7 @@ async def emit_trace(stage: str, message: str, **extra): ) if on_stream_chunk: raw_chunks = [] - async for chunk in self.provider.stream_complete(prompt, system_prompt=system_prompt): + async for chunk in self.provider.stream_complete(prompt, system_prompt=system_prompt, history=history): stream_chunk_count += 1 raw_chunks.append(chunk) await emit_trace("stream_chunk", "Received stream chunk", chunk_index=stream_chunk_count, chars=len(chunk or "")) @@ -88,7 +101,7 @@ async def emit_trace(stage: str, message: str, **extra): raw_response = "".join(raw_chunks) await emit_trace("model_done", "Streaming completion finished", stream_chunks=stream_chunk_count, chars=len(raw_response)) else: - raw_response = await self.provider.complete(prompt, system_prompt=system_prompt) + raw_response = await self.provider.complete(prompt, system_prompt=system_prompt, history=history) await emit_trace("model_done", "One-shot completion finished", chars=len(raw_response or "")) # 4. Caveman Compression @@ -115,9 +128,6 @@ async def emit_trace(stage: str, message: str, **extra): except Exception as e: logger.debug(f"Skill synthesis skipped: {e}") await emit_trace("skill_synthesis_error", f"Skill synthesis error: {e}") - else: - await emit_trace("skill_synthesis_done", "Skill synthesis disabled (manual mode)") - return final_response def load_agent_from_config(config_path: str = "", provider_id: str | None = None) -> MotionAgent: @@ -250,6 +260,11 @@ async def interactive_chat(provider_id: str | None = None): config = ConfigManager() provider_id = args.provider or config.get_default_provider() provider_cfg = config.get_provider_config(provider_id) + # Normalize to full provider/model id so the TUI dropdown matches. + if "/" not in provider_id: + model = provider_cfg.get("options", {}).get("model") + if model: + provider_id = f"{provider_id}/{model}" model_config = ModelConfig( name=provider_cfg.get("name", provider_id), endpoint=provider_cfg["endpoint"], diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..4731a92 --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,72 @@ +"""Tests for the built-in provider/model catalog.""" +from core.catalog import BUILTIN_CATALOG, merge_catalog + + +def test_catalog_has_expected_providers(): + assert "ollama-cloud" in BUILTIN_CATALOG + assert "claude" in BUILTIN_CATALOG + assert "openai" in BUILTIN_CATALOG + assert "local-llama" in BUILTIN_CATALOG + + +def test_catalog_ollama_has_models(): + models = BUILTIN_CATALOG["ollama-cloud"]["models"] + assert "deepseek-v4-flash" in models + assert "qwen3-coder:480b" in models + + +def test_catalog_claude_has_many_models(): + models = BUILTIN_CATALOG["claude"]["models"] + assert "claude-opus-5" in models + assert "claude-sonnet-5" in models + assert "claude-haiku-4-5" in models + assert len(models) >= 8 + + +def test_catalog_openai_has_many_models(): + models = BUILTIN_CATALOG["openai"]["models"] + assert "gpt-5.6-sol" in models + assert "gpt-4o" in models + assert "o3" in models + assert len(models) >= 15 + + +def test_merge_catalog_fills_missing_providers(): + merged = merge_catalog({}) + # Catalog providers are present even with an empty user config. + assert "openai" in merged + assert "claude" in merged + + +def test_merge_catalog_user_overrides_catalog(): + user = { + "gpt-4o": { + "name": "My GPT", + "options": {"model": "gpt-4o-mini", "temperature": 0.1}, + } + } + merged = merge_catalog(user) + # User name wins. + assert merged["gpt-4o"]["name"] == "My GPT" + # User options override catalog defaults. + assert merged["gpt-4o"]["options"]["model"] == "gpt-4o-mini" + assert merged["gpt-4o"]["options"]["temperature"] == 0.1 + + +def test_merge_catalog_preserves_default_key(): + user = {"default": "gpt-4o"} + merged = merge_catalog(user) + # The default key is not treated as a provider. + assert "default" not in merged + + +def test_merge_catalog_deep_merges_models(): + user = { + "ollama-cloud": { + "models": {"custom-model": {"temperature": 0.3}}, + } + } + merged = merge_catalog(user) + # Catalog models preserved, user model added. + assert "deepseek-v4-flash" in merged["ollama-cloud"]["models"] + assert "custom-model" in merged["ollama-cloud"]["models"] diff --git a/tests/test_composer.py b/tests/test_composer.py new file mode 100644 index 0000000..9cd3546 --- /dev/null +++ b/tests/test_composer.py @@ -0,0 +1,93 @@ +"""Tests for the ChatComposer widget behavior. + +Covers: + - Enter posts a ComposerSubmitted message (submit) + - Ctrl+S also submits + - Up/down navigates prompt history +""" +import pytest +from rich.style import Style + +from textual.app import App, ComposeResult +from textual.message import Message + +from ui.tui import AppState, ChatComposer, ComposerSubmitted + + +class _ComposerHarness(App): + """Minimal app that hosts a ChatComposer and records submitted messages.""" + + def __init__(self): + super().__init__() + self.submitted = [] + self.state = AppState() + + def compose(self) -> ComposeResult: + yield ChatComposer(self.state, id="composer") + + def on_composer_submitted(self, event: ComposerSubmitted) -> None: + self.submitted.append(event.text) + self.state.record_prompt(event.text) + + +@pytest.mark.asyncio +async def test_enter_submits_message(): + app = _ComposerHarness() + async with app.run_test() as pilot: + composer = app.query_one("#composer", ChatComposer) + composer.focus() + composer.value = "hello world" + composer.cursor_position = len(composer.value) + await pilot.press("enter") + # The message should have been posted with the composer text. + assert app.submitted == ["hello world"] + + +@pytest.mark.asyncio +async def test_ctrl_s_submits_message(): + app = _ComposerHarness() + async with app.run_test() as pilot: + composer = app.query_one("#composer", ChatComposer) + composer.focus() + composer.value = "send via ctrl+s" + composer.cursor_position = len(composer.value) + await pilot.press("ctrl+s") + assert app.submitted == ["send via ctrl+s"] + + +@pytest.mark.asyncio +async def test_up_down_navigates_history(): + app = _ComposerHarness() + async with app.run_test() as pilot: + composer = app.query_one("#composer", ChatComposer) + composer.focus() + composer.value = "draft" + composer.cursor_position = len(composer.value) + await pilot.press("enter") + composer.value = "" + composer.cursor_position = 0 + await pilot.press("up") + assert composer.value == "draft" + await pilot.press("down") + assert composer.value == "" + await pilot.press("down") + assert composer.value == "" + + +def test_prompt_history_records_and_navigates(): + state = AppState() + state.record_prompt("first") + state.record_prompt("second") + # Previous goes back through history. + assert state.history_previous("") == "second" + assert state.history_previous("") == "first" + # Next returns to the current draft. + assert state.history_next("") == "second" + assert state.history_next("") == "" + + +def test_prompt_history_dedupes_consecutive(): + state = AppState() + state.record_prompt("same") + state.record_prompt("same") + assert state.prompt_history == ["same"] diff --git a/tests/test_discovery.py b/tests/test_discovery.py new file mode 100644 index 0000000..4ffba25 --- /dev/null +++ b/tests/test_discovery.py @@ -0,0 +1,130 @@ +"""Tests for local model auto-discovery (Ollama /api/tags) and the +Ollama Cloud model scraper.""" +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from core.catalog import ( + BUILTIN_CATALOG, + discover_local_models, + scrape_ollama_cloud_models, + update_ollama_cloud_models, +) +from core.providers import LocalProvider, ModelConfig + + +def _fake_tags_response(models): + resp = MagicMock() + resp.json.return_value = {"models": [{"name": m} for m in models]} + return resp + + +async def test_discover_local_models_returns_installed(): + cfg = { + "provider_type": "local", + "endpoint": "http://localhost:11434", + "options": {"model": "llama3"}, + } + fake_resp = _fake_tags_response(["llama3:latest", "qwen3:8b", "nomic-embed-text:latest"]) + + with patch("core.providers.httpx.AsyncClient") as MockClient: + client = MockClient.return_value + client.get = AsyncMock(return_value=fake_resp) + client.aclose = AsyncMock() + models = await discover_local_models("local-llama", cfg) + + assert "llama3:latest" in models + assert "qwen3:8b" in models + assert "nomic-embed-text:latest" in models + + +async def test_discover_local_models_skips_non_local(): + cfg = {"provider_type": "cloud", "endpoint": "https://api.openai.com/v1"} + models = await discover_local_models("gpt-4o", cfg) + assert models == [] + + +async def test_discover_local_models_falls_back_on_error(): + cfg = { + "provider_type": "local", + "endpoint": "http://localhost:11434", + "options": {"model": "llama3"}, + } + with patch("core.providers.httpx.AsyncClient") as MockClient: + client = MockClient.return_value + client.get.side_effect = Exception("connection refused") + client.aclose = AsyncMock() + models = await discover_local_models("local-llama", cfg) + + # Falls back to the configured model. + assert models == ["llama3"] + + +async def test_local_provider_list_models_uses_tags_endpoint(): + provider = LocalProvider(ModelConfig( + name="local-llama", + endpoint="http://localhost:11434", + provider_type="local", + options={"model": "llama3"}, + )) + fake_resp = _fake_tags_response(["llama3:latest"]) + + with patch.object(provider._client, "get", return_value=fake_resp) as mock_get: + models = await provider.list_models() + + assert models == ["llama3:latest"] + mock_get.assert_called_once_with("http://localhost:11434/api/tags") + + +# ─── Ollama Cloud scraper ──────────────────────────────────────────────────── + +_HTML = """ + +GLM-5.2 +DeepSeek V4 Flash +Qwen 3.5 +duplicate + +""" + + +async def test_scrape_ollama_cloud_models_extracts_names(): + fake_resp = MagicMock() + fake_resp.text = _HTML + + with patch("core.catalog.httpx.AsyncClient") as MockClient: + client = MockClient.return_value + client.get = AsyncMock(return_value=fake_resp) + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + models = await scrape_ollama_cloud_models() + + assert "glm-5.2" in models + assert "deepseek-v4-flash" in models + assert "qwen3.5" in models + # Duplicates are removed. + assert models.count("glm-5.2") == 1 + + +async def test_scrape_ollama_cloud_models_returns_empty_on_error(): + with patch("core.catalog.httpx.AsyncClient") as MockClient: + client = MockClient.return_value + client.get.side_effect = Exception("network error") + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + models = await scrape_ollama_cloud_models() + assert models == [] + + +async def test_update_ollama_cloud_models_merges_into_catalog(): + fake_resp = MagicMock() + fake_resp.text = 'New' + + with patch("core.catalog.httpx.AsyncClient") as MockClient: + client = MockClient.return_value + client.get = AsyncMock(return_value=fake_resp) + client.__aenter__ = AsyncMock(return_value=client) + client.__aexit__ = AsyncMock(return_value=False) + added = await update_ollama_cloud_models() + + assert added >= 1 + assert "brand-new-model" in BUILTIN_CATALOG["ollama-cloud"]["models"] diff --git a/tests/test_session_context.py b/tests/test_session_context.py new file mode 100644 index 0000000..b543de4 --- /dev/null +++ b/tests/test_session_context.py @@ -0,0 +1,219 @@ +"""Tests for the rolling session-context feature. + +Covers: + - AppState.update_session_context / build_context_prompt / context_summary + - MotionAgent.run() augmenting memory recall with a context_query + - De-duplication of recalled chunks +""" +import asyncio +from unittest.mock import AsyncMock, MagicMock + +from core.providers import ModelConfig +from main import MotionAgent +from ui.tui import AppState + + +# ─── AppState session context ──────────────────────────────────────────────── + +def test_context_empty_initial(): + state = AppState() + assert state.build_context_prompt() == "" + assert state.context_summary() == "No context yet" + + +def test_context_keeps_recent_turns_verbatim(): + state = AppState() + for i in range(3): + state.update_session_context(f"prompt {i}", f"response {i}") + + block = state.build_context_prompt() + # Recent turns are kept verbatim (not folded). + assert "Q: prompt 0" in block + assert "A: response 0" in block + assert "Q: prompt 2" in block + # No prior-context folding yet (only 3 turns). + assert "[Prior context]" not in block + + +def test_context_folds_older_turns(): + state = AppState() + # Add more than KEEP (4) turns so older ones get folded into a summary. + for i in range(7): + state.update_session_context(f"prompt {i}", f"response {i}") + + block = state.build_context_prompt() + # Older turns are folded into a [Prior context] summary. + assert "[Prior context]" in block + # The most recent 4 turns remain verbatim. + assert "Q: prompt 3" in block + assert "Q: prompt 6" in block + # The very first turn is no longer verbatim (it was folded). + assert "Q: prompt 0" not in block + + +def test_context_summary_reports_turn_count_and_last_prompt(): + state = AppState() + state.update_session_context("hello", "hi there") + state.update_session_context("how are you", "fine") + summary = state.context_summary() + assert "2 turns" in summary + assert "how are you" in summary + + +def test_context_summary_singular(): + state = AppState() + state.update_session_context("only one", "ok") + assert "1 turn" in state.context_summary() + + +# ─── MotionAgent context_query recall ──────────────────────────────────────── + +class _MockProvider: + class _Config: + provider_type = "local" + + def __init__(self): + self.config = self._Config() + + async def complete(self, prompt, system_prompt="", **kwargs): + return "answer" + + async def close(self): + pass + + +class _MockRetriever: + """Records queries and returns canned results.""" + + def __init__(self): + self.queries = [] + self.results = { + "user prompt": [{"content": "memory A", "score": 1.0}], + "context query": [{"content": "memory B", "score": 1.0}], + } + + async def retrieve(self, query, top_k=5): + self.queries.append(query) + return self.results.get(query, []) + + +async def test_agent_uses_context_query_for_recall(): + agent = MotionAgent(ModelConfig(name="t", endpoint="http://localhost", provider_type="local")) + agent.provider = _MockProvider() + retriever = _MockRetriever() + agent.retriever = retriever + + await agent.run("user prompt", context_query="context query") + + # Both the prompt and the context query were used for recall. + assert "user prompt" in retriever.queries + assert "context query" in retriever.queries + + +async def test_agent_dedupes_recalled_chunks(): + agent = MotionAgent(ModelConfig(name="t", endpoint="http://localhost", provider_type="local")) + agent.provider = _MockProvider() + + class _DupRetriever: + async def retrieve(self, query, top_k=5): + # Both the prompt and context query return the SAME content. + return [{"content": "same memory", "score": 1.0}] + + agent.retriever = _DupRetriever() + + # Patch the provider to capture the system prompt (which contains the + # de-duplicated memory context). + captured = {} + + async def fake_complete(prompt, system_prompt="", **kwargs): + captured["system"] = system_prompt + return "answer" + + agent.provider.complete = fake_complete + + await agent.run("user prompt", context_query="context query") + + # The same memory should appear only once in the system prompt. + assert captured["system"].count("same memory") == 1 + + +async def test_agent_without_context_query_uses_only_prompt(): + agent = MotionAgent(ModelConfig(name="t", endpoint="http://localhost", provider_type="local")) + agent.provider = _MockProvider() + retriever = _MockRetriever() + agent.retriever = retriever + + await agent.run("user prompt") + + # Only the prompt was used; no context query. + assert retriever.queries == ["user prompt"] + + +# ─── Auto-compact ──────────────────────────────────────────────────────────── + +def test_should_compact_below_threshold(): + state = AppState() + state.session_metrics["total_tokens_est"] = 1000 + # 1000 < 8192 * 0.95 β†’ no compaction. + assert state.should_compact(8192) is False + + +def test_should_compact_at_threshold(): + state = AppState() + state.session_metrics["total_tokens_est"] = 8000 + # 8000 >= 8192 * 0.95 β†’ compact. + assert state.should_compact(8192) is True + + +def test_should_compact_ignores_invalid_window(): + state = AppState() + state.session_metrics["total_tokens_est"] = 100000 + assert state.should_compact(0) is False + assert state.should_compact(-1) is False + + +def test_compact_folds_turns_and_resets_metrics(): + state = AppState() + for i in range(5): + state.update_session_context(f"prompt {i}", f"response {i}") + state.session_metrics["total_tokens_est"] = 9000 + state.session_metrics["prompt_tokens_est"] = 5000 + state.session_metrics["output_tokens_est"] = 4000 + + state.compact() + + # All remaining recent turns folded into a compacted summary. + assert "[Compacted context]" in state.session_context + assert "Q: prompt 1" in state.session_context + assert "Q: prompt 4" in state.session_context + # Recent turns cleared. + assert state._context_turns == [] + assert state.conversation_turns == [] + # Token metrics reset. + assert state.session_metrics["total_tokens_est"] == 0 + assert state.session_metrics["prompt_tokens_est"] == 0 + assert state.session_metrics["output_tokens_est"] == 0 + + +def test_compact_preserves_context_in_build_prompt(): + state = AppState() + state.update_session_context("hello", "hi") + state.compact() + # The compacted summary is still available as a context query. + assert "[Compacted context]" in state.build_context_prompt() + + +# ─── Agent mode (build/plan) ────────────────────────────────────────────────── + +def test_agent_mode_defaults_to_build(): + state = AppState() + assert state.agent_mode == "build" + + +def test_agent_mode_toggle_cycles(): + state = AppState() + assert state.agent_mode == "build" + state.agent_mode = "plan" if state.agent_mode == "build" else "build" + assert state.agent_mode == "plan" + state.agent_mode = "plan" if state.agent_mode == "build" else "build" + assert state.agent_mode == "build" diff --git a/ui/themes.py b/ui/themes.py index dd57cd7..b8bbf96 100644 --- a/ui/themes.py +++ b/ui/themes.py @@ -18,6 +18,23 @@ # 2. A lightweight dataclass-style Theme (backward compat for ThemeRegistry) _RAW_THEMES: List[dict] = [ + dict( + id="omni_dark", + name="Omni Dark", + background="#191622", + surface="#23212B", + panel="#2A2734", + foreground="#E1E1E6", + primary="#78D1E1", + secondary="#483C67", + accent="#78D1E1", + border="#41414D", + highlight="#2E2A3A", + error="#E96379", + success="#67E480", + warning="#E89E64", + dark=True, + ), dict( id="one_dark", name="One Dark", @@ -82,6 +99,24 @@ warning="#ebcb8b", dark=True, ), + # opencode native theme β€” matches the terminal UI from github.com/anomalyco/opencode + dict( + id="opencode", + name="OpenCode", + background="#0a0a0a", + surface="#1e1e1e", + panel="#141414", + foreground="#eeeeee", + primary="#fab283", + secondary="#5c9cf5", + accent="#9d7cd8", + border="#484848", + highlight="#282828", + error="#e06c75", + success="#7fd88f", + warning="#f5a742", + dark=True, + ), ] @@ -119,7 +154,7 @@ def __init__(self, name: str, background: str, foreground: str, success=d["success"], warning=d["warning"], dark=d["dark"], - panel=d["border"], + panel=d.get("panel", d["border"]), boost=d["highlight"], ) diff --git a/ui/tui.py b/ui/tui.py index f6b7c24..766c75a 100644 --- a/ui/tui.py +++ b/ui/tui.py @@ -14,6 +14,15 @@ - Ctrl+C cancels the current request; Ctrl+Q quits - KB tab: knowledge base for reference docs that don't become skills +Visual guardrails (R3 β€” Ops Rounded): + - Maximum one permanent border per major region. + - No adjacent parallel separators within 1 row of each other. + - Spacing scale: 0, 1, 2 row gaps (2 reserved for section breaks). + - Persistent accent area target below ~8-10% of screen. + - Rounded corners on conversation bubbles and grouped setting blocks. + - Both User and Motion replies render as explicit rounded balloons. + - Accent is reserved for active tab, focused input, and high-signal state. + Launch: python main.py β†’ TUI (default) python main.py --chat β†’ old REPL python main.py --provider X β†’ TUI with pre-selected provider @@ -30,25 +39,28 @@ from typing import Any, Dict, Optional from rich.console import Group from rich.markdown import Markdown as RichMarkdown +from rich.style import Style from rich.text import Text from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal, Vertical, VerticalScroll +from textual.message import Message from textual.screen import Screen +from textual.strip import Strip +from textual.widget import Widget from textual.widgets import ( Button, - Header, Footer, + Header, Input, Label, ListItem, ListView, + LoadingIndicator, Select, Static, - TabbedContent, - TabPane, ) from core.config import ConfigManager @@ -86,12 +98,20 @@ def __init__(self) -> None: self.task_manager: Optional[TaskManager] = None self.config_manager: ConfigManager = ConfigManager() self.current_provider_id: str = "" - self.current_theme: str = "one_dark" + self.current_theme: str = "opencode" self.caveman_enabled: bool = True + self.auto_synthesis_enabled: bool = False self.ui_mode: str = "conservative" self.show_activity_rail: bool = True - self.show_trace_panel: bool = True + self.show_trace_panel: bool = False + self.agent_mode: str = "build" # "build" (full access) or "plan" (read-only) + self.busy: bool = False # True while an agent response is streaming self.last_agent_response: str = "" + self.prompt_history: list[str] = [] # submitted prompts for up/down recall + self._history_index: int = -1 + self.session_context: str = "" # rolling, bounded summary of the session + self._context_turns: list[tuple[str, str]] = [] # recent turns used to build context + self.conversation_turns: list[tuple[str, str]] = [] # (prompt, response) self.last_turn_metrics: dict = {} self.session_metrics: dict = { "turns": 0, @@ -118,6 +138,7 @@ def reconnect(self, provider_id: str) -> None: except Exception: pass self.agent = MotionAgent(model_config) + self.agent.auto_skill_synthesis = self.auto_synthesis_enabled self.task_manager = TaskManager(model_config, WORKSPACE) self.current_provider_id = provider_id @@ -150,6 +171,87 @@ def _provider_priority(pid: str) -> tuple[int, str]: options.append((name, pid)) return options + def record_prompt(self, prompt: str) -> None: + """Record a submitted prompt for up/down history recall.""" + if not prompt: + return + if not self.prompt_history or self.prompt_history[-1] != prompt: + self.prompt_history.append(prompt) + self._history_index = len(self.prompt_history) + + def history_previous(self, current: str) -> str: + """Return the previous prompt in history, or the current draft.""" + if not self.prompt_history: + return current + if self._history_index < 0: + self._history_index = len(self.prompt_history) + # Save the draft the first time we move up from the empty "new prompt" slot. + if self._history_index == len(self.prompt_history): + self._history_draft = current + self._history_index = max(0, self._history_index - 1) + return self.prompt_history[self._history_index] + + def history_next(self, current: str) -> str: + """Return the next prompt in history, or the current draft.""" + if not self.prompt_history: + return current + if self._history_index >= len(self.prompt_history) - 1: + self._history_index = len(self.prompt_history) + return getattr(self, "_history_draft", current) + self._history_index += 1 + return self.prompt_history[self._history_index] + + def update_session_context(self, prompt: str, response: str) -> None: + """Maintain a rolling, bounded summary of the session. + + Keeps the most recent turns verbatim and folds older turns into a + compact summary so the model always has aligned, up-to-date context + without unbounded history growth. + """ + self._context_turns.append((prompt, response)) + # Keep the last N turns verbatim; fold everything older into a summary. + KEEP = 4 + if len(self._context_turns) > KEEP: + older = self._context_turns[:-KEEP] + self._context_turns = self._context_turns[-KEEP:] + folded = "\n".join(f"Q: {p}\nA: {r[:200]}" for p, r in older) + self.session_context = f"[Prior context]\n{folded}\n\n[Recent turns]\n" + else: + self.session_context = "[Recent turns]\n" + + def should_compact(self, context_window: int = 8192, threshold: float = 0.95) -> bool: + """Return True when the session has consumed most of the context window.""" + if context_window <= 0: + return False + used = self.session_metrics.get("total_tokens_est", 0) + return used >= context_window * threshold + + def compact(self) -> None: + """Fold all current turns into a single prior-context summary.""" + if not self._context_turns: + return + folded = "\n".join(f"Q: {p}\nA: {r[:200]}" for p, r in self._context_turns) + self.session_context = f"[Compacted context]\n{folded}\n" + self._context_turns = [] + self.session_metrics["total_tokens_est"] = 0 + self.session_metrics["prompt_tokens_est"] = 0 + self.session_metrics["output_tokens_est"] = 0 + + def build_context_prompt(self) -> str: + """Return the session context block, used as a *query* to the vector DB.""" + if not self._context_turns and not self.session_context: + return "" + recent = "\n".join(f"Q: {p}\nA: {r[:300]}" for p, r in self._context_turns) + return f"{self.session_context}{recent}" + + def context_summary(self) -> str: + """Short one-line summary for the UI context row.""" + if not self._context_turns: + return "No context yet" + n = len(self._context_turns) + last = self._context_turns[-1][0] + return f"{n} turn{'s' if n != 1 else ''} Β· last: {last[:60]}" + @staticmethod def build_all_provider_info() -> list[tuple[str, str, list, bool, bool]]: """Return all providers: (pid, name, models, is_default, has_key).""" @@ -187,58 +289,235 @@ def _extract_reasoning_and_answer(text: str) -> tuple[str, str]: # ─── Chat message widgets ───────────────────────────────────────────────────── class UserMessage(Static): - """A user message bubble β€” primary accent, left border.""" + """User message β€” thin primary left accent bar, no box.""" DEFAULT_CSS = """ UserMessage { - background: $surface; - border-left: heavy $primary; - border-right: blank; - padding: 1 2; - margin: 1 10 0 0; + background: transparent; color: $text; + border-left: thick $primary; + padding: 0 2; + margin: 1 0 0 1; } """ - class ReasoningMessage(Static): - """Collapsible-style block used to surface model reasoning stream.""" + """opencode-style Thinking block β€” muted header + dim italic body.""" DEFAULT_CSS = """ ReasoningMessage { background: $panel; - border-left: heavy $warning; - border-right: blank; - padding: 1 2; - margin: 1 0 0 10; color: $text-muted; - text-style: dim italic; + border-left: solid $warning; + padding: 0 2; + margin: 0 0 1 1; } """ class AgentMessage(Static): - """An agent message bubble β€” success accent, left border.""" + """Agent reply β€” no box, clean text flow, spaced below the user prompt. + + The color is intentionally unset so the Rich Markdown visual keeps its + own theme-aware token colors (syntax highlighting) like opencode. + """ DEFAULT_CSS = """ AgentMessage { - background: $surface; - border-left: heavy $accent; - border-right: blank; - padding: 1 2; - margin: 1 0 0 10; - color: $text; + background: transparent; + padding: 0 2; + margin: 1 0 1 1; } """ class SystemMessage(Static): - """A system/info message β€” muted, italic.""" + """System/info message β€” muted, single-line.""" DEFAULT_CSS = """ SystemMessage { color: $text-muted; text-style: dim; - padding: 0 1; + padding: 0 2; margin: 0 0 0 0; } """ +class ComposerSubmitted(Message): + """Posted by ChatComposer when the user presses Enter.""" + + def __init__(self, text: str) -> None: + self.text = text + super().__init__() + + +class ChatComposer(Static, can_focus=True): + """Compact two-row prompt panel mirroring opencode's Prompt component. + + Row 0: single-line editable input with a themed cursor. + Row 1: meta line (agent Β· model Β· provider) rendered as markup. + The whole panel has a thick left border in the agent-mode color. + """ + + DEFAULT_CSS = """ + ChatComposer { + height: auto; + min-height: 4; + max-height: 12; + width: 1fr; + background: $surface; + color: $text; + padding: 1 2 1 2; + border: blank; + border-left: solid $secondary; + margin: 0; + } + ChatComposer:focus { + border: blank; + border-left: solid $secondary; + background: $surface; + } + """ + + def __init__(self, state: AppState, placeholder: str = "Ask anything…", **kwargs) -> None: + super().__init__(**kwargs) + self._state = state + self._placeholder = placeholder + self.value: str = "" + self.cursor_position: int = 0 + self.meta_markup: str = "" + + def set_meta(self, markup: str) -> None: + """Update the second (meta) row.""" + self.meta_markup = markup + self.refresh() + + def _wrap_value(self) -> list[str]: + """Soft-wrap the input value to the available content width.""" + width = max(10, self.size.width - self.styles.padding.left - self.styles.padding.right) + if not self.value: + return [self._placeholder] + lines: list[str] = [] + for paragraph in self.value.split("\n"): + if not paragraph: + lines.append("") + continue + while len(paragraph) > width: + lines.append(paragraph[:width]) + paragraph = paragraph[width:] + lines.append(paragraph) + return lines + + def _cursor_to_wrapped(self, width: int) -> tuple[int, int]: + """Map a flat cursor offset to (wrapped_line_index, column).""" + pos = min(self.cursor_position, len(self.value)) + paragraphs = self.value.split("\n") + seen = 0 + for p, para in enumerate(paragraphs): + para_len = len(para) + if pos <= seen + para_len: + col_in_para = pos - seen + # How many wrapped lines preceded this paragraph? + wline = sum(max(1, -(-len(q) // width)) for q in paragraphs[:p]) + wrapped_row = wline + (col_in_para // width) + wrapped_col = col_in_para % width + return wrapped_row, wrapped_col + seen += para_len + 1 + # Cursor at very end. + wline = sum(max(1, -(-len(q) // width)) for q in paragraphs) + last = paragraphs[-1] if paragraphs else "" + return max(0, wline - 1), len(last) % width + + def render(self) -> Text: + width = max(10, self.size.width - self.styles.padding.left - self.styles.padding.right) + wrapped = self._wrap_value() + lines = [Text(line, style=self.rich_style) for line in wrapped] + + if self.has_focus: + wline, wcol = self._cursor_to_wrapped(width) + if wline >= len(lines): + wline = len(lines) - 1 + if not lines[wline].plain: + lines[wline] = Text(" ", style=self.rich_style) + if wcol >= len(lines[wline].plain): + lines[wline].append(" ") + wcol = len(lines[wline].plain) - 1 + theme = self.app.get_theme(self.app.theme) + from textual.color import Color + primary = Color.parse(theme.primary).rich_color + surface_color = theme.surface or theme.background or "#1e1e1e" + surface = Color.parse(surface_color).rich_color + lines[wline].stylize(Style(bgcolor=primary, color=surface), wcol, wcol + 1) + + line1 = Text.from_markup(self.meta_markup) if self.meta_markup else Text("") + result = lines[0] + for extra in lines[1:]: + result = Text.assemble(result, "\n", extra) + return Text.assemble(result, "\n\n", line1) + + def _invalidate_layout(self) -> None: + # Invalidate the cached content height so the panel re-sizes with the + # number of (soft-wrapped) input lines. + try: + self._content_height_cache = None + except Exception: + pass + self.refresh(repaint=True, layout=True) + + def _insert(self, char: str) -> None: + pos = self.cursor_position + self.value = self.value[:pos] + char + self.value[pos:] + self.cursor_position = min(len(self.value), pos + 1) + self._invalidate_layout() + + def _delete(self) -> None: + pos = self.cursor_position + if pos < len(self.value): + self.value = self.value[:pos] + self.value[pos + 1:] + self._invalidate_layout() + + def _backspace(self) -> None: + pos = self.cursor_position + if pos > 0: + self.value = self.value[: pos - 1] + self.value[pos:] + self.cursor_position = pos - 1 + self._invalidate_layout() + + def on_key(self, event) -> None: + if event.key == "enter" or event.key == "ctrl+s": + event.prevent_default() + self.post_message(ComposerSubmitted(self.value)) + elif event.key == "up": + event.prevent_default() + self.value = self._state.history_previous(self.value) + self.cursor_position = len(self.value) + self.refresh() + elif event.key == "down": + event.prevent_default() + self.value = self._state.history_next(self.value) + self.cursor_position = len(self.value) + self.refresh() + elif event.key == "left": + event.prevent_default() + self.cursor_position = max(0, self.cursor_position - 1) + self.refresh() + elif event.key == "right": + event.prevent_default() + self.cursor_position = min(len(self.value), self.cursor_position + 1) + self.refresh() + elif event.key == "home": + event.prevent_default() + self.cursor_position = 0 + self.refresh() + elif event.key == "end": + event.prevent_default() + self.cursor_position = len(self.value) + self.refresh() + elif event.key == "backspace": + event.prevent_default() + self._backspace() + elif event.key == "delete": + event.prevent_default() + self._delete() + elif event.character is not None and event.is_printable: + event.prevent_default() + self._insert(event.character) + class ProviderOption(ListItem): """A selectable provider row on the startup screen.""" @@ -267,7 +546,7 @@ class ProviderSelectScreen(Screen): width: 72; height: auto; max-height: 85%; - border: round $primary; + border: round $border; padding: 1 3; background: $surface; overflow-y: auto; @@ -275,7 +554,7 @@ class ProviderSelectScreen(Screen): #provider_title { text-align: center; text-style: bold; - color: $primary; + color: $text; margin-bottom: 0; } #provider_subtitle { @@ -351,33 +630,52 @@ def action_select(self) -> None: # ─── Global activity rail + main hub ───────────────────────────────────────── -class ActivityRail(Vertical): - """Right-side global activity rail for live task visibility.""" +class ContextPanel(Vertical): + """Right-side context panel showing session context and recent turns. + + Mirrors opencode's context/side panel: it reflects the rolling session + summary and the most recent user/assistant turns so the operator can see + what the model is working against. + """ DEFAULT_CSS = """ - ActivityRail { - width: 36; + ContextPanel { + width: 38; min-width: 30; - max-width: 42; - border-left: heavy $border; + max-width: 44; + border-left: blank; + margin-left: 2; padding: 1 1; background: $panel; } - #activity_header { - color: $accent; + #context_header { + color: $text-muted; text-style: bold; margin-bottom: 1; + padding: 0 1; + background: transparent; + border: blank; } - #activity_list { + #context_body { height: 1fr; scrollbar-size: 1 1; } - .activity_row { + .context_section { + color: $text-muted; + text-style: bold; + margin-top: 1; + margin-bottom: 0; + padding: 0 1; + } + .context_turn { padding: 0 1; margin: 0 0 1 0; color: $text; - background: $surface; - border: round $border; + background: transparent; + border: blank; + } + .context_turn:first-child { + border-top: blank; } """ @@ -386,102 +684,379 @@ def __init__(self, state: AppState, **kwargs) -> None: self.state = state def compose(self) -> ComposeResult: - yield Label("⚑ Live Activity", id="activity_header") - yield VerticalScroll(id="activity_list") + yield Label("Context", id="context_header") + yield VerticalScroll(id="context_body") + + def refresh_context(self) -> None: + container = self.query_one("#context_body", VerticalScroll) + for child in list(container.children): + child.remove() + + summary = self.state.context_summary() + container.mount(Static(f"[dim]session Β· {summary}[/]", classes="context_turn")) + + # Resolve the theme primary to a hex for Rich markup. + try: + theme = self.app.get_theme(self.app.theme) + primary = theme.primary or "#fab283" + except Exception: + primary = "#fab283" + + turns = getattr(self.state, "conversation_turns", None) or [] + if turns: + container.mount(Label("Recent turns", classes="context_section")) + for prompt, response in turns[-6:]: + p = prompt.replace("[", "\\[").replace("]", "\\]") + p = p[:44] + "…" if len(p) > 44 else p + r = (response or "").replace("[", "\\[").replace("]", "\\]") + r = r[:44] + "…" if len(r) > 44 else r + container.mount( + Static(f"[{primary} bold]Q[/] {p}\n[dim]A[/] {r or '…'}", classes="context_turn") + ) + else: + container.mount(Static("[dim]No turns yet β€” start a conversation.[/]", classes="context_turn")) + + +# ─── Shortcuts overlay ──────────────────────────────────────────────────────── + +class ShortcutsOverlay(Screen): + """Unified shortcuts help overlay generated from real bindings.""" + + CSS = """ + ShortcutsOverlay { + align: center middle; + } + #shortcuts_box { + width: 78; + max-height: 80%; + border: round $border; + background: $surface; + padding: 1 2; + scrollbar-size: 1 1; + } + #shortcuts_title { + color: $text; + text-style: bold; + text-align: center; + margin-bottom: 1; + } + #shortcuts_body { + height: auto; + max-height: 70%; + scrollbar-size: 1 1; + } + .shortcut_section { + color: $text-muted; + text-style: bold; + margin-top: 1; + margin-bottom: 0; + } + .shortcut_row { + color: $text; + padding: 0 1; + } + #shortcuts_hint { + color: $text-muted; + text-align: center; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("escape", "dismiss_overlay", "Close"), + Binding("question_sign", "dismiss_overlay", "Close"), + Binding("q", "dismiss_overlay", "Close"), + ] + + def __init__(self, main_screen: "MainScreen", **kwargs) -> None: + super().__init__(**kwargs) + self._main = main_screen + + def compose(self) -> ComposeResult: + with VerticalScroll(id="shortcuts_box"): + yield Label("⌨️ Keyboard Shortcuts", id="shortcuts_title") + yield VerticalScroll(id="shortcuts_body") + yield Label("Press ? / Esc / q to close", id="shortcuts_hint") def on_mount(self) -> None: - self.set_interval(0.5, self.refresh_activity) - self.refresh_activity() + self.query_one("#shortcuts_box", VerticalScroll).border_title = " Shortcuts " + body = self.query_one("#shortcuts_body", VerticalScroll) + sections = self._collect_bindings() + for section_title, rows in sections.items(): + body.mount(Label(section_title, classes="shortcut_section")) + for key, label in rows: + body.mount(Static(f" [bold]{key}[/] [dim]Β·[/] {label}", classes="shortcut_row")) + + def _collect_bindings(self) -> dict: + groups: dict[str, list[tuple[str, str]]] = {} + seen_keys: set[str] = set() + global_bindings = getattr(self._main, "BINDINGS", []) or [] + groups["Global"] = [] + for b in global_bindings: + key = getattr(b, "key", "") + label = getattr(b, "description", "") or key + if not key or key in seen_keys: + continue + seen_keys.add(key) + groups["Global"].append(self._pretty_key(key, label)) + try: + chat = self._main.query_one(ChatPane) + chat_bindings = getattr(chat, "BINDINGS", []) or [] + groups["Chat"] = [] + for b in chat_bindings: + key = getattr(b, "key", "") + label = getattr(b, "description", "") or key + if not key or key in seen_keys: + continue + seen_keys.add(key) + groups["Chat"].append(self._pretty_key(key, label)) + except Exception: + pass + return {k: v for k, v in groups.items() if v} - def refresh_activity(self) -> None: - task_manager = self.state.task_manager - if not task_manager: - return + @staticmethod + def _pretty_key(key: str, label: str) -> tuple[str, str]: + pretty = ( + key.replace("ctrl+", "Ctrl+") + .replace("alt+", "Alt+") + .replace("shift+", "Shift+") + .replace("_", " ") + ) + pretty = pretty.replace("question sign", "?") + return pretty, label - status = task_manager.get_status() - tasks = list(status["tasks"].values()) - tasks.sort(key=lambda t: t.start_time or datetime.min, reverse=True) - running = sum(1 for t in tasks if t.status == "RUNNING") - self.query_one("#activity_header", Label).update(f"⚑ Live Activity Β· {running} running") + def action_dismiss_overlay(self) -> None: + self.app.pop_screen() - container = self.query_one("#activity_list", VerticalScroll) - for child in list(container.children): - child.remove() - if not tasks: - container.mount(Static("[dim]No tasks yet[/]", classes="activity_row")) +# ─── Command palette + model dialog ───────────────────────────────────────── + +class CommandPalette(Screen): + """opencode-style command palette (Ctrl+K).""" + + CSS = """ + CommandPalette { + align: center middle; + } + #palette_box { + width: 72; + max-height: 80%; + border: round $border; + background: $surface; + padding: 1 2; + scrollbar-size: 1 1; + } + #palette_input { + margin-bottom: 1; + border: solid $border; + } + #palette_list { + height: auto; + max-height: 60%; + scrollbar-size: 1 1; + padding: 0 1; + border: blank; + } + #palette_hint { + color: $text-muted; + text-align: center; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("escape", "dismiss_palette", "Close", priority=True), + ] + + def __init__(self, main_screen: "MainScreen", **kwargs) -> None: + super().__init__(**kwargs) + self._main = main_screen + self._commands: list[tuple[str, str]] = [] + + def compose(self) -> ComposeResult: + with VerticalScroll(id="palette_box"): + yield Input(placeholder="Type a command…", id="palette_input") + yield ListView(id="palette_list") + yield Label("Esc to close", id="palette_hint") + + def on_mount(self) -> None: + self.query_one("#palette_box", VerticalScroll).border_title = " Commands " + self._commands = [ + ("Switch model…", "model"), + ("Toggle agent (build/plan)", "agent"), + ("Toggle theme", "theme"), + ("Show shortcuts", "shortcuts"), + ("Toggle trace panel", "trace"), + ("Copy last response", "copy"), + ("Toggle context panel", "context"), + ] + lv = self.query_one("#palette_list", ListView) + for label, _ in self._commands: + lv.append(ListItem(Label(label))) + self.query_one("#palette_input", Input).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + query = event.value.strip().lower() + lv = self.query_one("#palette_list", ListView) + lv.clear() + for label, action in self._commands: + if query in label.lower(): + lv.append(ListItem(Label(label))) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if event.item is None: return + self._activate_label(event.item) - for task in tasks[:20]: - icon = TaskRow.ICON.get(task.status, "β€’") - color = TaskRow.COLOR.get(task.status, "white") - snippet = task.prompt.replace("[", "\\[").replace("]", "\\]") - snippet = snippet[:46] + "…" if len(snippet) > 46 else snippet - latest = task.logs[-1] if task.logs else "" - latest = latest.replace("[", "\\[").replace("]", "\\]") - latest = latest[:58] + "…" if len(latest) > 58 else latest - row = f"{icon} [{color}]{task.status}[/] [dim]{task.task_id}[/]\n{snippet}" - if latest: - row += f"\n[dim]{latest}[/]" - container.mount(Static(row, classes="activity_row")) + def on_input_submitted(self, event: Input.Submitted) -> None: + # Enter in the input box: pick the highlighted / first filtered command. + lv = self.query_one("#palette_list", ListView) + item = lv.highlighted_child or (lv.children[0] if lv.children else None) + if item is not None: + event.stop() + self._activate_label(item) + def _activate_label(self, item) -> None: + try: + label = str(item.query_one(Label).renderable) + except Exception: + return + for lab, action in self._commands: + if lab == label: + self.app.pop_screen() + self._run(action) + return -class MainScreen(Screen): - """The main hub with Chat, Tasks, Skills, Memory, Settings tabs.""" + def _run(self, action: str) -> None: + if action == "model": + self._main.action_open_model_dialog() + elif action == "agent": + try: + self._main.query_one(ChatPane)._toggle_agent_mode() + except Exception: + pass + elif action == "theme": + self._main.action_toggle_theme() + elif action == "shortcuts": + self._main.action_show_shortcuts() + elif action == "trace": + try: + self._main.query_one(ChatPane).action_toggle_trace_panel() + except Exception: + pass + elif action == "copy": + try: + self._main.query_one(ChatPane).action_copy_last_response() + except Exception: + pass + elif action == "context": + self._main.action_toggle_context_panel() + + def action_dismiss_palette(self) -> None: + self.app.pop_screen() + + +class ModelDialog(Screen): + """opencode-style model/provider switcher (Ctrl+O).""" CSS = """ - #main_shell { height: 1fr; background: $background; } - #main_body { width: 1fr; padding: 0 1 0 0; } - #tab_quick_nav { - height: auto; - padding: 0 1 1 1; - background: $panel; + ModelDialog { + align: center middle; + } + #model_box { + width: 72; + max-height: 80%; border: round $border; + background: $surface; + padding: 1 2; + scrollbar-size: 1 1; } - .tab_nav_btn { - margin-right: 1; - min-width: 11; - height: 3; + #model_title { + color: $text; + text-style: bold; + text-align: center; + margin-bottom: 1; + } + #model_list { + height: auto; + max-height: 60%; + scrollbar-size: 1 1; + padding: 0 1; + border: blank; } - #main_tabs { height: 1fr; } + #model_hint { + color: $text-muted; + text-align: center; + margin-top: 1; + } + """ + + BINDINGS = [ + Binding("escape", "dismiss_model", "Close", priority=True), + ] + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state + + def compose(self) -> ComposeResult: + with VerticalScroll(id="model_box"): + yield Label("Select Provider / Model", id="model_title") + yield ListView(id="model_list") + yield Label("Esc to close", id="model_hint") + + def on_mount(self) -> None: + lv = self.query_one("#model_list", ListView) + for pid, name, models, is_default, has_key in AppState.build_all_provider_info(): + if not has_key: + continue + marker = " ← default" if is_default else "" + default_model = (models[0] if models else "") + label = f"⚑ {name}{marker} [dim]{default_model}[/]" + lv.append(ProviderOption(pid, name, models, is_default, has_key)) + self.query_one("#model_list", ListView).focus() + + def on_list_view_selected(self, event: ListView.Selected) -> None: + option = event.item + if not isinstance(option, ProviderOption): + return + provider_id = option.provider_id + models = option.models + default_model = 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) + self.notify(f"Switched to {full_id}") + except Exception as e: + self.notify(f"Connection failed: {e}", severity="error") + self.app.pop_screen() + + def action_dismiss_model(self) -> None: + self.app.pop_screen() + + +class MainScreen(Screen): + """The main chat screen with a right-hand context panel.""" + + CSS = """ + #main_shell { height: 1fr; background: $background; } #session_metrics_footer { height: auto; color: $text-muted; background: $background; - border-top: solid $border; + border-top: blank; padding: 0 2; - } - TabbedContent TabPane { - padding: 0 0; + text-style: dim; } """ BINDINGS = [ Binding("ctrl+t", "toggle_theme", "Theme", priority=True), - Binding("ctrl+b", "toggle_activity_rail", "Rail", priority=True), - Binding("ctrl+right", "next_tab", "Next Tab", priority=True), - Binding("ctrl+left", "prev_tab", "Prev Tab", priority=True), - Binding("ctrl+1", "goto_tab('chat_tab')", "Chat", priority=True), - Binding("ctrl+2", "goto_tab('tasks_tab')", "Tasks", priority=True), - Binding("ctrl+3", "goto_tab('skills_tab')", "Skills", priority=True), - Binding("ctrl+4", "goto_tab('kb_tab')", "KB", priority=True), - Binding("ctrl+5", "goto_tab('memory_tab')", "Memory", priority=True), - Binding("ctrl+6", "goto_tab('settings_tab')", "Settings", priority=True), - Binding("alt+1", "goto_tab('chat_tab')", "Chat", priority=True), - Binding("alt+2", "goto_tab('tasks_tab')", "Tasks", priority=True), - Binding("alt+3", "goto_tab('skills_tab')", "Skills", priority=True), - Binding("alt+4", "goto_tab('kb_tab')", "KB", priority=True), - Binding("alt+5", "goto_tab('memory_tab')", "Memory", priority=True), - Binding("alt+6", "goto_tab('settings_tab')", "Settings", priority=True), - Binding("f1", "goto_tab('chat_tab')", "Chat", priority=True), - Binding("f2", "goto_tab('tasks_tab')", "Tasks", priority=True), - Binding("f3", "goto_tab('skills_tab')", "Skills", priority=True), - Binding("f4", "goto_tab('kb_tab')", "KB", priority=True), - Binding("f5", "goto_tab('memory_tab')", "Memory", priority=True), - Binding("f6", "goto_tab('settings_tab')", "Settings", priority=True), - Binding("ctrl+]", "next_tab", "Next Tab", priority=True), - Binding("ctrl+[", "prev_tab", "Prev Tab", priority=True), + Binding("ctrl+b", "toggle_context_panel", "Context", priority=True), + Binding("ctrl+k", "open_command_palette", "Commands", priority=True), + Binding("ctrl+o", "open_model_dialog", "Model", priority=True), Binding("ctrl+q", "quit", "Quit", priority=True), + Binding("question_sign", "show_shortcuts", "Shortcuts", priority=True), ] def __init__(self, state: AppState, **kwargs) -> None: @@ -491,48 +1066,29 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: yield Header(show_clock=True) with Horizontal(id="main_shell"): - with Vertical(id="main_body"): - with Horizontal(id="tab_quick_nav"): - yield Button("Chat", id="tab_btn_chat", classes="tab_nav_btn") - yield Button("Tasks", id="tab_btn_tasks", classes="tab_nav_btn") - yield Button("Skills", id="tab_btn_skills", classes="tab_nav_btn") - yield Button("Knowledge", id="tab_btn_kb", classes="tab_nav_btn") - yield Button("Memory", id="tab_btn_memory", classes="tab_nav_btn") - yield Button("Settings", id="tab_btn_settings", classes="tab_nav_btn") - 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("πŸ“š KB", id="kb_tab"): - yield KBPane(self.state) - with TabPane("🧠 Memory", id="memory_tab"): - yield MemoryPane(self.state) - with TabPane("πŸ”§ Settings", id="settings_tab"): - yield SettingsPane(self.state) - yield ActivityRail(self.state, id="activity_rail") + yield ChatPane(self.state) + yield ContextPanel(self.state, id="context_panel") yield Label("", id="session_metrics_footer") yield Footer() - async def on_button_pressed(self, event: Button.Pressed) -> None: - mapping = { - "tab_btn_chat": "chat_tab", - "tab_btn_tasks": "tasks_tab", - "tab_btn_skills": "skills_tab", - "tab_btn_kb": "kb_tab", - "tab_btn_memory": "memory_tab", - "tab_btn_settings": "settings_tab", - } - tab = mapping.get(event.button.id or "") - if tab: - self._activate_tab(tab) - def on_mount(self) -> None: if not self.state.show_activity_rail: - self.query_one("#activity_rail", ActivityRail).styles.display = "none" + self.query_one("#context_panel", ContextPanel).styles.display = "none" self.refresh_session_footer() + self.refresh_context_panel() + + def refresh_context_panel(self) -> None: + try: + panel = self.query_one("#context_panel", ContextPanel) + panel.refresh_context() + except Exception: + pass + + def action_open_command_palette(self) -> None: + self.app.push_screen(CommandPalette(self)) + + def action_open_model_dialog(self) -> None: + self.app.push_screen(ModelDialog(self.state)) def refresh_session_footer(self) -> None: s = self.state.session_metrics or {} @@ -557,51 +1113,13 @@ def action_toggle_theme(self) -> None: self.app.theme = self.state.current_theme self.notify(f"Theme β†’ {self.state.current_theme}") - def action_toggle_activity_rail(self) -> None: - rail = self.query_one("#activity_rail", ActivityRail) + def action_toggle_context_panel(self) -> None: + panel = self.query_one("#context_panel", ContextPanel) self.state.show_activity_rail = not self.state.show_activity_rail - rail.styles.display = "block" if self.state.show_activity_rail else "none" - - def _activate_tab(self, tab_id: str) -> None: - tabs = self.query_one("#main_tabs", TabbedContent) - tabs.active = tab_id - focus_targets = { - "chat_tab": "#chat_input", - "tasks_tab": "#task_input", - "skills_tab": "#skills_search_input", - "kb_tab": "#kb_search_input", - "memory_tab": "#memory_search_input", - } - selector = focus_targets.get(tab_id) - if not selector: - return - try: - self.query_one(selector, Input).focus() - except Exception: - pass + panel.styles.display = "block" if self.state.show_activity_rail else "none" - def action_goto_tab(self, tab_id: str) -> None: - self._activate_tab(tab_id) - - def action_next_tab(self) -> None: - tabs = self.query_one("#main_tabs", TabbedContent) - order = ["chat_tab", "tasks_tab", "skills_tab", "kb_tab", "memory_tab", "settings_tab"] - current = tabs.active or order[0] - try: - idx = order.index(current) - except ValueError: - idx = 0 - self._activate_tab(order[(idx + 1) % len(order)]) - - def action_prev_tab(self) -> None: - tabs = self.query_one("#main_tabs", TabbedContent) - order = ["chat_tab", "tasks_tab", "skills_tab", "kb_tab", "memory_tab", "settings_tab"] - current = tabs.active or order[0] - try: - idx = order.index(current) - except ValueError: - idx = 0 - self._activate_tab(order[(idx - 1) % len(order)]) + def action_show_shortcuts(self) -> None: + self.app.push_screen(ShortcutsOverlay(self)) # ─── Chat pane ──────────────────────────────────────────────────────────────── @@ -613,84 +1131,95 @@ class ChatPane(Vertical): Binding("ctrl+shift+c", "copy_last_response", "Copy", priority=True), Binding("f8", "toggle_trace_panel", "Trace", priority=True), Binding("f9", "copy_last_response", "Copy", priority=True), + Binding("tab", "toggle_agent_mode", "Agent", priority=True), + Binding("ctrl+e", "open_external_editor", "Editor", priority=True), ] DEFAULT_CSS = """ ChatPane { height: 1fr; + background: $background; + padding: 0; } #chat_body { height: 1fr; - padding: 0 0 1 0; + padding: 0; } #chat_log { height: 1fr; width: 3fr; - border: round $primary; + border: blank; padding: 1 2; scrollbar-size: 1 1; - background: $surface; + background: $background; } #trace_panel { - width: 42; + width: 40; height: 1fr; - border: round $accent; + border: blank; background: $panel; padding: 1 1; margin-left: 1; } #trace_header { - color: $accent; + color: $text-muted; text-style: bold; - margin-bottom: 0; + margin-bottom: 1; + padding: 0 1; + background: transparent; + border: blank; } #trace_log { height: 1fr; scrollbar-size: 1 1; - border-top: solid $border; padding-top: 1; } - #chat_input_row { - height: auto; - padding: 1 1 1 1; - background: $panel; - border: round $border; - } - #chat_controls { - height: auto; - width: auto; - padding: 0 0 0 1; - border-left: solid $border; - margin-left: 1; + #trace_log > * { + border-top: blank; + padding-top: 0; + margin-top: 0; } - #chat_primary_actions, #chat_secondary_actions { + #trace_summary_chip { height: auto; width: auto; - } - #chat_secondary_actions { - margin-left: 1; - } - #chat_metrics { - color: $text-muted; - margin-top: 0; - margin-bottom: 1; padding: 0 2; + margin: 0 0 1 0; + background: transparent; + border: blank; + color: $text-muted; + text-style: dim; } - #chat_input { - height: 3; - border: round $primary; - background: $surface; + /* + Prompt layout modeled on opencode's Prompt component: + - Surface panel holds the single-line input + meta row. + - Thick left border in the agent/model color. + - Status row below shows spinner + token/cost + shortcuts. + */ + #chat_status { + height: 1; width: 1fr; + padding: 0 0 0 2; + background: $background; + color: $text-muted; + text-style: dim; + content-align: left middle; } - .chat_btn { - margin-left: 1; - min-width: 10; + #chat_status_spinner { + width: 3; + height: 1; + color: $primary; + margin-right: 1; + display: none; + } + #chat_status_spinner.busy { + display: block; } """ def __init__(self, state: AppState, **kwargs) -> None: super().__init__(**kwargs) self.state = state + self._last_trace_stage: str = "" def compose(self) -> ComposeResult: with Horizontal(id="chat_body"): @@ -698,39 +1227,95 @@ def compose(self) -> ComposeResult: with Vertical(id="trace_panel"): yield Label("Interaction Trace", id="trace_header") yield VerticalScroll(id="trace_log") - yield Label("", id="chat_metrics") - with Horizontal(id="chat_input_row"): - yield Input(placeholder="Type a message… (Enter to send)", id="chat_input") - with Horizontal(id="chat_controls"): - with Horizontal(id="chat_primary_actions"): - yield Button("Trace On", id="chat_toggle_trace_btn", classes="chat_btn") - yield Button("Copy", id="chat_copy_btn", classes="chat_btn") - yield Button("Stats", id="chat_stats_btn", classes="chat_btn") - with Horizontal(id="chat_secondary_actions"): - yield Button("Save Skill", id="chat_save_skill_btn", classes="chat_btn") + yield Label("", id="trace_summary_chip") + yield ChatComposer( + self.state, + placeholder="Ask anything…", + id="chat_input", + ) + with Horizontal(id="chat_status"): + yield LoadingIndicator(id="chat_status_spinner") + yield Label("", id="chat_status_text") + + def on_click(self, event) -> None: + if getattr(event.control, "id", None) == "trace_summary_chip": + self._set_trace_panel_visible(True) def on_mount(self) -> None: 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(SystemMessage(f"⚑ Motion Harness β€” connected to {name}")) - log.mount(SystemMessage("Tip: Ctrl/Alt+1..6 or F1-F6 tabs Β· F8 Trace Β· F9 Copy Β· /skill save ")) + log.mount(SystemMessage("Tip: Ctrl+K commands Β· Ctrl+O model Β· Tab agent Β· F8 trace Β· F9 copy Β· /skill save ")) self._append_trace("session_start", f"provider={name}") self._set_trace_panel_visible(self.state.show_trace_panel) - self._refresh_metrics_bar() - self.query_one("#chat_input", Input).focus() + self._refresh_meta() + self.query_one("#chat_input", ChatComposer).focus() def _set_trace_panel_visible(self, visible: bool) -> None: self.state.show_trace_panel = visible panel = self.query_one("#trace_panel", Vertical) panel.styles.display = "block" if visible else "none" - button = self.query_one("#chat_toggle_trace_btn", Button) - button.label = "Trace On" if visible else "Trace Off" + chip = self.query_one("#trace_summary_chip", Label) + chip.styles.display = "none" if visible else "block" + self._refresh_trace_chip() self.notify("Trace panel shown" if visible else "Trace panel hidden") + # Preserve composer focus: expanding the trace panel must never steal focus + # from the input unless the user explicitly clicked into the panel. + try: + self.query_one("#chat_input", ChatComposer).focus() + except Exception: + pass + + def _refresh_trace_chip(self) -> None: + try: + trace_log = self.query_one("#trace_log", VerticalScroll) + chip = self.query_one("#trace_summary_chip", Label) + except Exception: + return + count = len(trace_log.children) + last_stage = self._last_trace_stage + chip.update(f" trace Β· {count} events Β· {last_stage} " if last_stage else f" trace Β· {count} events ") def action_toggle_trace_panel(self) -> None: self._set_trace_panel_visible(not self.state.show_trace_panel) + def _toggle_agent_mode(self) -> None: + """Switch between build (full access) and plan (read-only) agents.""" + self.state.agent_mode = "plan" if self.state.agent_mode == "build" else "build" + self._refresh_meta() + self.notify(f"Agent β†’ {self.state.agent_mode}") + + def action_toggle_agent_mode(self) -> None: + self._toggle_agent_mode() + + def action_open_external_editor(self) -> None: + """Open the composer draft in $EDITOR and read it back (opencode Ctrl+E).""" + import subprocess + import tempfile + editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi" + try: + input_box = self.query_one("#chat_input", ChatComposer) + except Exception: + return + draft = input_box.value + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False, encoding="utf-8") as f: + f.write(draft) + path = f.name + try: + subprocess.call([editor, path]) + with open(path, encoding="utf-8") as f: + content = f.read() + input_box.value = content + input_box.focus() + self.notify("Editor content loaded into composer.") + except Exception as e: + self.notify(f"Could not open editor: {e}", severity="error") + finally: + try: + os.remove(path) + except Exception: + pass + def _copy_last_response(self) -> None: text = (self.state.last_agent_response or "").strip() if not text: @@ -745,7 +1330,7 @@ def _copy_last_response(self) -> None: except Exception: pass try: - input_box = self.query_one("#chat_input", Input) + input_box = self.query_one("#chat_input", ChatComposer) input_box.value = text[:10000] input_box.focus() self.notify("Clipboard unavailable; response inserted into input for manual copy.", severity="warning") @@ -758,51 +1343,148 @@ def action_copy_last_response(self) -> None: self._copy_last_response() @staticmethod - def _render_agent_markdown(timestamp: str, answer: str): - safe_answer = answer.strip() or "_No response content._" + def _theme_code_style(theme_name: str) -> str: + """Map a TUI theme to a Pygments code style for Rich Markdown.""" + mapping = { + "opencode": "github-dark", + "dracula": "dracula", + "nord": "nord", + "one_dark": "one-dark", + "omni_dark": "dracula", + "solarized_light": "solarized-light", + } + return mapping.get(theme_name, "default") + + def _render_user_markdown(self, timestamp: str, text: str): + safe = text.strip() or "_Empty message._" return Group( - Text(f"{timestamp} β€’ Motion", style="dim"), - RichMarkdown(safe_answer), + Text(f"you {timestamp}", style="dim"), + RichMarkdown(safe, code_theme=self._theme_code_style(self.app.theme)), ) - async def on_input_submitted(self, event: Input.Submitted) -> None: - text = event.value.strip() + def _render_agent_markdown(self, timestamp: str, answer: str): + safe_answer = answer.strip() or "_No response content._" + return RichMarkdown(safe_answer, code_theme=self._theme_code_style(self.app.theme)) + + def _agent_color(self) -> str: + """Return the agent-mode accent color as a CSS variable string. + + opencode uses blue for the build agent and orange for the plan agent. + """ + return "$secondary" if self.state.agent_mode == "build" else "$warning" + + def _refresh_meta(self) -> None: + """Update the composer meta row: agent Β· model Β· provider. + + Mirrors opencode's prompt footer which shows: + AgentName Β· modelName providerName + and uses the agent color as the left border highlight. + """ + try: + composer = self.query_one("#chat_input", ChatComposer) + except Exception: + return + agent = self.state.agent_mode + agent_label = agent.capitalize() + provider_id = self.state.current_provider_id or "" + model = self.state.agent.provider.config.name if self.state.agent else "?" + agent_color = self._agent_color() + # Resolve CSS variable name to a concrete hex color for Rich markup. + theme = self.app.get_theme(self.app.theme) + agent_hex = self._agent_hex(theme, agent_color) + # opencode-style meta: "Build Β· deepseek-v4-flash ollama-cloud" + meta_markup = ( + f"[{agent_hex} bold]{agent_label}[/] [dim]Β·[/] {model} [dim]{provider_id}[/]" + ) + composer.set_meta(meta_markup) + # Tint the left border of the composer with the agent color. + self._refresh_agent_color_accent() + self._refresh_status() + + @staticmethod + def _agent_hex(theme, agent_color: str) -> str: + """Resolve an agent-mode CSS variable name to a concrete hex color.""" + if agent_color == "$warning": + return theme.warning or theme.primary or "#f5a742" + if agent_color == "$secondary": + return theme.secondary or theme.primary or "#5c9cf5" + return theme.primary or "#fab283" + + def _refresh_agent_color_accent(self) -> None: + """Apply the current agent-mode accent color to the prompt border.""" + theme = self.app.get_theme(self.app.theme) + agent_color = self._agent_color() + hex_color = self._agent_hex(theme, agent_color) + composer = self.query_one("#chat_input", ChatComposer) + composer.styles.border_left = ("solid", hex_color) + + def _refresh_status(self) -> None: + """Update the status row below the composer (spinner + token/cost).""" + try: + status_text = self.query_one("#chat_status_text", Label) + spinner = self.query_one("#chat_status_spinner", LoadingIndicator) + except Exception: + return + m = self.state.last_turn_metrics or {} + s = self.state.session_metrics or {} + last_total = m.get("total_tokens_est", 0) + session_total = s.get("total_tokens_est", 0) + turns = s.get("turns", 0) + cost = s.get("estimated_cost_usd", 0.0) + if isinstance(cost, (int, float)) and cost > 0: + cost_color = "$warning" + cost_part = f" [dim]Β·[/] [{cost_color}]${cost:.4f}[/]" + else: + cost_part = "" + if self.state.busy: + spinner.set_class(True, "busy") + status_text.update( + f"[dim]Working…[/] " + f"[dim]turns[/] {turns} [dim]Β·[/] " + f"[dim]last[/] {last_total} tok [dim]Β·[/] " + f"[dim]session[/] {session_total} tok{cost_part}" + ) + else: + spinner.set_class(False, "busy") + status_text.update( + f"[dim]turns[/] {turns} [dim]Β·[/] " + f"[dim]last[/] {last_total} tok [dim]Β·[/] " + f"[dim]session[/] {session_total} tok{cost_part}" + ) + + async def on_composer_submitted(self, event: ComposerSubmitted) -> None: + await self._submit_composer(event.text) + + async def _submit_composer(self, text: str = "") -> None: + input_box = self.query_one("#chat_input", ChatComposer) + text = (text or input_box.value).strip() if not text: return - event.input.value = "" + input_box.value = "" + input_box.cursor_position = 0 + input_box.refresh() + input_box.focus() + self.state.record_prompt(text) log = self.query_one("#chat_log", VerticalScroll) if text.startswith("/skill"): + # Plan agent is read-only: no skill writes. + if self.state.agent_mode == "plan" and ("save" in text or "delete" in text): + log.mount(SystemMessage("β›” Plan agent is read-only β€” skill writes disabled.")) + log.scroll_end(animate=False) + return await self._handle_skill_command(text, log) log.scroll_end(animate=False) return ts = datetime.now().strftime("%H:%M:%S") - log.mount(UserMessage(f"[dim]{ts} β€’ You[/]\n{text}")) - thinking = SystemMessage("βš™οΈ Thinking…") - live_response = AgentMessage(f"[dim]{ts} β€’ Motion[/]\n") - log.mount(thinking) + user_msg = UserMessage("") + user_msg.update(self._render_user_markdown(ts, text)) + log.mount(user_msg) + live_response = AgentMessage("") log.mount(live_response) log.scroll_end(animate=False) - self._run_agent(text, thinking, live_response) + self._run_agent(text, live_response) - def _refresh_metrics_bar(self) -> None: - m = self.state.last_turn_metrics or {} - s = self.state.session_metrics or {} - provider = m.get("provider_type") or ( - getattr(self.state.agent.provider.config, "provider_type", "") if self.state.agent else "" - ) - last_total = m.get("total_tokens_est", 0) - session_total = s.get("total_tokens_est", 0) - cost = s.get("estimated_cost_usd", 0.0) - cost_text = "$0.00 local" if provider == "local" else f"${cost:.4f} est" - text = ( - f"Lastβ‰ˆ{last_total} tok Β· Sessionβ‰ˆ{session_total} tok Β· " - f"Turns={s.get('turns', 0)} Β· Spend={cost_text}" - ) - try: - self.query_one("#chat_metrics", Label).update(text) - except Exception: - pass def _append_trace(self, event_type: str, detail: str = "") -> None: trace_log = self.query_one("#trace_log", VerticalScroll) ts = datetime.now().strftime("%H:%M:%S") @@ -826,43 +1508,38 @@ def _append_trace(self, event_type: str, detail: str = "") -> None: line = f"[dim]{ts}[/] {label}" if safe_detail: line += f" [dim]Β· {safe_detail[:220]}[/]" + self._last_trace_stage = f"{label}" trace_log.mount(SystemMessage(line)) trace_log.scroll_end(animate=False) self.query_one("#trace_header", Label).update( f"Interaction Trace ({len(trace_log.children)})" ) + self._refresh_trace_chip() @work(exclusive=True, name="agent_chat") - async def _run_agent(self, prompt: str, thinking: SystemMessage, live_response: AgentMessage) -> None: + async def _run_agent(self, prompt: str, live_response: AgentMessage) -> None: log = self.query_one("#chat_log", VerticalScroll) + self.state.busy = True + self._refresh_status() chunks: list[str] = [] - first_chunk = True header_ts = datetime.now().strftime("%H:%M:%S") - header = f"[dim]{header_ts} β€’ Motion[/]\n" reasoning_widget: Optional[ReasoningMessage] = None current_raw = "" self._append_trace("interaction_start", prompt[:120]) async def on_stream_chunk(chunk: str) -> None: - nonlocal first_chunk if not chunk: return nonlocal reasoning_widget, current_raw chunks.append(chunk) current_raw = "".join(chunks) - if first_chunk: - first_chunk = False - try: - thinking.remove() - except Exception: - pass reasoning, answer = _extract_reasoning_and_answer(current_raw) if reasoning: if reasoning_widget is None: - reasoning_widget = ReasoningMessage("[dim]Reasoning stream[/]\n") + reasoning_widget = ReasoningMessage("") log.mount(reasoning_widget, before=live_response) - reasoning_widget.update(f"[dim]Reasoning[/]\n{reasoning[:2500]}") - live_response.update(header + (answer or "")) + reasoning_widget.update(Text(reasoning[:2500], style="dim italic")) + live_response.update(self._render_agent_markdown(header_ts, answer or "")) async def on_trace_event(*args) -> None: event_type = "trace" payload: Dict[str, Any] = {} @@ -882,21 +1559,29 @@ async def on_trace_event(*args) -> None: break self._append_trace(event_type, ", ".join(detail_parts)) try: + # Reference prior conversation so the model isn't left to guess: + # the context query pulls related memory AND the last turns keep + # the model grounded in what was already said. + history = [] + for p, r in (getattr(self.state, "conversation_turns", None) or []): + history.append({"role": "user", "content": p}) + if r: + history.append({"role": "assistant", "content": r}) + context_query = self.state.build_context_prompt() response = await self.state.agent.run( prompt, target="user", on_stream_chunk=on_stream_chunk, on_trace_event=on_trace_event, + history=history or None, + context_query=context_query or None, ) if not chunks: - try: - thinking.remove() - except Exception: - pass raw = response or "" reasoning, answer = _extract_reasoning_and_answer(raw) if reasoning: - reasoning_widget = ReasoningMessage(f"[dim]Reasoning[/]\n{reasoning[:2500]}") + reasoning_widget = ReasoningMessage("") + reasoning_widget.update(Text(reasoning[:2500], style="dim italic")) log.mount(reasoning_widget, before=live_response) live_response.update(self._render_agent_markdown(header_ts, answer or "")) self.state.last_agent_response = answer or "" @@ -922,60 +1607,25 @@ async def on_trace_event(*args) -> None: session["total_tokens_est"] += est_prompt_tokens + est_output_tokens if isinstance(est_cost_usd, (int, float)): session["estimated_cost_usd"] += float(est_cost_usd) - self._refresh_metrics_bar() + self._refresh_status() + # Record the turn for the context panel + rolling session context. + self.state.conversation_turns.append((prompt, self.state.last_agent_response)) + self.state.update_session_context(prompt, self.state.last_agent_response) main_screen = self.screen if isinstance(main_screen, MainScreen): main_screen.refresh_session_footer() + main_screen.refresh_context_panel() except asyncio.CancelledError: - try: - thinking.remove() - except Exception: - pass live_response.remove() log.mount(SystemMessage("⏹ Cancelled.")) self._append_trace("interaction_cancelled") except Exception as e: - try: - thinking.remove() - except Exception: - pass live_response.remove() log.mount(SystemMessage(f"❌ {e}")) self._append_trace("interaction_error", str(e)) finally: - log.scroll_end(animate=False) - - async def on_button_pressed(self, event: Button.Pressed) -> None: - bid = event.button.id - log = self.query_one("#chat_log", VerticalScroll) - if bid == "chat_save_skill_btn": - await self._handle_skill_command("/skill save skill_from_chat", log) - log.scroll_end(animate=False) - return - if bid == "chat_toggle_trace_btn": - self.action_toggle_trace_panel() - return - if bid == "chat_copy_btn": - self._copy_last_response() - return - if bid == "chat_stats_btn": - m = self.state.last_turn_metrics or {} - if not m: - self.notify("No interaction stats yet. Send a message first.", severity="warning") - return - cost = m.get("estimated_cost_usd") - cost_text = "$0.00 (local model)" if cost == 0.0 else "N/A" - msg = ( - f"πŸ“Š Last turn β€” promptβ‰ˆ{m.get('prompt_tokens_est', 0)} tok, " - f"outputβ‰ˆ{m.get('output_tokens_est', 0)} tok, " - f"totalβ‰ˆ{m.get('total_tokens_est', 0)} tok, costβ‰ˆ{cost_text}" - ) - log.mount(SystemMessage(msg)) - s = self.state.session_metrics - log.mount(SystemMessage( - f"πŸ“¦ Session β€” turns={s.get('turns', 0)}, totalβ‰ˆ{s.get('total_tokens_est', 0)} tok, " - f"costβ‰ˆ${s.get('estimated_cost_usd', 0.0):.4f}" - )) + self.state.busy = False + self._refresh_status() log.scroll_end(animate=False) async def _handle_skill_command(self, text: str, log: VerticalScroll) -> None: @@ -1028,7 +1678,7 @@ class TaskDetailScreen(Screen): #detail_box { width: 80; height: 85%; - border: round $primary; + border: round $border; background: $surface; padding: 1 2; overflow-y: auto; @@ -1036,14 +1686,14 @@ class TaskDetailScreen(Screen): } #detail_header { text-style: bold; - color: $primary; + color: $text; margin-bottom: 1; } #detail_back_btn { margin-right: 1; } .detail_section { - color: $primary; + color: $text-muted; text-style: bold; margin-top: 1; margin-bottom: 0; @@ -1185,14 +1835,14 @@ class TaskRow(Static): DEFAULT_CSS = """ TaskRow { - padding: 1 1; + padding: 1 1 1 2; margin: 0 0 1 0; - background: $background 30%; - border: round $border; + background: $surface; + border: blank; } TaskRow:hover { background: $primary 15%; - border: round $primary; + color: $text; } """ @@ -1232,19 +1882,21 @@ class TasksPane(Vertical): DEFAULT_CSS = """ TasksPane { height: 1fr; + padding: 1 1 0 1; } #tasks_container { height: 1fr; - border: round $border; + border: blank; padding: 1; background: $surface; } #tasks_header_row { height: auto; - margin-bottom: 0; + margin-bottom: 1; + padding: 0 1; } #tasks_header { - color: $primary; + color: $text-muted; text-style: bold; width: 1fr; } @@ -1255,13 +1907,19 @@ class TasksPane(Vertical): #task_list { height: 1fr; scrollbar-size: 1 1; + padding: 0 1; } #task_input_row { height: auto; - padding: 1 0 0 0; + padding: 1 1 1 1; + background: $panel; } #task_input { - border: round $border; + border: blank; + background: $background; + } + #task_input:focus { + border-bottom: solid $primary; } """ @@ -1272,7 +1930,7 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: with Vertical(id="tasks_container"): with Horizontal(id="tasks_header_row"): - yield Label("βš™οΈ Tasks", id="tasks_header") + yield Label("Tasks", id="tasks_header") yield Label("", id="tasks_count") yield VerticalScroll(id="task_list") with Horizontal(id="task_input_row"): @@ -1337,7 +1995,7 @@ def _update_header(self) -> None: running = sum(1 for t in s["tasks"].values() if t.status == "RUNNING") done = sum(1 for t in s["tasks"].values() if t.status in ("COMPLETED", "FAILED")) try: - self.query_one("#tasks_header", Label).update("βš™οΈ Tasks") + self.query_one("#tasks_header", Label).update("Tasks") self.query_one("#tasks_count", Label).update( f"{running} running Β· {done}/{total} done" if total else "no tasks yet" ) @@ -1367,39 +2025,57 @@ class SkillsPane(Container): CSS = """ #skills_container { height: 1fr; - border: round $primary; + border: blank; padding: 1; background: $surface; } #skills_header { - color: $primary; + color: $text-muted; text-style: bold; + margin-bottom: 1; + padding: 0 1; } #skills_list { height: 1fr; scrollbar-size: 1 1; + padding: 0 1; } #skills_search { height: auto; - padding: 0 0 1 0; + padding: 0 1 1 1; } #skills_search_input { - border: round $primary; + border: blank; + background: $background; + } + #skills_search_input:focus { + border-bottom: solid $primary; } #skills_editor_row { height: auto; - padding: 1 0 0 0; + padding: 1 1 0 1; + background: $panel; } #skills_title_input { margin-right: 1; + border: blank; + background: $background; + } + #skills_title_input:focus { + border-bottom: solid $primary; } #skills_content_input { - border: round $primary; + border: blank; + background: $background; margin-top: 1; } + #skills_content_input:focus { + border-bottom: solid $primary; + } #skills_status { color: $text-muted; margin-top: 1; + padding: 0 1; } .skill_entry { padding: 0 1; @@ -1413,7 +2089,7 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: with Vertical(id="skills_container"): - yield Label("πŸŽ“ Crystallized Skills", id="skills_header") + yield Label("Skills", id="skills_header") with Horizontal(id="skills_search"): yield Input(placeholder="Search skills…", id="skills_search_input") with Horizontal(id="skills_editor_row"): @@ -1428,7 +2104,6 @@ def compose(self) -> ComposeResult: yield VerticalScroll(id="skills_list") def on_mount(self) -> None: - self.query_one("#skills_container", Vertical).border_title = " Skills " self._load_skills() def _load_skills(self, query: str = "") -> None: @@ -1566,32 +2241,45 @@ class KBPane(Container): CSS = """ #kb_container { height: 1fr; - border: round $primary; + border: blank; padding: 1; background: $surface; } #kb_header { - color: $primary; + color: $text-muted; text-style: bold; + margin-bottom: 1; + padding: 0 1; } #kb_list { height: 1fr; scrollbar-size: 1 1; + padding: 0 1; } #kb_search { height: auto; - padding: 0 0 1 0; + padding: 0 1 1 1; } #kb_search_input { - border: round $primary; + border: blank; + background: $background; + } + #kb_search_input:focus { + border-bottom: solid $primary; } #kb_add_row { height: auto; - padding: 0 0 0 0; + padding: 1 1 0 1; + background: $panel; } #kb_add_title { height: 3; margin-right: 1; + border: blank; + background: $background; + } + #kb_add_title:focus { + border-bottom: solid $primary; } #kb_add_btn { margin-right: 1; @@ -1602,29 +2290,40 @@ class KBPane(Container): #kb_add_area { height: 5; margin-top: 1; - border: round $primary; + border: blank; + background: $background; + } + #kb_add_area:focus { + border-bottom: solid $primary; } #kb_mode_select { margin-top: 1; + padding: 0 1; } #kb_memory_search_row { height: auto; margin-top: 1; + padding: 0 1; } #kb_memory_search_input { - border: round $border; + border: blank; + background: $background; + } + #kb_memory_search_input:focus { + border-bottom: solid $primary; } #kb_memory_results { height: 8; scrollbar-size: 1 1; - border: round $border; - background: $background 25%; + border: blank; + background: $panel; padding: 0 1; margin-top: 1; } #kb_status { color: $text-muted; margin-top: 1; + padding: 0 1; } .kb_entry { padding: 0 1; @@ -1638,7 +2337,7 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: with Vertical(id="kb_container"): - yield Label("πŸ“š Knowledge Base", id="kb_header") + yield Label("Knowledge Base", id="kb_header") with Horizontal(id="kb_search"): yield Input(placeholder="Search knowledge base…", id="kb_search_input") yield VerticalScroll(id="kb_list") @@ -1660,7 +2359,6 @@ def compose(self) -> ComposeResult: yield Label("KB indexing mode controls whether entries are annexed into memory.", id="kb_status") def on_mount(self) -> None: - self.query_one("#kb_container", Vertical).border_title = " Knowledge Base " self._load_kb() def _load_kb(self, query: str = "") -> None: @@ -1819,28 +2517,36 @@ class MemoryPane(Container): CSS = """ #memory_container { height: 1fr; - border: round $primary; + border: blank; padding: 1; background: $surface; } #memory_results { height: 1fr; scrollbar-size: 1 1; + padding: 0 1; } #memory_search_row { height: auto; - padding: 0 0 1 0; + padding: 0 1 1 1; } #memory_search_input { - border: round $primary; + border: blank; + background: $background; + } + #memory_search_input:focus { + border-bottom: solid $primary; } #memory_header { - color: $primary; + color: $text-muted; text-style: bold; + margin-bottom: 1; + padding: 0 1; } .memory_entry { padding: 0 1; - margin: 0 0; + margin: 0 0 1 0; + border-top: blank; } """ @@ -1850,13 +2556,13 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: with Vertical(id="memory_container"): - yield Label("🧠 Memory Search", id="memory_header") + 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") def on_mount(self) -> None: - self.query_one("#memory_container", Vertical).border_title = " Memory " + pass async def on_input_submitted(self, event: Input.Submitted) -> None: query = event.value.strip() @@ -1900,46 +2606,48 @@ class SettingsPane(Container): CSS = """ #settings_container { height: 1fr; - border: round $primary; + border: blank; padding: 1 2; - background: $surface; + background: $background; scrollbar-size: 1 1; } + .settings_card { + height: auto; + padding: 1 2; + margin-top: 1; + background: $surface; + border: round $surface; + } .settings_label { text-style: bold; - color: $primary; - margin-top: 1; + color: $text; 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; + background: transparent; + border: blank; + color: $text-muted; } - #settings_provider { - color: $foreground; + .settings_btn:hover { + background: $panel; + color: $text; } - #settings_caveman { - color: $foreground; + .settings_btn:focus { + border-bottom: solid $primary; + color: $text; } - #settings_workers { + #provider_select, #theme_select, #ui_mode_select { + margin-top: 0; + margin-bottom: 0; + } + #settings_provider, #settings_caveman, #settings_synthesis, #settings_activity_rail, #settings_workers { color: $text-muted; } """ @@ -1950,52 +2658,55 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: with VerticalScroll(id="settings_container"): - yield Label("πŸ”§ Settings", classes="settings_label") - - yield Label("Provider / Model", classes="settings_label") - yield Label(" Switch the active model at runtime:", classes="settings_row") - options = AppState.build_provider_options() - 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") - 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") - - 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") - - yield Label("Interface Style", classes="settings_label") - yield Select( - [("Conservative", "conservative"), ("Experimental", "experimental")], - value=self.state.ui_mode, - id="ui_mode_select", - ) - - yield Label("Global Activity Rail", classes="settings_label") - rail_state = "ON" if self.state.show_activity_rail else "OFF" - yield Label(f" Status: {rail_state} (Ctrl+B)", id="settings_activity_rail") - yield Button("Toggle Activity Rail", id="btn_toggle_activity_rail", classes="settings_btn") - - 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") - - yield Label("Workspace", classes="settings_label") - yield Label(f" {WORKSPACE}", classes="settings_row") - - yield Label("Dashboard", classes="settings_label") - yield Button("Open Dashboard β†—", id="btn_dashboard", classes="settings_btn") + with Container(classes="settings_card"): + yield Label("Provider / Model", classes="settings_label") + yield Label("Switch the active model at runtime", classes="settings_row") + options = AppState.build_provider_options() + 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") + 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") + + with Container(classes="settings_card"): + yield Label("Theme", classes="settings_label") + yield Label("Select the visual theme", classes="settings_row") + 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("Cycle theme", id="btn_toggle_theme", classes="settings_btn") + + with Container(classes="settings_card"): + yield Label("Interface Style", classes="settings_label") + yield Label("Toggle experimental visual mode", classes="settings_row") + yield Select( + [("Conservative", "conservative"), ("Experimental", "experimental")], + value=self.state.ui_mode, + id="ui_mode_select", + ) - 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") + with Container(classes="settings_card"): + yield Label("Options", classes="settings_label") + yield Label("Workspace toggles and status", classes="settings_row") + rail_state = "ON" if self.state.show_activity_rail else "OFF" + yield Label(f"Activity rail: {rail_state} (Ctrl+B)", id="settings_activity_rail") + yield Button("Toggle rail", id="btn_toggle_activity_rail", classes="settings_btn") + cstatus = "ON" if self.state.caveman_enabled else "OFF" + yield Label(f"Caveman: {cstatus}", id="settings_caveman") + yield Button("Toggle caveman", id="btn_toggle_caveman", classes="settings_btn") + syn_status = "ON" if self.state.auto_synthesis_enabled else "OFF" + yield Label(f"Auto skill synthesis: {syn_status}", id="settings_synthesis") + yield Button("Toggle synthesis", id="btn_toggle_synthesis", classes="settings_btn") + + with Container(classes="settings_card"): + yield Label("Workspace", classes="settings_label") + yield Label(f"{WORKSPACE}", classes="settings_row") + yield Button("Open dashboard β†—", id="btn_dashboard", classes="settings_btn") + w = f"Max workers: {os.cpu_count() * 2} (CPU-aware)" if self.state.task_manager else "Task manager 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.""" @@ -2062,13 +2773,23 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: self.query_one("#settings_caveman", Label).update(f" Status: {s}") self.notify(f"Caveman: {s}") + elif bid == "btn_toggle_synthesis": + self.state.auto_synthesis_enabled = not self.state.auto_synthesis_enabled + if self.state.agent: + self.state.agent.auto_skill_synthesis = self.state.auto_synthesis_enabled + s = "ON" if self.state.auto_synthesis_enabled else "OFF" + self.query_one("#settings_synthesis", Label).update( + f" Status: {s} (crystallize skills from each reply)" + ) + self.notify(f"Auto Skill Synthesis: {s}") + elif bid == "btn_dashboard": webbrowser.open(f"{DASHBOARD_URL}?key={DASHBOARD_ADMIN_KEY}") self.notify("Opening dashboard in browser…") elif bid == "btn_toggle_activity_rail": try: if isinstance(self.app.screen, MainScreen): - self.app.screen.action_toggle_activity_rail() + self.app.screen.action_toggle_context_panel() rail_state = "ON" if self.state.show_activity_rail else "OFF" self.query_one("#settings_activity_rail", Label).update(f" Status: {rail_state} (Ctrl+B)") except Exception: @@ -2087,18 +2808,19 @@ class MotionTUI(App): CSS = """ Screen { background: $background; color: $foreground; } - Header { background: $surface; } - Footer { background: $surface; } - TabbedContent { height: 1fr; } - .experimental-ui TaskRow { - padding: 1 2; - margin: 0 0 1 0; - } - .experimental-ui #chat_log { - border: heavy $primary; + Header { + background: $background; + border-bottom: blank; + color: $text-muted; + text-style: bold; + padding: 0 1; } - .experimental-ui #tasks_container { - border: heavy $primary; + Header.-header-tall { height: 3; } + Footer { + background: $background; + border-top: blank; + color: $text-muted; + padding: 0 1; } """