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;
}
"""