From 06c5e43cbf17ee81e83b16f29a485fe63370e615 Mon Sep 17 00:00:00 2001 From: Mathitz Date: Tue, 14 Jul 2026 01:16:46 -0300 Subject: [PATCH 1/5] fix: CSS variable corrections, KB tab, and layout fixes --- .gitignore | 4 ++ ui/tui.py | 189 +++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 179 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index e381763..d35d10e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,9 @@ bin/ skills/ !skills/.gitkeep +# Knowledge base entries (user-generated) +knowledge/ +!knowledge/.gitkeep + # Test artifacts test_doc.md diff --git a/ui/tui.py b/ui/tui.py index fedabef..9977c7c 100644 --- a/ui/tui.py +++ b/ui/tui.py @@ -5,13 +5,14 @@ Screens: - ProviderSelect: Pick a provider/model at startup - - MainScreen: Tabbed hub (Chat, Tasks, Skills, Memory, Settings) + - MainScreen: Tabbed hub (Chat, Tasks, Skills, KB, Memory, Settings) Key features: - Themes cascade through every widget via Textual's ``$variable`` system - Ctrl+T cycles themes instantly - Settings tab has a SelectableDropdown for switching provider/model at runtime - Ctrl+C cancels the current request; Ctrl+Q quits + - KB tab: knowledge base for reference docs that don't become skills Launch: python main.py β†’ TUI (default) python main.py --chat β†’ old REPL @@ -53,6 +54,7 @@ from ui.themes import ThemeRegistry WORKSPACE = os.getenv("MOTION_WORKSPACE", os.getcwd()) +KB_DIR = os.path.join(WORKSPACE, "knowledge") DASHBOARD_URL = "https://localhost:7860/" DASHBOARD_ADMIN_KEY = "ME27dXc6uoEC_dWXJCyPVDPN" @@ -137,6 +139,17 @@ def __init__(self, text: str, sender: str = "user", **kwargs) -> None: super().__init__(formatted, classes=f"msg {sender}_msg", **kwargs) +class KBEntry(Static): + """A single knowledge base entry row.""" + + def __init__(self, title: str, preview: str, entry_type: str = "doc", **kwargs) -> None: + safe_title = title.replace("[", "\\[").replace("]", "\\]") + safe_preview = preview.replace("[", "\\[").replace("]", "\\]")[:120] + icon = {"doc": "πŸ“„", "url": "πŸ”—", "snippet": "βœ‚οΈ", "note": "πŸ“"}.get(entry_type, "πŸ“„") + display = f"{icon} [bold]{safe_title}[/]\n [dim]{safe_preview}[/]" + super().__init__(display, classes="kb_entry", **kwargs) + + class TaskRow(Static): """One row in the task panel.""" @@ -195,19 +208,19 @@ class ProviderSelectScreen(Screen): } #provider_subtitle { text-align: center; - color: $text-muted; + color: $secondary; margin-bottom: 1; } #provider_list { height: auto; max-height: 22; - border: solid $border; + border: solid $panel; padding: 0 1; - background: $background; + background: $surface; } #provider_status { text-align: center; - color: $text-muted; + color: $secondary; margin-top: 1; } """ @@ -293,6 +306,8 @@ def compose(self) -> ComposeResult: 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"): @@ -325,10 +340,11 @@ class ChatPane(Container): padding: 0 1; overflow-y: auto; scrollbar-size: 1 1; - background: $background; + background: $surface; } #chat_input_row { height: auto; + dock: bottom; padding: 1 0 0 0; } #chat_input { @@ -353,7 +369,7 @@ class ChatPane(Container): margin: 0 0; } .system_msg { - color: $text-muted; + color: $secondary; text-style: italic; padding: 0 1; margin: 0 0; @@ -418,7 +434,7 @@ class TasksPane(Container): border: round $primary; border-title: " Tasks "; padding: 1; - background: $background; + background: $surface; } #tasks_header { height: auto; @@ -511,7 +527,7 @@ class SkillsPane(Container): border: round $primary; border-title: " Skills "; padding: 1; - background: $background; + background: $surface; } #skills_header { color: $primary; @@ -576,6 +592,151 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: self._load_skills(query=event.value.strip().lower()) +# ─── Knowledge Base pane ────────────────────────────────────────────────────── + +class KBPane(Container): + """Browse and search the knowledge base (reference docs that aren't skills).""" + + CSS = """ + #kb_container { + height: 1fr; + border: round $primary; + border-title: " Knowledge Base "; + padding: 1; + background: $surface; + } + #kb_header { + color: $primary; + text-style: bold; + } + #kb_list { + height: 1fr; + scrollbar-size: 1 1; + } + #kb_search { + height: auto; + padding: 0 0 1 0; + } + #kb_search_input { + border: round $primary; + } + #kb_add_row { + height: auto; + padding: 0 0 0 0; + } + #kb_add_title { + height: 3; + margin-right: 1; + } + #kb_add_btn { + margin-right: 1; + } + #kb_add_area { + height: 5; + margin-top: 1; + border: round $primary; + } + .kb_entry { + padding: 0 1; + margin: 0 0; + } + """ + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state + + def compose(self) -> ComposeResult: + with Vertical(id="kb_container"): + 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") + with Horizontal(id="kb_add_row"): + yield Input(placeholder="Title for new entry…", id="kb_add_title") + yield Button("Add", id="kb_add_btn", classes="settings_btn") + yield Input(placeholder="Content or paste text…", id="kb_add_area") + + def on_mount(self) -> None: + self._load_kb() + + def _load_kb(self, query: str = "") -> None: + kb_dir = Path(KB_DIR) + kl = self.query_one("#kb_list", VerticalScroll) + for child in list(kl.children): + child.remove() + + if not kb_dir.exists(): + kl.mount(Static("[dim]No knowledge base entries yet. Use the form below to add one.[/]", classes="kb_entry")) + return + + md_files = sorted(kb_dir.glob("*.md")) + if query: + md_files = [f for f in md_files if query in f.stem.lower() or query in f.read_text(errors="replace").lower()] + + if not md_files: + kl.mount(Static(f"[dim]No entries matching '{query}'.[/]", classes="kb_entry")) + return + + for f in md_files: + name = f.stem.replace("_", " ").title() + sz = f.stat().st_size + mtime = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M") + entry_type = "note" + # Read first line for type hint + first_line = f.read_text(errors="replace").split("\n", 1)[0].lower() + if first_line.startswith("http"): + entry_type = "url" + elif first_line.startswith("#"): + entry_type = "doc" + kl.mount(KBEntry(name, f.read_text(errors="replace")[:200], entry_type=entry_type)) + + async def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "kb_search_input": + self._load_kb(query=event.value.strip().lower()) + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "kb_add_btn": + title_input = self.query_one("#kb_add_title", Input) + content_input = self.query_one("#kb_add_area", Input) + title = title_input.value.strip() + content = content_input.value.strip() + if not title: + self.notify("Enter a title for the KB entry", severity="warning") + return + if not content: + self.notify("Enter content for the KB entry", severity="warning") + return + + # Save to knowledge/ directory + kb_dir = Path(KB_DIR) + kb_dir.mkdir(parents=True, exist_ok=True) + safe_name = title.replace(" ", "_").lower() + # Remove any path separators or unsafe chars + safe_name = "".join(c for c in safe_name if c.isalnum() or c in "_-") + file_path = kb_dir / f"{safe_name}.md" + with open(file_path, "w", encoding="utf-8") as f: + f.write(f"# {title}\n\n{content}\n") + + # Also index into memory DB if agent is available + if self.state.agent: + from memory.db import MemoryChunk + try: + self.state.agent.memory.add_memory(MemoryChunk( + content=content, + embedding=[0.0] * 128, + metadata={"file": str(file_path), "type": "KB"}, + mem_type="DOC", + )) + except Exception: + pass + + title_input.value = "" + content_input.value = "" + self.notify(f"Added KB entry: {title}") + self._load_kb() + + # ─── Memory pane ────────────────────────────────────────────────────────────── class MemoryPane(Container): @@ -587,7 +748,7 @@ class MemoryPane(Container): border: round $primary; border-title: " Memory "; padding: 1; - background: $background; + background: $surface; } #memory_results { height: 1fr; @@ -666,7 +827,7 @@ class SettingsPane(Container): border: round $primary; border-title: " Settings "; padding: 1 2; - background: $background; + background: $surface; scrollbar-size: 1 1; } .settings_label { @@ -683,7 +844,7 @@ class SettingsPane(Container): .settings_row { margin-top: 0; height: auto; - color: $text-muted; + color: $secondary; } #provider_select { margin-top: 0; @@ -704,7 +865,7 @@ class SettingsPane(Container): color: $foreground; } #settings_workers { - color: $text-muted; + color: $secondary; } """ @@ -843,7 +1004,7 @@ class MotionTUI(App): .msg { margin: 0 0; padding: 0 1; } .user_msg { color: $primary; text-style: bold; } .agent_msg { color: $foreground; } - .system_msg { color: $text-muted; text-style: italic; } + .system_msg { color: $secondary; text-style: italic; } """ BINDINGS = [ From f50865b08d97b2f8b23bafa6d7b83c8d70f28b62 Mon Sep 17 00:00:00 2001 From: Mathitz Date: Tue, 14 Jul 2026 08:37:03 -0300 Subject: [PATCH 2/5] fix: revert $text-muted and $border changes (they were valid Textual vars) --- ui/tui.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ui/tui.py b/ui/tui.py index 9977c7c..2d33db0 100644 --- a/ui/tui.py +++ b/ui/tui.py @@ -208,19 +208,19 @@ class ProviderSelectScreen(Screen): } #provider_subtitle { text-align: center; - color: $secondary; + color: $text-muted; margin-bottom: 1; } #provider_list { height: auto; max-height: 22; - border: solid $panel; + border: solid $border; padding: 0 1; background: $surface; } #provider_status { text-align: center; - color: $secondary; + color: $text-muted; margin-top: 1; } """ @@ -369,7 +369,7 @@ class ChatPane(Container): margin: 0 0; } .system_msg { - color: $secondary; + color: $text-muted; text-style: italic; padding: 0 1; margin: 0 0; @@ -844,7 +844,7 @@ class SettingsPane(Container): .settings_row { margin-top: 0; height: auto; - color: $secondary; + color: $text-muted; } #provider_select { margin-top: 0; @@ -865,7 +865,7 @@ class SettingsPane(Container): color: $foreground; } #settings_workers { - color: $secondary; + color: $text-muted; } """ @@ -1004,7 +1004,7 @@ class MotionTUI(App): .msg { margin: 0 0; padding: 0 1; } .user_msg { color: $primary; text-style: bold; } .agent_msg { color: $foreground; } - .system_msg { color: $secondary; text-style: italic; } + .system_msg { color: $text-muted; text-style: italic; } """ BINDINGS = [ From ee1759c9699876160395a5287b8fab722bc4211c Mon Sep 17 00:00:00 2001 From: Mathitz Date: Sat, 18 Jul 2026 14:20:42 -0300 Subject: [PATCH 3/5] Polish TUI UX, traceability, and interaction flow --- .gitignore | 6 + core/orchestrator.py | 161 ++- core/providers.py | 128 +- main.py | 85 +- tests/test_task_manager_live_updates.py | 79 ++ ui/tui.py | 1462 ++++++++++++++++++++--- 6 files changed, 1701 insertions(+), 220 deletions(-) create mode 100644 tests/test_task_manager_live_updates.py diff --git a/.gitignore b/.gitignore index d35d10e..b028a62 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,9 @@ knowledge/ # Test artifacts test_doc.md + +# Logs +motion.log + +# Task artifacts +tasks/ diff --git a/core/orchestrator.py b/core/orchestrator.py index 9d5e6fb..50ebce9 100644 --- a/core/orchestrator.py +++ b/core/orchestrator.py @@ -1,9 +1,12 @@ import asyncio +import json import os import uuid -from typing import List, Dict, Any, Optional +import inspect +from typing import List, Dict, Any, Optional, Callable from dataclasses import dataclass, field from datetime import datetime +from pathlib import Path import logging from core.providers import ModelConfig, ProviderFactory @@ -27,6 +30,23 @@ class TaskStatus: error: Optional[str] = None start_time: Optional[datetime] = None end_time: Optional[datetime] = None + # Full conversation trace + conversation: List[Dict[str, str]] = field(default_factory=list) + # Timestamped log lines for progress + logs: List[str] = field(default_factory=list) + # Path to saved artifact if any + artifact_path: Optional[str] = None + + @property + def duration(self) -> Optional[str]: + """Human-readable duration string.""" + if self.start_time and self.end_time: + delta = (self.end_time - self.start_time).total_seconds() + if delta < 60: + return f"{delta:.1f}s" + minutes, secs = divmod(delta, 60) + return f"{int(minutes)}m {int(secs)}s" + return None class TaskManager: @@ -35,10 +55,12 @@ class TaskManager: Handles hardware-aware concurrency and per-task model routing. Uses asyncio.Event per task for efficient notification instead of polling. + Supports progress_callback for streaming status updates to the TUI. """ def __init__(self, default_model_config: ModelConfig, workspace_path: str): self.default_config = default_model_config self.workspace_path = workspace_path + self.tasks_dir = os.path.join(self.workspace_path, "tasks") # Hardware-aware concurrency: os.cpu_count() * 2 self.max_workers = (os.cpu_count() or 1) * 2 @@ -46,12 +68,23 @@ def __init__(self, default_model_config: ModelConfig, workspace_path: str): self.tasks: Dict[str, TaskStatus] = {} self._events: Dict[str, asyncio.Event] = {} + self._progress_callbacks: Dict[str, List[Callable]] = {} self.active_count = 0 - async def spawn_task(self, request: TaskRequest, model_override: Optional[ModelConfig] = None) -> str: + # Ensure tasks directory exists + os.makedirs(self.tasks_dir, exist_ok=True) + + async def spawn_task( + self, + request: TaskRequest, + model_override: Optional[ModelConfig] = None, + progress_callback: Optional[Callable] = None, + ) -> str: """ Queue a new task for execution. Returns the task_id. Callers can await wait_for_task(task_id) instead of polling. + + progress_callback: optional async callable(status: TaskStatus) for streaming updates. """ self.tasks[request.task_id] = TaskStatus( task_id=request.task_id, @@ -59,6 +92,8 @@ async def spawn_task(self, request: TaskRequest, model_override: Optional[ModelC status="PENDING" ) self._events[request.task_id] = asyncio.Event() + if progress_callback: + self._progress_callbacks.setdefault(request.task_id, []).append(progress_callback) # Schedule execution without blocking the main loop asyncio.create_task(self._execute_task(request, model_override)) @@ -76,29 +111,130 @@ async def wait_for_task(self, task_id: str, timeout: Optional[float] = None) -> pass return self.tasks[task_id] + async def _notify_progress(self, task_id: str) -> None: + """Call the progress callback if one exists.""" + callbacks = list(self._progress_callbacks.get(task_id, [])) + for cb in callbacks: + try: + result = cb(self.tasks[task_id]) + if inspect.isawaitable(result): + await result + except Exception: + pass + + def subscribe(self, task_id: str, callback: Callable) -> None: + """Subscribe a callback for live updates on a specific task.""" + self._progress_callbacks.setdefault(task_id, []) + if callback not in self._progress_callbacks[task_id]: + self._progress_callbacks[task_id].append(callback) + + def unsubscribe(self, task_id: str, callback: Callable) -> None: + """Unsubscribe a callback from a specific task.""" + callbacks = self._progress_callbacks.get(task_id, []) + if callback in callbacks: + callbacks.remove(callback) + + def _log(self, task_id: str, message: str) -> None: + """Append a timestamped log line to the task and notify.""" + ts = datetime.now().strftime("%H:%M:%S") + self.tasks[task_id].logs.append(f"[{ts}] {message}") + + def _save_artifact(self, task: TaskStatus) -> str: + """Save task conversation + result to tasks/{task_id}.md.""" + os.makedirs(self.tasks_dir, exist_ok=True) + path = os.path.join(self.tasks_dir, f"{task.task_id}.md") + lines = [ + f"# Task {task.task_id}", + f"", + f"**Status**: {task.status}", + f"**Started**: {task.start_time}", + f"**Completed**: {task.end_time}", + f"**Duration**: {task.duration}", + f"", + f"## Prompt", + f"", + task.prompt, + f"", + ] + if task.result: + lines += ["## Result", "", task.result, ""] + if task.error: + lines += ["## Error", "", task.error, ""] + if task.conversation: + lines += ["## Conversation", ""] + for turn in task.conversation: + role = turn.get("role", "?") + content = turn.get("content", "") + lines.append(f"**{role}**: {content}") + lines.append("") + if task.logs: + lines += ["## Log", ""] + task.logs + [""] + + with open(path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + return path + async def _execute_task(self, request: TaskRequest, model_override: Optional[ModelConfig] = None): + task = self.tasks[request.task_id] + if self.semaphore.locked(): - logger.info(f"Capacity reached: {self.active_count}/{self.max_workers} workers. Task {request.task_id} queued.") + self._log(request.task_id, f"Queued β€” {self.active_count}/{self.max_workers} workers busy") async with self.semaphore: self.active_count += 1 - self.tasks[request.task_id].status = "RUNNING" - self.tasks[request.task_id].start_time = datetime.now() + task.status = "RUNNING" + task.start_time = datetime.now() + self._log(request.task_id, f"Started") + await self._notify_progress(request.task_id) try: config = model_override or self.default_config agent = MotionAgent(config, memory_path=f"memory_{request.task_id}.db") - result = await agent.run(request.prompt, target="agent") + + # Record user turn + task.conversation.append({"role": "user", "content": request.prompt}) + task.conversation.append({"role": "agent", "content": ""}) + self._log(request.task_id, f"Running agent ({config.name})") + await self._notify_progress(request.task_id) + async def on_stream_chunk(chunk: str) -> None: + if not chunk: + return + task.result = (task.result or "") + chunk + if task.conversation and task.conversation[-1].get("role") == "agent": + task.conversation[-1]["content"] = task.result + await self._notify_progress(request.task_id) + + result = await agent.run( + request.prompt, + target="user", + on_stream_chunk=on_stream_chunk, + ) - self.tasks[request.task_id].result = result - self.tasks[request.task_id].status = "COMPLETED" + # Fallback for non-streaming providers + if result and not task.result: + task.result = result + if task.conversation and task.conversation[-1].get("role") == "agent": + task.conversation[-1]["content"] = result + task.status = "COMPLETED" + self._log(request.task_id, f"Completed ({len(task.result or '')} chars)") except Exception as e: - self.tasks[request.task_id].error = str(e) - self.tasks[request.task_id].status = "FAILED" + task.error = str(e) + task.status = "FAILED" + if task.conversation and task.conversation[-1].get("role") == "agent" and not task.conversation[-1].get("content"): + task.conversation.pop() + task.conversation.append({"role": "system", "content": f"Error: {e}"}) + self._log(request.task_id, f"Failed: {e}") finally: - self.tasks[request.task_id].end_time = datetime.now() + task.end_time = datetime.now() self.active_count -= 1 + # Save artifact + try: + task.artifact_path = self._save_artifact(task) + self._log(request.task_id, f"Saved to {task.artifact_path}") + except Exception as e: + self._log(request.task_id, f"Could not save artifact: {e}") self._events[request.task_id].set() + await self._notify_progress(request.task_id) def get_status(self) -> Dict[str, Any]: return { @@ -110,3 +246,6 @@ def get_status(self) -> Dict[str, Any]: def get_task_result(self, task_id: str) -> Optional[str]: task = self.tasks.get(task_id) return task.result if task else None + + def get_task(self, task_id: str) -> Optional[TaskStatus]: + return self.tasks.get(task_id) diff --git a/core/providers.py b/core/providers.py index 186d923..bbf6864 100644 --- a/core/providers.py +++ b/core/providers.py @@ -1,12 +1,14 @@ import os import logging from abc import ABC, abstractmethod -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, AsyncIterator from dataclasses import dataclass, field +import json import httpx logger = logging.getLogger(__name__) + @dataclass class ModelConfig: name: str @@ -28,6 +30,12 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: """Generate a completion from the model.""" pass + async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]: + """Optional streaming completion. Defaults to one-shot completion.""" + result = await self.complete(prompt, system_prompt=system_prompt, **kwargs) + if result: + yield result + async def close(self): await self._client.aclose() @@ -48,7 +56,6 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: payload["messages"].append({"role": "system", "content": system_prompt}) payload["messages"].append({"role": "user", "content": prompt}) - # Forward temperature / num_ctx from config options if "temperature" in self.config.options: payload["options"]["temperature"] = self.config.options["temperature"] if "num_ctx" in self.config.options: @@ -57,9 +64,39 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: resp = await self._client.post(url, json=payload) resp.raise_for_status() data = resp.json() - # Ollama returns {"message": {"content": "..."}} return data.get("message", {}).get("content", "") + async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]: + url = f"{self.config.endpoint.rstrip('/')}/api/chat" + model = self.config.options.get("model", self.config.name.lower()) + payload = { + "model": model, + "messages": [], + "stream": True, + "options": {}, + } + if system_prompt: + payload["messages"].append({"role": "system", "content": system_prompt}) + payload["messages"].append({"role": "user", "content": prompt}) + + if "temperature" in self.config.options: + payload["options"]["temperature"] = self.config.options["temperature"] + if "num_ctx" in self.config.options: + payload["options"]["num_ctx"] = self.config.options["num_ctx"] + + async with self._client.stream("POST", url, json=payload) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line: + continue + try: + data = json.loads(line) + except Exception: + continue + chunk = data.get("message", {}).get("content", "") + if chunk: + yield chunk + async def embed(self, text: str) -> List[float]: """Generate an embedding via Ollama /api/embeddings.""" url = f"{self.config.endpoint.rstrip('/')}/api/embeddings" @@ -76,15 +113,59 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str: endpoint = self.config.endpoint api_key = self.config.api_key or os.environ.get("OLLAMA_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY", "") - # Detect provider family from endpoint if "anthropic" in endpoint: return await self._anthropic_complete(prompt, system_prompt, api_key) elif "openai" in endpoint: return await self._openai_complete(prompt, system_prompt, api_key) else: - # Generic OpenAI-compatible endpoint return await self._openai_complete(prompt, system_prompt, api_key) + async def stream_complete(self, prompt: str, system_prompt: str = "", **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): + yield chunk + return + + 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) + temperature = self.config.options.get("temperature", 0.8) + headers = { + "Authorization": f"Bearer {api_key}", + "content-type": "application/json", + } + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + payload = { + "model": model, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": messages, + "stream": True, + } + + async with self._client.stream("POST", url, json=payload, headers=headers) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line or not line.startswith("data: "): + continue + data_str = line[6:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + except Exception: + continue + delta = data.get("choices", [{}])[0].get("delta", {}) + chunk = delta.get("content", "") + if chunk: + yield chunk + async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: str) -> str: url = "https://api.anthropic.com/v1/messages" model = self.config.options.get("model", "claude-3-5-sonnet-20241022") @@ -158,6 +239,41 @@ 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]: + url = f"{self.config.endpoint.rstrip('/')}/chat/completions" + api_key = self.config.api_key or os.environ.get("PROXY_API_KEY", "") + headers = { + "Authorization": f"Bearer {api_key}", + "content-type": "application/json", + } + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + payload = { + "model": self.config.options.get("model", "default"), + "messages": messages, + "temperature": self.config.options.get("temperature", 0.7), + "stream": True, + } + + async with self._client.stream("POST", url, json=payload, headers=headers) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line or not line.startswith("data: "): + continue + data_str = line[6:].strip() + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + except Exception: + continue + delta = data.get("choices", [{}])[0].get("delta", {}) + chunk = delta.get("content", "") + if chunk: + yield chunk + class ProviderFactory: """Factory to resolve the correct provider based on configuration.""" @@ -171,4 +287,4 @@ def get_provider(config: ModelConfig) -> BaseProvider: elif config.provider_type == "proxy": return ProxyProvider(config) else: - raise ValueError(f"Unsupported provider type: {config.provider_type}") + raise ValueError(f"Unsupported provider type: {config.provider_type}") \ No newline at end of file diff --git a/main.py b/main.py index 559be5d..e2f05f0 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ from memory.db import MemoryDB, EMBEDDING_DIM from memory.retriever import HybridRetriever import asyncio +import inspect import hashlib import logging import os @@ -13,12 +14,13 @@ logger = logging.getLogger(__name__) class MotionAgent: - def __init__(self, model_config: ModelConfig, memory_path: str = "motion_memory.db"): + def __init__(self, model_config: ModelConfig, memory_path: str = "motion_memory.db", auto_skill_synthesis: bool = False): self.provider = ProviderFactory.get_provider(model_config) self.memory = MemoryDB(memory_path) self.retriever = HybridRetriever(self.memory, self) self.caveman = CavemanProtocol(enabled=True) self.synthesizer = SkillSynthesizer(model_config, self.memory) + self.auto_skill_synthesis = auto_skill_synthesis async def get_embedding(self, text: str): """Generate embeddings using the provider's embedding endpoint. @@ -39,34 +41,81 @@ 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"): + async def run(self, prompt: str, target: str = "user", on_stream_chunk=None, on_trace_event=None): + async def emit_trace(stage: str, message: str, **extra): + if not on_trace_event: + return + payload = {"stage": stage, "message": message, **extra} + try: + try: + maybe = on_trace_event(stage, payload) + except TypeError: + maybe = on_trace_event(payload) + if inspect.isawaitable(maybe): + await maybe + except Exception: + pass # 1. Memory Recall + await emit_trace("memory_recall_start", "Running retriever.retrieve") context_chunks = await self.retriever.retrieve(prompt) + await emit_trace("memory_recall_done", "Memory recall complete", chunks=len(context_chunks)) context_text = "\n".join([c["content"] for c in context_chunks]) # 2. Construct System Prompt system_prompt = f"You are Motion Agent. Memory Context:\n{context_text}" - # 3. Model Completion - raw_response = await self.provider.complete(prompt, system_prompt=system_prompt) + # 3. Model Completion (streaming if callback is provided) + stream_chunk_count = 0 + await emit_trace( + "model_start", + "Calling provider for completion", + mode="stream" if on_stream_chunk else "oneshot", + provider=self.provider.config.provider_type, + ) + if on_stream_chunk: + raw_chunks = [] + async for chunk in self.provider.stream_complete(prompt, system_prompt=system_prompt): + 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 "")) + try: + maybe = on_stream_chunk(chunk) + if inspect.isawaitable(maybe): + await maybe + except Exception: + pass + 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) + await emit_trace("model_done", "One-shot completion finished", chars=len(raw_response or "")) # 4. Caveman Compression final_response = self.caveman.process_outgoing(raw_response, target=target) + await emit_trace("finalize", "Post-processing completed", chars=len(final_response or "")) - # 5. Skill Crystallization (on success) - try: - trajectory = Trajectory( - task_id="single", - prompt=prompt, - steps=[{"tool": "model", "input": prompt, "output": raw_response}], - final_result=raw_response, - success=True, - ) - skill_path = await self.synthesizer.synthesize(trajectory) - if skill_path: - logger.info(f"Skill crystallized: {skill_path}") - except Exception as e: - logger.debug(f"Skill synthesis skipped: {e}") + # 5. Skill Crystallization (manual-first: disabled by default) + if self.auto_skill_synthesis: + try: + await emit_trace("skill_synthesis_start", "Running skill synthesizer") + trajectory = Trajectory( + task_id="single", + prompt=prompt, + steps=[{"tool": "model", "input": prompt, "output": raw_response}], + final_result=raw_response, + success=True, + ) + skill_path = await self.synthesizer.synthesize(trajectory) + if skill_path: + logger.info(f"Skill crystallized: {skill_path}") + await emit_trace("skill_synthesis_done", "Skill synthesized", path=skill_path) + else: + await emit_trace("skill_synthesis_done", "Skill synthesis skipped") + 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 diff --git a/tests/test_task_manager_live_updates.py b/tests/test_task_manager_live_updates.py new file mode 100644 index 0000000..fd32361 --- /dev/null +++ b/tests/test_task_manager_live_updates.py @@ -0,0 +1,79 @@ +import asyncio +import os +import tempfile + +from core.orchestrator import TaskManager, TaskRequest +from core.providers import ModelConfig + + +class _FakeAgent: + async def run(self, prompt, target="user", on_stream_chunk=None): + for chunk in ("hello ", "world"): + if on_stream_chunk: + maybe = on_stream_chunk(chunk) + if asyncio.iscoroutine(maybe): + await maybe + return "hello world" + + +async def test_task_manager_streaming_callbacks_and_artifact_path(): + updates = [] + with tempfile.TemporaryDirectory() as workspace: + manager = TaskManager( + default_model_config=ModelConfig( + name="test-model", + endpoint="http://localhost:11434", + provider_type="local", + ), + workspace_path=workspace, + ) + + async def on_progress(status): + updates.append((status.status, bool(status.result), status.artifact_path)) + + + original_execute = manager._execute_task + + async def patched_execute_task(request, model_override=None): + task = manager.tasks[request.task_id] + task.status = "RUNNING" + task.start_time = __import__("datetime").datetime.now() + task.conversation.append({"role": "user", "content": request.prompt}) + task.conversation.append({"role": "agent", "content": ""}) + await manager._notify_progress(request.task_id) + + async def on_stream_chunk(chunk): + task.result = (task.result or "") + chunk + task.conversation[-1]["content"] = task.result + await manager._notify_progress(request.task_id) + + result = await _FakeAgent().run(request.prompt, on_stream_chunk=on_stream_chunk) + if result and not task.result: + task.result = result + task.status = "COMPLETED" + task.end_time = __import__("datetime").datetime.now() + task.artifact_path = manager._save_artifact(task) + manager._events[request.task_id].set() + await manager._notify_progress(request.task_id) + + manager._execute_task = patched_execute_task + request = TaskRequest(prompt="test prompt") + task_id = await manager.spawn_task( + request=request, + model_override=manager.default_config, + progress_callback=on_progress, + ) + final = await manager.wait_for_task(task_id, timeout=5) + manager._execute_task = original_execute + + assert final.status == "COMPLETED" + assert final.result == "hello world" + assert final.artifact_path is not None + assert final.artifact_path.startswith(os.path.join(workspace, "tasks") + os.sep) + assert os.path.exists(final.artifact_path) + assert any(status == "RUNNING" for status, _, _ in updates) + assert any(status == "COMPLETED" and has_result for status, has_result, _ in updates) + + +if __name__ == "__main__": + asyncio.run(test_task_manager_streaming_callbacks_and_artifact_path()) diff --git a/ui/tui.py b/ui/tui.py index 2d33db0..a951184 100644 --- a/ui/tui.py +++ b/ui/tui.py @@ -23,10 +23,14 @@ import asyncio import os +import re import webbrowser from datetime import datetime from pathlib import Path -from typing import Optional +from typing import Any, Dict, Optional +from rich.console import Group +from rich.markdown import Markdown as RichMarkdown +from rich.text import Text from textual import work from textual.app import App, ComposeResult @@ -48,7 +52,7 @@ ) from core.config import ConfigManager -from core.orchestrator import TaskManager, TaskRequest +from core.orchestrator import TaskManager, TaskRequest, TaskStatus from core.providers import ModelConfig from main import MotionAgent from ui.themes import ThemeRegistry @@ -59,6 +63,19 @@ DASHBOARD_ADMIN_KEY = "ME27dXc6uoEC_dWXJCyPVDPN" +def _suppress_logging() -> None: + """Redirect root logging to a file so it doesn't bleed into the TUI.""" + import logging + log_path = os.path.join(WORKSPACE, "motion.log") + handler = logging.FileHandler(log_path, mode="a", encoding="utf-8") + handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) + root = logging.getLogger() + # Remove any StreamHandler (e.g. basicConfig's stderr handler) + root.handlers = [h for h in root.handlers if not isinstance(h, logging.StreamHandler) or isinstance(h, logging.FileHandler)] + root.addHandler(handler) + root.setLevel(logging.INFO) + + # ─── Shared state ───────────────────────────────────────────────────────────── class AppState: @@ -71,6 +88,18 @@ def __init__(self) -> None: self.current_provider_id: str = "" self.current_theme: str = "one_dark" self.caveman_enabled: bool = True + self.ui_mode: str = "conservative" + self.show_activity_rail: bool = True + self.show_trace_panel: bool = True + self.last_agent_response: str = "" + self.last_turn_metrics: dict = {} + self.session_metrics: dict = { + "turns": 0, + "prompt_tokens_est": 0, + "output_tokens_est": 0, + "total_tokens_est": 0, + "estimated_cost_usd": 0.0, + } def reconnect(self, provider_id: str) -> None: """Re-create the agent and task manager for a new provider/model.""" @@ -98,6 +127,14 @@ def build_provider_options() -> list[tuple[str, str]]: Only includes providers that have a configured API key (or are local).""" cm = ConfigManager() providers = cm.list_providers() + providers_cfg = cm.get("providers", {}) or {} + def _provider_priority(pid: str) -> tuple[int, str]: + cfg = providers_cfg.get(pid, {}) + is_local = cfg.get("provider_type") == "local" + is_ollama = "ollama" in pid.lower() or "ollama" in str(cfg.get("endpoint", "")).lower() + # Lower tuple sorts first: local/ollama first, then alphabetic. + return (0 if (is_local or is_ollama) else 1, pid) + providers = sorted(providers, key=lambda p: _provider_priority(p[0])) options: list[tuple[str, str]] = [] for pid, name, models, is_default, has_key in providers: if not has_key: @@ -120,50 +157,83 @@ def build_all_provider_info() -> list[tuple[str, str, list, bool, bool]]: return cm.list_providers() -# ─── Small widgets ──────────────────────────────────────────────────────────── +def _slugify_name(name: str) -> str: + value = name.strip().lower().replace(" ", "_") + return re.sub(r"[^a-z0-9_-]+", "", value) -class ChatMessage(Static): - """A single message in the chat log with formatted sender labels.""" - PREFIX = {"user": "You", "agent": "Agent", "system": "⚑"} +def _skills_dir() -> Path: + return Path(WORKSPACE) / "skills" - def __init__(self, text: str, sender: str = "user", **kwargs) -> None: - safe = text.replace("[", "\\[").replace("]", "\\]") - prefix = self.PREFIX.get(sender, sender) - if sender == "user": - formatted = f"[bold cyan]{prefix}:[/] {safe}" - elif sender == "agent": - formatted = f"[bold green]{prefix}:[/] {safe}" - else: - formatted = f"[dim]{prefix}:[/] {safe}" - super().__init__(formatted, classes=f"msg {sender}_msg", **kwargs) +def _extract_reasoning_and_answer(text: str) -> tuple[str, str]: + """Extract ... blocks if present; return (reasoning, answer).""" + if "" not in text: + return "", text + reasoning_parts: list[str] = [] + answer = text + while "" in answer and "" in answer: + start = answer.find("") + end = answer.find("", start) + if end == -1: + break + chunk = answer[start + len(""):end].strip() + if chunk: + reasoning_parts.append(chunk) + answer = (answer[:start] + answer[end + len(""):]).strip() + return "\n\n".join(reasoning_parts).strip(), answer.strip() -class KBEntry(Static): - """A single knowledge base entry row.""" - def __init__(self, title: str, preview: str, entry_type: str = "doc", **kwargs) -> None: - safe_title = title.replace("[", "\\[").replace("]", "\\]") - safe_preview = preview.replace("[", "\\[").replace("]", "\\]")[:120] - icon = {"doc": "πŸ“„", "url": "πŸ”—", "snippet": "βœ‚οΈ", "note": "πŸ“"}.get(entry_type, "πŸ“„") - display = f"{icon} [bold]{safe_title}[/]\n [dim]{safe_preview}[/]" - super().__init__(display, classes="kb_entry", **kwargs) +# ─── Chat message widgets ───────────────────────────────────────────────────── +class UserMessage(Static): + """A user message bubble β€” primary accent, left border.""" + DEFAULT_CSS = """ + UserMessage { + background: $primary 12%; + border: round $primary; + padding: 1 2; + margin: 1 10 0 0; + color: $text; + } + """ -class TaskRow(Static): - """One row in the task panel.""" - ICON = {"PENDING": "⏳", "RUNNING": "βš™οΈ", "COMPLETED": "βœ…", "FAILED": "❌"} - COLOR = {"PENDING": "dim", "RUNNING": "bold yellow", "COMPLETED": "bold green", "FAILED": "bold red"} +class ReasoningMessage(Static): + """Collapsible-style block used to surface model reasoning stream.""" + DEFAULT_CSS = """ + ReasoningMessage { + background: $warning 10%; + border: round $warning; + padding: 1 2; + margin: 1 0 0 10; + color: $text-muted; + text-style: dim italic; + } + """ - def __init__(self, task_id: str, prompt: str, status: str, **kwargs) -> None: - self._task_id = task_id - self._prompt = prompt - self._status = status - icon = self.ICON.get(status, "?") - color = self.COLOR.get(status, "") - display = f"[dim]{task_id}[/] {icon} [{color}]{status}[/] [dim]{prompt[:50]}[/]" - super().__init__(display, id=f"task-{task_id}", **kwargs) +class AgentMessage(Static): + """An agent message bubble β€” success accent, left border.""" + DEFAULT_CSS = """ + AgentMessage { + background: $surface; + border: round $primary; + padding: 1 2; + margin: 1 0 0 10; + color: $text; + } + """ + +class SystemMessage(Static): + """A system/info message β€” muted, italic.""" + DEFAULT_CSS = """ + SystemMessage { + color: $text-muted; + text-style: dim; + padding: 0 1; + margin: 0 0 0 0; + } + """ class ProviderOption(ListItem): @@ -195,7 +265,6 @@ class ProviderSelectScreen(Screen): height: auto; max-height: 85%; border: round $primary; - border-title: " Motion Harness "; padding: 1 3; background: $surface; overflow-y: auto; @@ -243,6 +312,7 @@ def compose(self) -> ComposeResult: yield Label("↑↓ Navigate Β· Enter Select Β· Q Quit", id="provider_status") def on_mount(self) -> None: + self.query_one("#provider_box", Container).border_title = " Motion Harness " providers = self.state.config_manager.list_providers() lv = self.query_one("#provider_list", ListView) for pid, name, models, is_default, has_key in providers: @@ -276,21 +346,139 @@ def action_select(self) -> None: self.app.switch_screen(MainScreen(self.state)) -# ─── Main hub screen (tabbed) ──────────────────────────────────────────────── +# ─── Global activity rail + main hub ───────────────────────────────────────── + +class ActivityRail(Vertical): + """Right-side global activity rail for live task visibility.""" + + DEFAULT_CSS = """ + ActivityRail { + width: 36; + min-width: 30; + max-width: 42; + border-left: heavy $border; + padding: 1 1; + background: $surface; + } + #activity_header { + color: $accent; + text-style: bold; + margin-bottom: 1; + } + #activity_list { + height: 1fr; + scrollbar-size: 1 1; + } + .activity_row { + padding: 0 1; + margin: 0 0 1 0; + color: $text; + background: $background 20%; + border: round $border; + } + """ + + def __init__(self, state: AppState, **kwargs) -> None: + super().__init__(**kwargs) + self.state = state + + def compose(self) -> ComposeResult: + yield Label("⚑ Live Activity", id="activity_header") + yield VerticalScroll(id="activity_list") + + def on_mount(self) -> None: + self.set_interval(0.5, self.refresh_activity) + self.refresh_activity() + + def refresh_activity(self) -> None: + task_manager = self.state.task_manager + if not task_manager: + return + + 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") + + 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")) + return + + 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")) + class MainScreen(Screen): """The main hub with Chat, Tasks, Skills, Memory, Settings tabs.""" 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: $surface; + border: round $border; + } + .tab_nav_btn { + margin-right: 1; + min-width: 11; + height: 3; + } #main_tabs { height: 1fr; } + #session_metrics_footer { + height: auto; + color: $text-muted; + background: $background; + border-top: solid $border; + padding: 0 2; + } TabbedContent TabPane { - padding: 0 1; + padding: 0 0; } """ BINDINGS = [ - Binding("ctrl+t", "toggle_theme", "Theme"), - Binding("ctrl+q", "quit", "Quit"), + 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+q", "quit", "Quit", priority=True), ] def __init__(self, state: AppState, **kwargs) -> None: @@ -299,80 +487,201 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: yield Header(show_clock=True) - with TabbedContent(id="main_tabs"): - with TabPane("πŸ’¬ Chat", id="chat_tab"): - yield ChatPane(self.state) - with TabPane("βš™οΈ Tasks", id="tasks_tab"): - yield TasksPane(self.state) - with TabPane("πŸŽ“ Skills", id="skills_tab"): - yield SkillsPane(self.state) - with TabPane("πŸ“š 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) + 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 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.refresh_session_footer() + + def refresh_session_footer(self) -> None: + s = self.state.session_metrics or {} + provider_hint = self.state.current_provider_id or "unknown" + text = ( + f"Session Β· turns={s.get('turns', 0)} Β· " + f"promptβ‰ˆ{s.get('prompt_tokens_est', 0)} tok Β· " + f"outputβ‰ˆ{s.get('output_tokens_est', 0)} tok Β· " + f"totalβ‰ˆ{s.get('total_tokens_est', 0)} tok Β· " + f"costβ‰ˆ${s.get('estimated_cost_usd', 0.0):.4f} Β· " + f"provider={provider_hint}" + ) + try: + self.query_one("#session_metrics_footer", Label).update(text) + except Exception: + pass + def action_toggle_theme(self) -> None: themes = ThemeRegistry.theme_ids() idx = themes.index(self.state.current_theme) self.state.current_theme = themes[(idx + 1) % len(themes)] - # Use Textual's native theme system β€” cascades through ALL CSS vars self.app.theme = self.state.current_theme self.notify(f"Theme β†’ {self.state.current_theme}") + def action_toggle_activity_rail(self) -> None: + rail = self.query_one("#activity_rail", ActivityRail) + 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 + + 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)]) + # ─── Chat pane ──────────────────────────────────────────────────────────────── -class ChatPane(Container): +class ChatPane(Vertical): """Message history + input bar.""" + BINDINGS = [ + Binding("ctrl+shift+t", "toggle_trace_panel", "Trace", priority=True), + 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), + ] - CSS = """ + DEFAULT_CSS = """ ChatPane { - layout: vertical; height: 1fr; } + #chat_body { + height: 1fr; + padding: 0 0 1 0; + } #chat_log { height: 1fr; + width: 3fr; border: round $primary; - border-title: " Chat "; - padding: 0 1; - overflow-y: auto; + padding: 1 2; scrollbar-size: 1 1; + background: $background 15%; + } + #trace_panel { + width: 42; + height: 1fr; + border: round $accent; background: $surface; + padding: 1 1; + margin-left: 1; + } + #trace_header { + color: $accent; + text-style: bold; + margin-bottom: 0; + } + #trace_log { + height: 1fr; + scrollbar-size: 1 1; + border-top: solid $border; + padding-top: 1; } #chat_input_row { height: auto; - dock: bottom; - padding: 1 0 0 0; + padding: 1 1 1 1; + background: $surface; + border: round $border; + } + #chat_controls { + height: auto; + width: auto; + padding: 0 0 0 1; + border-left: solid $border; + margin-left: 1; + } + #chat_primary_actions, #chat_secondary_actions { + height: auto; + width: auto; + } + #chat_secondary_actions { + margin-left: 1; + } + #chat_metrics { + color: $text; + margin-top: 0; + margin-bottom: 1; + padding: 0 2; } #chat_input { height: 3; border: round $primary; - } - .msg { - margin: 0 0; - padding: 0 1; - } - .user_msg { - color: $primary; - text-style: bold; background: $surface; - padding: 0 1; - margin: 0 0; + width: 1fr; } - .agent_msg { - color: $foreground; - background: transparent; - padding: 0 1; - margin: 0 0; - } - .system_msg { - color: $text-muted; - text-style: italic; - padding: 0 1; - margin: 0 0; + .chat_btn { + margin-left: 1; + min-width: 10; } """ @@ -381,17 +690,78 @@ def __init__(self, state: AppState, **kwargs) -> None: self.state = state def compose(self) -> ComposeResult: - yield VerticalScroll(id="chat_log") + with Horizontal(id="chat_body"): + yield VerticalScroll(id="chat_log") + 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") 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(ChatMessage(f"⚑ Motion Harness β€” connected to {name}", sender="system")) + 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 ")) + 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() + 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" + self.notify("Trace panel shown" if visible else "Trace panel hidden") + + def action_toggle_trace_panel(self) -> None: + self._set_trace_panel_visible(not self.state.show_trace_panel) + + def _copy_last_response(self) -> None: + text = (self.state.last_agent_response or "").strip() + if not text: + self.notify("No assistant response to copy yet.", severity="warning") + return + copy_fn = getattr(self.app, "copy_to_clipboard", None) + if callable(copy_fn): + try: + copy_fn(text) + self.notify("Copied last response to clipboard.") + return + except Exception: + pass + try: + input_box = self.query_one("#chat_input", Input) + input_box.value = text[:10000] + input_box.focus() + self.notify("Clipboard unavailable; response inserted into input for manual copy.", severity="warning") + return + except Exception: + pass + self.notify("Clipboard copy unavailable in this terminal.", severity="warning") + + 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._" + return Group( + Text(f"{timestamp} β€’ Motion", style="dim"), + RichMarkdown(safe_answer), + ) + async def on_input_submitted(self, event: Input.Submitted) -> None: text = event.value.strip() if not text: @@ -399,48 +769,485 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: event.input.value = "" log = self.query_one("#chat_log", VerticalScroll) - log.mount(ChatMessage(text, sender="user")) - thinking = ChatMessage("βš™οΈ Thinking…", sender="system") + if text.startswith("/skill"): + 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) + log.mount(live_response) log.scroll_end(animate=False) + self._run_agent(text, thinking, live_response) - self._run_agent(text, thinking) + 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") + label_map = { + "memory_recall_start": "🧠 memory.recall.start", + "memory_recall_done": "🧠 memory.recall.done", + "model_start": "πŸ€– model.start", + "stream_chunk": "🌊 stream.chunk", + "model_done": "πŸ€– model.done", + "finalize": "βœ… finalize", + "skill_synthesis_start": "πŸŽ“ skill.synthesis.start", + "skill_synthesis_done": "πŸŽ“ skill.synthesis.done", + "skill_synthesis_error": "πŸŽ“ skill.synthesis.error", + "session_start": "⚑ session.start", + "interaction_start": "β–Ά interaction.start", + "interaction_error": "❌ interaction.error", + "interaction_cancelled": "⏹ interaction.cancelled", + } + label = label_map.get(event_type, event_type) + safe_detail = (detail or "").replace("[", "\\[").replace("]", "\\]") + line = f"[dim]{ts}[/] {label}" + if safe_detail: + line += f" [dim]Β· {safe_detail[:220]}[/]" + 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)})" + ) @work(exclusive=True, name="agent_chat") - async def _run_agent(self, prompt: str, thinking: ChatMessage) -> None: + async def _run_agent(self, prompt: str, thinking: SystemMessage, live_response: AgentMessage) -> None: log = self.query_one("#chat_log", VerticalScroll) + 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") + log.mount(reasoning_widget, before=live_response) + reasoning_widget.update(f"[dim]Reasoning[/]\n{reasoning[:2500]}") + live_response.update(header + (answer or "")) + async def on_trace_event(*args) -> None: + event_type = "trace" + payload: Dict[str, Any] = {} + if len(args) == 2: + event_type = str(args[0]) + payload = args[1] if isinstance(args[1], dict) else {} + elif len(args) == 1 and isinstance(args[0], dict): + payload = args[0] + event_type = str(payload.get("stage") or payload.get("event") or "trace") + detail_parts: list[str] = [] + if isinstance(payload, dict): + for key in ("query", "target", "provider", "model", "task_id", "status"): + value = payload.get(key) + if value is not None and value != "": + detail_parts.append(f"{key}={value}") + if len(detail_parts) >= 2: + break + self._append_trace(event_type, ", ".join(detail_parts)) try: - response = await self.state.agent.run(prompt, target="user") - thinking.remove() - log.mount(ChatMessage(response, sender="agent")) + response = await self.state.agent.run( + prompt, + target="user", + on_stream_chunk=on_stream_chunk, + on_trace_event=on_trace_event, + ) + 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]}") + 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 "" + else: + reasoning, answer = _extract_reasoning_and_answer("".join(chunks)) + self.state.last_agent_response = answer or "" + live_response.update(self._render_agent_markdown(header_ts, answer or "")) + est_prompt_tokens = max(1, len(prompt) // 4) + est_output_tokens = max(1, len(self.state.last_agent_response or "") // 4) + provider_type = getattr(self.state.agent.provider.config, "provider_type", "") + est_cost_usd = 0.0 if provider_type == "local" else None + self.state.last_turn_metrics = { + "prompt_tokens_est": est_prompt_tokens, + "output_tokens_est": est_output_tokens, + "total_tokens_est": est_prompt_tokens + est_output_tokens, + "estimated_cost_usd": est_cost_usd, + "provider_type": provider_type, + } + session = self.state.session_metrics + session["turns"] += 1 + session["prompt_tokens_est"] += est_prompt_tokens + session["output_tokens_est"] += est_output_tokens + 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() + main_screen = self.screen + if isinstance(main_screen, MainScreen): + main_screen.refresh_session_footer() except asyncio.CancelledError: - thinking.remove() - log.mount(ChatMessage("⏹ Cancelled.", sender="system")) + try: + thinking.remove() + except Exception: + pass + live_response.remove() + log.mount(SystemMessage("⏹ Cancelled.")) + self._append_trace("interaction_cancelled") except Exception as e: - thinking.remove() - log.mount(ChatMessage(f"❌ {e}", sender="system")) + 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}" + )) + log.scroll_end(animate=False) + + async def _handle_skill_command(self, text: str, log: VerticalScroll) -> None: + parts = text.split(maxsplit=2) + if len(parts) < 2: + log.mount(SystemMessage("Usage: /skill save or /skill delete ")) + return + action = parts[1].strip().lower() + if action not in {"save", "delete"}: + log.mount(SystemMessage("Unknown /skill action. Use save or delete.")) + return + if len(parts) < 3 or not parts[2].strip(): + log.mount(SystemMessage("Provide a skill name, e.g. /skill save refactor_parser")) + return + + skill_name = _slugify_name(parts[2]) + if not skill_name: + log.mount(SystemMessage("Skill name can only include letters, numbers, '-' and '_'")) + return + skills_dir = _skills_dir() + skills_dir.mkdir(parents=True, exist_ok=True) + skill_path = skills_dir / f"{skill_name}.md" + + if action == "delete": + if skill_path.exists(): + skill_path.unlink() + log.mount(SystemMessage(f"πŸ—‘ Deleted skill: {skill_name}")) + else: + log.mount(SystemMessage(f"Skill not found: {skill_name}")) + return + + content = self.state.last_agent_response.strip() + if not content: + log.mount(SystemMessage("No recent agent reply to save yet. Ask something first, then run /skill save .")) + return + with open(skill_path, "w", encoding="utf-8") as f: + f.write(f"# {parts[2].strip()}\n\n{content}\n") + log.mount(SystemMessage(f"βœ… Saved skill from last reply: {skill_name}")) + + +# ─── Task detail screen ────────────────────────────────────────────────────── + +class TaskDetailScreen(Screen): + """Push-screen overlay showing full task detail.""" + + DEFAULT_CSS = """ + TaskDetailScreen { + align: center middle; + } + #detail_box { + width: 80; + height: 85%; + border: round $primary; + background: $surface; + padding: 1 2; + overflow-y: auto; + scrollbar-size: 1 1; + } + #detail_header { + text-style: bold; + color: $primary; + margin-bottom: 1; + } + #detail_back_btn { + margin-right: 1; + } + .detail_section { + color: $primary; + text-style: bold; + margin-top: 1; + margin-bottom: 0; + } + .detail_prompt { + color: $foreground; + background: $surface; + padding: 0 1; + margin: 0 0; + } + .detail_result { + color: $foreground; + padding: 0 1; + margin: 0 0; + } + .detail_error { + color: red; + text-style: bold; + padding: 0 1; + margin: 0 0; + } + .detail_meta { + color: $text-muted; + padding: 0 1; + margin: 0 0; + } + .detail_log_line { + color: $text-muted; + padding: 0 1; + margin: 0 0; + } + """ + + BINDINGS = [ + Binding("escape", "pop_screen", "Back"), + ] + + def __init__(self, task_id: str, **kwargs) -> None: + super().__init__(**kwargs) + self._task_id = task_id + self._task_manager: Optional[TaskManager] = None + + def compose(self) -> ComposeResult: + with Vertical(id="detail_box"): + yield Button("← Back", id="detail_back_btn", classes="settings_btn") + yield Static("", id="detail_header") + yield Static("", classes="detail_meta") + + def _render_task(self, task: TaskStatus) -> None: + status_icon = TaskRow.ICON.get(task.status, "?") + status_color = TaskRow.COLOR.get(task.status, "") + header = f"{status_icon} [{status_color}]{task.status}[/] β€” {self._task_id}" + self.query_one("#detail_header", Static).update(header) + + meta = "" + if task.start_time: + meta += f"Started {task.start_time.strftime('%H:%M:%S')}" + if task.duration: + meta += f" Β· Duration: {task.duration}" + if task.artifact_path: + safe_path = task.artifact_path.replace("[", "\\[").replace("]", "\\]") + meta += f"\nπŸ“„ {safe_path}" + self.query_one(".detail_meta", Static).update(meta) + + box = self.query_one("#detail_box", Vertical) + fixed_ids = {"detail_back_btn", "detail_header"} + for child in list(box.children): + if child.id in fixed_ids or "detail_meta" in child.classes: + continue + child.remove() + + box.mount(Label("Prompt", classes="detail_section")) + safe_prompt = task.prompt.replace("[", "\\[").replace("]", "\\]") + box.mount(Static(safe_prompt, classes="detail_prompt")) + + if task.result: + box.mount(Label("Result", classes="detail_section")) + safe_result = task.result.replace("[", "\\[").replace("]", "\\]") + if len(safe_result) > 3000: + safe_result = safe_result[:3000] + "…" + box.mount(Static(safe_result, classes="detail_result")) + + if task.error: + box.mount(Label("Error", classes="detail_section")) + safe_error = task.error.replace("[", "\\[").replace("]", "\\]") + box.mount(Static(safe_error, classes="detail_error")) + + if task.conversation: + box.mount(Label("Conversation", classes="detail_section")) + for turn in task.conversation: + role = turn.get("role", "?") + content = turn.get("content", "").replace("[", "\\[").replace("]", "\\]") + role_icon = {"user": "πŸ‘€", "agent": "πŸ€–", "system": "⚑"}.get(role, "β€’") + box.mount(Static(f"{role_icon} [{status_color if role == 'agent' else 'cyan'}]{role.title()}[/]: {content[:2000]}", classes="detail_result")) + + if task.logs: + box.mount(Label("Log", classes="detail_section")) + for line in task.logs: + safe_line = line.replace("[", "\\[").replace("]", "\\]") + box.mount(Static(safe_line, classes="detail_log_line")) + + async def _on_task_progress(self, status: TaskStatus) -> None: + if status.task_id != self._task_id: + return + self._render_task(status) -# ─── Tasks pane ─────────────────────────────────────────────────────────────── + def on_mount(self) -> None: + self.query_one("#detail_box", Vertical).border_title = " Task Detail " + state = None + app = self.app + if isinstance(app, MotionTUI) and hasattr(app, "state"): + state = app.state + if not state or not state.task_manager: + self.query_one("#detail_header", Static).update(f"Task {self._task_id}") + return + + self._task_manager = state.task_manager + self._task_manager.subscribe(self._task_id, self._on_task_progress) + task = self._task_manager.get_task(self._task_id) + if not task: + self.query_one("#detail_header", Static).update(f"Task {self._task_id} β€” not found") + return + self._render_task(task) + + def on_unmount(self) -> None: + if self._task_manager: + self._task_manager.unsubscribe(self._task_id, self._on_task_progress) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "detail_back_btn": + self.app.pop_screen() + + def action_pop_screen(self) -> None: + self.app.pop_screen() + + +class TaskRow(Static): + """A clickable task row in the task list.""" + + DEFAULT_CSS = """ + TaskRow { + padding: 1 1; + margin: 0 0 1 0; + background: $background 30%; + border: round $border; + } + TaskRow:hover { + background: $primary 15%; + border: round $primary; + } + """ + + ICON = {"PENDING": "⏳", "RUNNING": "βš™οΈ", "COMPLETED": "βœ…", "FAILED": "❌"} + COLOR = {"PENDING": "dim", "RUNNING": "bold yellow", "COMPLETED": "bold green", "FAILED": "bold red"} -class TasksPane(Container): + def __init__(self, task_id: str, prompt: str, status: str, **kwargs) -> None: + self._task_id = task_id + self._prompt = prompt + self._status = status + icon = self.ICON.get(status, "?") + color = self.COLOR.get(status, "") + display = f"{icon} [{color}]{status}[/] [dim]{task_id}[/]\n {self._truncate(prompt, 80)}" + super().__init__(display, id=f"task-{task_id}", **kwargs) + + @staticmethod + def _truncate(text: str, max_len: int) -> str: + safe = text.replace("[", "\\[").replace("]", "\\]") + return safe[:max_len] + "…" if len(safe) > max_len else safe + + def update_status(self, status: str, prompt: str | None = None) -> None: + if prompt: + self._prompt = prompt + self._status = status + icon = self.ICON.get(status, "?") + color = self.COLOR.get(status, "") + display = f"{icon} [{color}]{status}[/] [dim]{self._task_id}[/]\n {self._truncate(self._prompt, 80)}" + self.update(display) + + def on_click(self) -> None: + self.app.push_screen(TaskDetailScreen(self._task_id)) + + +class TasksPane(Vertical): """Live task orchestration dashboard.""" - CSS = """ + DEFAULT_CSS = """ + TasksPane { + height: 1fr; + } #tasks_container { height: 1fr; - border: round $primary; - border-title: " Tasks "; + border: round $border; padding: 1; background: $surface; } - #tasks_header { + #tasks_header_row { height: auto; - margin-bottom: 1; + margin-bottom: 0; + } + #tasks_header { color: $primary; text-style: bold; + width: 1fr; + } + #tasks_count { + color: $text-muted; + width: auto; } #task_list { height: 1fr; @@ -451,7 +1258,7 @@ class TasksPane(Container): padding: 1 0 0 0; } #task_input { - border: round $primary; + border: round $border; } """ @@ -461,7 +1268,9 @@ def __init__(self, state: AppState, **kwargs) -> None: def compose(self) -> ComposeResult: with Vertical(id="tasks_container"): - yield Label("βš™οΈ Task Orchestrator", id="tasks_header") + with Horizontal(id="tasks_header_row"): + yield Label("βš™οΈ Tasks", id="tasks_header") + yield Label("", id="tasks_count") yield VerticalScroll(id="task_list") with Horizontal(id="task_input_row"): yield Input(placeholder="Spawn a new task… (Enter to submit)", id="task_input") @@ -481,26 +1290,39 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: return request = TaskRequest(prompt=prompt) - task_id = await tm.spawn_task(request) - + task_id = await tm.spawn_task(request, progress_callback=self._on_task_progress) tl = self.query_one("#task_list", VerticalScroll) tl.mount(TaskRow(task_id, prompt, "PENDING")) self.notify(f"Task {task_id} spawned") + self._update_header() self._wait_for_task(task_id, prompt) + def _on_task_progress(self, status: 'TaskStatus') -> None: + """Called by TaskManager on status transitions.""" + try: + row = self.query_one(f"#task-{status.task_id}", TaskRow) + row.update_status(status.status) + except Exception: + pass + self._update_header() + @work(exclusive=False, name="task_wait") async def _wait_for_task(self, task_id: str, prompt: str) -> None: tm = self.state.task_manager if not tm: return status = await tm.wait_for_task(task_id) + # Update the TaskRow with final status (pane may not be mounted if user switched tabs) try: - self.query_one(f"#task-{task_id}", TaskRow).remove() + existing = self.query_one(f"#task-{task_id}", TaskRow) + existing.update_status(status.status, prompt) except Exception: - pass - self.query_one("#task_list", VerticalScroll).mount( - TaskRow(task_id, prompt, status.status) - ) + try: + self.query_one("#task_list", VerticalScroll).mount( + TaskRow(task_id, prompt, status.status) + ) + except Exception: + pass self._update_header() def _update_header(self) -> None: @@ -508,9 +1330,13 @@ def _update_header(self) -> None: if not tm: return s = tm.get_status() + total = len(s["tasks"]) + 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( - f"βš™οΈ Task Orchestrator β€” {s['active_workers']}/{s['max_workers']} workers" + 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" ) except Exception: pass @@ -518,6 +1344,20 @@ def _update_header(self) -> None: # ─── Skills pane ────────────────────────────────────────────────────────────── +class SkillFileRow(Static): + """Clickable skill row that loads content into the editor form.""" + + def __init__(self, skill_path: Path, label: str, **kwargs) -> None: + super().__init__(label, classes="skill_entry", **kwargs) + self.skill_path = skill_path + + def on_click(self) -> None: + try: + pane = self.app.screen.query_one(SkillsPane) + pane._load_skill_file(self.skill_path) + except Exception: + pass + class SkillsPane(Container): """Browse crystallized skills from the skills/ directory.""" @@ -525,7 +1365,6 @@ class SkillsPane(Container): #skills_container { height: 1fr; border: round $primary; - border-title: " Skills "; padding: 1; background: $surface; } @@ -544,9 +1383,24 @@ class SkillsPane(Container): #skills_search_input { border: round $primary; } + #skills_editor_row { + height: auto; + padding: 1 0 0 0; + } + #skills_title_input { + margin-right: 1; + } + #skills_content_input { + border: round $primary; + margin-top: 1; + } + #skills_status { + color: $text-muted; + margin-top: 1; + } .skill_entry { padding: 0 1; - margin: 0 0; + margin: 0 0 1 0; } """ @@ -559,13 +1413,23 @@ def compose(self) -> ComposeResult: yield Label("πŸŽ“ Crystallized Skills", id="skills_header") with Horizontal(id="skills_search"): yield Input(placeholder="Search skills…", id="skills_search_input") + with Horizontal(id="skills_editor_row"): + yield Input(placeholder="Skill name…", id="skills_title_input") + yield Button("Use Last Reply", id="skills_use_last_reply_btn", classes="settings_btn") + yield Button("Refine Draft", id="skills_refine_btn", classes="settings_btn") + yield Button("Save/Update", id="skills_save_btn", classes="settings_btn") + yield Button("Delete", id="skills_delete_btn", classes="settings_btn") + yield Button("Clear", id="skills_clear_btn", classes="settings_btn") + yield Input(placeholder="Skill content…", id="skills_content_input") + yield Label("Tip: In chat, use /skill save to save the last reply as a skill.", id="skills_status") 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: - skills_dir = Path(WORKSPACE) / "skills" + skills_dir = _skills_dir() sl = self.query_one("#skills_list", VerticalScroll) for child in list(sl.children): child.remove() @@ -586,13 +1450,112 @@ def _load_skills(self, query: str = "") -> None: name = f.stem.replace("_", " ").title() sz = f.stat().st_size mtime = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M") - sl.mount(Static(f"[bold]{name}[/] [dim]{sz}B Β· {mtime}[/]", classes="skill_entry")) + sl.mount(SkillFileRow(f, f"[bold]{name}[/] [dim]{sz}B Β· {mtime}[/]")) + + def _load_skill_file(self, skill_path: Path) -> None: + try: + raw = skill_path.read_text(encoding="utf-8", errors="replace") + except Exception as e: + self.notify(f"Could not read skill: {e}", severity="error") + return + self.query_one("#skills_title_input", Input).value = skill_path.stem + self.query_one("#skills_content_input", Input).value = raw.replace("\n", " ")[:8000] + self.query_one("#skills_status", Label).update(f"Selected: {skill_path.name}") + + async def on_button_pressed(self, event: Button.Pressed) -> None: + bid = event.button.id + if bid not in {"skills_save_btn", "skills_delete_btn", "skills_refine_btn", "skills_use_last_reply_btn", "skills_clear_btn"}: + return + if bid == "skills_clear_btn": + self.query_one("#skills_title_input", Input).value = "" + self.query_one("#skills_content_input", Input).value = "" + self.query_one("#skills_status", Label).update("Draft cleared.") + return + if bid == "skills_use_last_reply_btn": + content = (self.state.last_agent_response or "").strip() + if not content: + self.notify("No recent reply yet. Ask something in Chat first.", severity="warning") + return + self.query_one("#skills_content_input", Input).value = content.replace("\n", " ")[:8000] + self.query_one("#skills_status", Label).update("Loaded last assistant reply into draft.") + return + title = self.query_one("#skills_title_input", Input).value.strip() + if not title: + self.notify("Enter a skill name first", severity="warning") + return + slug = _slugify_name(title) + if not slug: + self.notify("Invalid skill name", severity="warning") + return + skills_dir = _skills_dir() + skills_dir.mkdir(parents=True, exist_ok=True) + skill_path = skills_dir / f"{slug}.md" + + content = self.query_one("#skills_content_input", Input).value.strip() + if bid == "skills_refine_btn": + if not content: + self.notify("Add draft content first, then refine.", severity="warning") + return + if not self.state.agent: + self.notify("No active agent to refine draft.", severity="error") + return + prompt = ( + "Refine the following draft into a concise, reusable skill with clear steps and constraints. " + "Return plain markdown only.\n\n" + f"Skill name: {title}\n\nDraft:\n{content}" + ) + self.query_one("#skills_status", Label).update("Refining draft…") + try: + refined = await self.state.agent.run(prompt, target="user") + self.query_one("#skills_content_input", Input).value = (refined or "").replace("\n", " ")[:8000] + self.query_one("#skills_status", Label).update("Draft refined. Review, edit, then Save/Update.") + self.notify("Skill draft refined") + except Exception as e: + self.query_one("#skills_status", Label).update(f"Refine failed: {e}") + self.notify(f"Refine failed: {e}", severity="error") + return + + if bid == "skills_delete_btn": + if not skill_path.exists(): + self.notify(f"Skill not found: {slug}", severity="warning") + return + skill_path.unlink() + self.query_one("#skills_status", Label).update(f"Deleted: {skill_path.name}") + self.notify(f"Deleted skill: {slug}") + self._load_skills() + return + + content = self.query_one("#skills_content_input", Input).value.strip() + if not content: + self.notify("Enter skill content first", severity="warning") + return + if not content.startswith("# "): + content = f"# {title}\n\n{content}" + with open(skill_path, "w", encoding="utf-8") as f: + f.write(content.strip() + "\n") + self.query_one("#skills_status", Label).update(f"Saved: {skill_path.name}") + self.notify(f"Saved skill: {slug}") + self._load_skills() async def on_input_submitted(self, event: Input.Submitted) -> None: - self._load_skills(query=event.value.strip().lower()) + if event.input.id == "skills_search_input": + self._load_skills(query=event.value.strip().lower()) # ─── Knowledge Base pane ────────────────────────────────────────────────────── +class KBEntryRow(Static): + """Clickable KB row that loads entry content into editor fields.""" + + def __init__(self, kb_path: Path, label: str, **kwargs) -> None: + super().__init__(label, classes="kb_entry", **kwargs) + self.kb_path = kb_path + + def on_click(self) -> None: + try: + pane = self.app.screen.query_one(KBPane) + pane._load_kb_file(self.kb_path) + except Exception: + pass class KBPane(Container): """Browse and search the knowledge base (reference docs that aren't skills).""" @@ -601,7 +1564,6 @@ class KBPane(Container): #kb_container { height: 1fr; border: round $primary; - border-title: " Knowledge Base "; padding: 1; background: $surface; } @@ -631,14 +1593,39 @@ class KBPane(Container): #kb_add_btn { margin-right: 1; } + #kb_delete_btn { + margin-right: 1; + } #kb_add_area { height: 5; margin-top: 1; border: round $primary; } + #kb_mode_select { + margin-top: 1; + } + #kb_memory_search_row { + height: auto; + margin-top: 1; + } + #kb_memory_search_input { + border: round $border; + } + #kb_memory_results { + height: 8; + scrollbar-size: 1 1; + border: round $border; + background: $background 25%; + padding: 0 1; + margin-top: 1; + } + #kb_status { + color: $text-muted; + margin-top: 1; + } .kb_entry { padding: 0 1; - margin: 0 0; + margin: 0 0 1 0; } """ @@ -654,10 +1641,23 @@ def compose(self) -> ComposeResult: yield VerticalScroll(id="kb_list") with Horizontal(id="kb_add_row"): yield Input(placeholder="Title for new entry…", id="kb_add_title") - yield Button("Add", id="kb_add_btn", classes="settings_btn") + yield Button("Use Last Reply", id="kb_use_last_reply_btn", classes="settings_btn") + yield Button("Save/Update", id="kb_add_btn", classes="settings_btn") + yield Button("Delete", id="kb_delete_btn", classes="settings_btn") + yield Button("Clear", id="kb_clear_btn", classes="settings_btn") yield Input(placeholder="Content or paste text…", id="kb_add_area") + yield Select( + [("Save + index to memory", "index"), ("Save only (no indexing)", "save")], + value="index", + id="kb_mode_select", + ) + with Horizontal(id="kb_memory_search_row"): + yield Input(placeholder="Search indexed memory from this tab…", id="kb_memory_search_input") + yield VerticalScroll(id="kb_memory_results") + 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: @@ -683,58 +1683,129 @@ def _load_kb(self, query: str = "") -> None: sz = f.stat().st_size mtime = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M") entry_type = "note" - # Read first line for type hint first_line = f.read_text(errors="replace").split("\n", 1)[0].lower() if first_line.startswith("http"): entry_type = "url" elif first_line.startswith("#"): entry_type = "doc" - kl.mount(KBEntry(name, f.read_text(errors="replace")[:200], entry_type=entry_type)) + icon = {"doc": "πŸ“„", "url": "πŸ”—", "snippet": "βœ‚οΈ", "note": "πŸ“"}.get(entry_type, "πŸ“„") + kl.mount(KBEntryRow(f, f"{icon} [bold]{name}[/] [dim]{sz}B Β· {mtime}[/]")) + + def _load_kb_file(self, kb_path: Path) -> None: + try: + raw = kb_path.read_text(encoding="utf-8", errors="replace") + except Exception as e: + self.notify(f"Could not read KB entry: {e}", severity="error") + return + title = kb_path.stem.replace("_", " ") + body = raw + if raw.startswith("# "): + lines = raw.splitlines() + title = lines[0][2:].strip() or title + body = "\n".join(lines[2:]).strip() + self.query_one("#kb_add_title", Input).value = title + self.query_one("#kb_add_area", Input).value = body.replace("\n", " ")[:8000] + self.query_one("#kb_status", Label).update(f"Selected: {kb_path.name}") async def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "kb_search_input": self._load_kb(query=event.value.strip().lower()) + elif event.input.id == "kb_memory_search_input": + await self._run_memory_search(event.value.strip()) async def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "kb_add_btn": - title_input = self.query_one("#kb_add_title", Input) - content_input = self.query_one("#kb_add_area", Input) - title = title_input.value.strip() - content = content_input.value.strip() - if not title: - self.notify("Enter a title for the KB entry", severity="warning") - return + bid = event.button.id + if bid not in {"kb_add_btn", "kb_delete_btn", "kb_use_last_reply_btn", "kb_clear_btn"}: + return + if bid == "kb_clear_btn": + self.query_one("#kb_add_title", Input).value = "" + self.query_one("#kb_add_area", Input).value = "" + self.query_one("#kb_status", Label).update("Draft cleared.") + return + if bid == "kb_use_last_reply_btn": + content = (self.state.last_agent_response or "").strip() if not content: - self.notify("Enter content for the KB entry", severity="warning") + self.notify("No recent reply yet. Ask something in Chat first.", severity="warning") return + self.query_one("#kb_add_area", Input).value = content.replace("\n", " ")[:8000] + self.query_one("#kb_status", Label).update("Loaded last assistant reply into KB draft.") + return + title_input = self.query_one("#kb_add_title", Input) + content_input = self.query_one("#kb_add_area", Input) + title = title_input.value.strip() + if not title: + self.notify("Enter a title for the KB entry", severity="warning") + return - # Save to knowledge/ directory - kb_dir = Path(KB_DIR) - kb_dir.mkdir(parents=True, exist_ok=True) - safe_name = title.replace(" ", "_").lower() - # Remove any path separators or unsafe chars - safe_name = "".join(c for c in safe_name if c.isalnum() or c in "_-") - file_path = kb_dir / f"{safe_name}.md" - with open(file_path, "w", encoding="utf-8") as f: - f.write(f"# {title}\n\n{content}\n") - - # Also index into memory DB if agent is available - if self.state.agent: - from memory.db import MemoryChunk - try: - self.state.agent.memory.add_memory(MemoryChunk( - content=content, - embedding=[0.0] * 128, - metadata={"file": str(file_path), "type": "KB"}, - mem_type="DOC", - )) - except Exception: - pass + kb_dir = Path(KB_DIR) + kb_dir.mkdir(parents=True, exist_ok=True) + safe_name = _slugify_name(title) + if not safe_name: + self.notify("Invalid KB title", severity="warning") + return + file_path = kb_dir / f"{safe_name}.md" - title_input.value = "" - content_input.value = "" - self.notify(f"Added KB entry: {title}") + if bid == "kb_delete_btn": + if not file_path.exists(): + self.notify(f"KB entry not found: {safe_name}", severity="warning") + return + file_path.unlink() + self.query_one("#kb_status", Label).update(f"Deleted: {file_path.name}") + self.notify(f"Deleted KB entry: {title}") self._load_kb() + return + + content = content_input.value.strip() + if not content: + self.notify("Enter content for the KB entry", severity="warning") + return + with open(file_path, "w", encoding="utf-8") as f: + f.write(f"# {title}\n\n{content}\n") + + mode = self.query_one("#kb_mode_select", Select).value + indexed = False + if mode == "index" and self.state.agent: + from memory.db import MemoryChunk + try: + embedding = await self.state.agent.get_embedding(content) + self.state.agent.memory.add_memory(MemoryChunk( + content=content, + embedding=embedding, + metadata={"file": str(file_path), "type": "KB"}, + mem_type="DOC", + )) + indexed = True + except Exception: + indexed = False + + title_input.value = "" + content_input.value = "" + annexed = "indexed to memory" if indexed else ("saved only" if mode == "save" else "saved (index failed)") + self.query_one("#kb_status", Label).update(f"Saved: {file_path.name} Β· {annexed}") + self.notify(f"Saved KB entry: {title} ({annexed})") + self._load_kb() + + async def _run_memory_search(self, query: str) -> None: + results = self.query_one("#kb_memory_results", VerticalScroll) + for child in list(results.children): + child.remove() + if not query: + results.mount(Static("[dim]Type a query and press Enter to search indexed memory.[/]", classes="kb_entry")) + return + if not self.state.agent: + results.mount(Static("[red]No agent available[/]", classes="kb_entry")) + return + try: + chunks = await self.state.agent.retriever.retrieve(query, top_k=6) + if not chunks: + results.mount(Static("[dim]No indexed memory results.[/]", classes="kb_entry")) + return + for i, chunk in enumerate(chunks): + content = (chunk.get("content", "") or "").replace("[", "\\[").replace("]", "\\]") + score = chunk.get("score", 0) + results.mount(Static(f"[bold]#{i+1}[/] [dim]score={score:.3f}[/]\n{content[:240]}", classes="kb_entry")) + except Exception as e: + results.mount(Static(f"[red]Memory search failed: {e}[/]", classes="kb_entry")) # ─── Memory pane ────────────────────────────────────────────────────────────── @@ -746,7 +1817,6 @@ class MemoryPane(Container): #memory_container { height: 1fr; border: round $primary; - border-title: " Memory "; padding: 1; background: $surface; } @@ -782,6 +1852,9 @@ def compose(self) -> ComposeResult: 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 " + async def on_input_submitted(self, event: Input.Submitted) -> None: query = event.value.strip() if not query: @@ -825,7 +1898,6 @@ class SettingsPane(Container): #settings_container { height: 1fr; border: round $primary; - border-title: " Settings "; padding: 1 2; background: $surface; scrollbar-size: 1 1; @@ -877,15 +1949,12 @@ def compose(self) -> ComposeResult: with VerticalScroll(id="settings_container"): yield Label("πŸ”§ Settings", classes="settings_label") - # ── Provider / Model selector ────────────────────────────── yield Label("Provider / Model", classes="settings_label") yield Label(" Switch the active model at runtime:", classes="settings_row") options = AppState.build_provider_options() - # Select needs (display, value) tuples and a default value valid_values = {v for _, v in options} default_val = self.state.current_provider_id if self.state.current_provider_id in valid_values else (options[0][1] if options else Select.BLANK) yield Select(options, value=default_val, id="provider_select") - # Show a human-friendly label for the active provider active_label = default_val for label, val in options: if val == default_val: @@ -893,27 +1962,34 @@ def compose(self) -> ComposeResult: break yield Label(f" Active: {active_label}", id="settings_provider") - # ── Theme ──────────────────────────────────────────────────── yield Label("Theme", classes="settings_label") theme_options = [(ThemeRegistry.get_theme(tid).name, tid) for tid in ThemeRegistry.theme_ids()] yield Select(theme_options, value=self.state.current_theme, id="theme_select") yield Button("Toggle Theme (Ctrl+T)", id="btn_toggle_theme", classes="settings_btn") - # ── Caveman ────────────────────────────────────────────────── + yield Label("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") - # ── Workspace ──────────────────────────────────────────────── yield Label("Workspace", classes="settings_label") yield Label(f" {WORKSPACE}", classes="settings_row") - # ── Dashboard ──────────────────────────────────────────────── yield Label("Dashboard", classes="settings_label") yield Button("Open Dashboard β†—", id="btn_dashboard", classes="settings_btn") - # ── Workers ────────────────────────────────────────────────── yield Label("Workers", classes="settings_label") w = f" {os.cpu_count() * 2} max (CPU-aware)" if self.state.task_manager else " Not initialized" yield Label(w, id="settings_workers") @@ -924,17 +2000,14 @@ async def on_select_changed(self, event: Select.Changed) -> None: new_provider_id = event.value if new_provider_id == Select.BLANK: return - # Verify API key is available before switching if not self.state.config_manager.has_api_key(new_provider_id): self.notify("πŸ”’ No API key configured for this provider", severity="warning") - # Revert the Select to the current provider valid_values = {v for _, v in AppState.build_provider_options()} current = self.state.current_provider_id if self.state.current_provider_id in valid_values else (list(valid_values)[0] if valid_values else "") self.query_one("#provider_select", Select).value = current return try: self.state.reconnect(new_provider_id) - # Find the display label for the new provider new_label = new_provider_id for label, val in AppState.build_provider_options(): if val == new_provider_id: @@ -942,11 +2015,10 @@ async def on_select_changed(self, event: Select.Changed) -> None: break self.query_one("#settings_provider", Label).update(f" Active: {new_label}") self.notify(f"Switched to {new_label}") - # Update chat welcome message try: chat_pane = self.app.screen.query_one(ChatPane) log = chat_pane.query_one("#chat_log", VerticalScroll) - log.mount(ChatMessage(f"⚑ Switched to {new_label}", sender="system")) + log.mount(SystemMessage(f"⚑ Switched to {new_label}")) log.scroll_end(animate=False) except Exception: pass @@ -958,8 +2030,15 @@ async def on_select_changed(self, event: Select.Changed) -> None: if new_theme == Select.BLANK: return self.state.current_theme = new_theme - self.app.theme = new_theme # Textual native β€” cascades everywhere + self.app.theme = new_theme self.notify(f"Theme β†’ {ThemeRegistry.get_theme(new_theme).name}") + elif event.select.id == "ui_mode_select": + mode = event.value + if mode == Select.BLANK: + return + self.state.ui_mode = mode + self.app.set_class(mode == "experimental", "experimental-ui") + self.notify(f"UI mode β†’ {mode}") async def on_button_pressed(self, event: Button.Pressed) -> None: bid = event.button.id @@ -969,7 +2048,6 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: idx = themes.index(self.state.current_theme) self.state.current_theme = themes[(idx + 1) % len(themes)] self.app.theme = self.state.current_theme - # Sync the Select dropdown too self.query_one("#theme_select", Select).value = self.state.current_theme self.notify(f"Theme β†’ {ThemeRegistry.get_theme(self.state.current_theme).name}") @@ -984,6 +2062,14 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: 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() + 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: + pass # ─── The App ────────────────────────────────────────────────────────────────── @@ -1001,10 +2087,16 @@ class MotionTUI(App): Header { background: $surface; } Footer { background: $surface; } TabbedContent { height: 1fr; } - .msg { margin: 0 0; padding: 0 1; } - .user_msg { color: $primary; text-style: bold; } - .agent_msg { color: $foreground; } - .system_msg { color: $text-muted; text-style: italic; } + .experimental-ui TaskRow { + padding: 1 2; + margin: 0 0 1 0; + } + .experimental-ui #chat_log { + border: heavy $primary; + } + .experimental-ui #tasks_container { + border: heavy $primary; + } """ BINDINGS = [ @@ -1020,6 +2112,9 @@ def __init__(self, model_config: Optional[ModelConfig] = None, provider_id: str self._workspace = workspace def on_mount(self) -> None: + # Redirect logging to file so it doesn't bleed into the TUI + _suppress_logging() + # Register all themes with Textual's native system for tid in ThemeRegistry.theme_ids(): ttheme = ThemeRegistry.get_textual_theme(tid) @@ -1027,17 +2122,14 @@ def on_mount(self) -> None: # Set initial theme self.theme = self.state.current_theme + self.set_class(self.state.ui_mode == "experimental", "experimental-ui") if self._model_config: self.state.agent = MotionAgent(self._model_config) self.state.task_manager = TaskManager(self._model_config, self._workspace) - # Use the explicit provider_id (e.g. "ollama-cloud/gemma3:12b") - # rather than model_config.name (a display name like "Ollama Cloud (gemma3:12b)") - # which won't match Select option values. if self._provider_id: self.state.current_provider_id = self._provider_id else: - # Fallback: try to match against known options options = AppState.build_provider_options() self.state.current_provider_id = options[0][1] if options else "" self.push_screen(MainScreen(self.state)) From d490674bc7a0969661b001724b7fa88ea4cb534b Mon Sep 17 00:00:00 2001 From: Mathitz Date: Sat, 18 Jul 2026 14:26:41 -0300 Subject: [PATCH 4/5] Normalize TUI theme-token hierarchy across bundled themes --- ui/tui.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/ui/tui.py b/ui/tui.py index a951184..f6b7c24 100644 --- a/ui/tui.py +++ b/ui/tui.py @@ -190,8 +190,9 @@ class UserMessage(Static): """A user message bubble β€” primary accent, left border.""" DEFAULT_CSS = """ UserMessage { - background: $primary 12%; - border: round $primary; + background: $surface; + border-left: heavy $primary; + border-right: blank; padding: 1 2; margin: 1 10 0 0; color: $text; @@ -203,8 +204,9 @@ class ReasoningMessage(Static): """Collapsible-style block used to surface model reasoning stream.""" DEFAULT_CSS = """ ReasoningMessage { - background: $warning 10%; - border: round $warning; + background: $panel; + border-left: heavy $warning; + border-right: blank; padding: 1 2; margin: 1 0 0 10; color: $text-muted; @@ -217,7 +219,8 @@ class AgentMessage(Static): DEFAULT_CSS = """ AgentMessage { background: $surface; - border: round $primary; + border-left: heavy $accent; + border-right: blank; padding: 1 2; margin: 1 0 0 10; color: $text; @@ -358,7 +361,7 @@ class ActivityRail(Vertical): max-width: 42; border-left: heavy $border; padding: 1 1; - background: $surface; + background: $panel; } #activity_header { color: $accent; @@ -373,7 +376,7 @@ class ActivityRail(Vertical): padding: 0 1; margin: 0 0 1 0; color: $text; - background: $background 20%; + background: $surface; border: round $border; } """ @@ -432,7 +435,7 @@ class MainScreen(Screen): #tab_quick_nav { height: auto; padding: 0 1 1 1; - background: $surface; + background: $panel; border: round $border; } .tab_nav_btn { @@ -626,13 +629,13 @@ class ChatPane(Vertical): border: round $primary; padding: 1 2; scrollbar-size: 1 1; - background: $background 15%; + background: $surface; } #trace_panel { width: 42; height: 1fr; border: round $accent; - background: $surface; + background: $panel; padding: 1 1; margin-left: 1; } @@ -650,7 +653,7 @@ class ChatPane(Vertical): #chat_input_row { height: auto; padding: 1 1 1 1; - background: $surface; + background: $panel; border: round $border; } #chat_controls { @@ -668,7 +671,7 @@ class ChatPane(Vertical): margin-left: 1; } #chat_metrics { - color: $text; + color: $text-muted; margin-top: 0; margin-bottom: 1; padding: 0 2; From d4e4926f1767dee93c6fc926d82826a9e33b13b4 Mon Sep 17 00:00:00 2001 From: Mathitz Date: Sat, 18 Jul 2026 14:55:21 -0300 Subject: [PATCH 5/5] Fix provider mock compatibility and stabilize memory test IO --- main.py | 3 ++- tests/test_integration.py | 9 +++++++-- tests/test_memory.py | 15 ++++++++++----- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index e2f05f0..6a8ed88 100644 --- a/main.py +++ b/main.py @@ -66,11 +66,12 @@ async def emit_trace(stage: str, message: str, **extra): # 3. Model Completion (streaming if callback is provided) stream_chunk_count = 0 + provider_type = getattr(getattr(self.provider, "config", None), "provider_type", "unknown") await emit_trace( "model_start", "Calling provider for completion", mode="stream" if on_stream_chunk else "oneshot", - provider=self.provider.config.provider_type, + provider=provider_type, ) if on_stream_chunk: raw_chunks = [] diff --git a/tests/test_integration.py b/tests/test_integration.py index c0380ef..19a0765 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -9,6 +9,11 @@ class MockProvider: + class _Config: + provider_type = "local" + + def __init__(self): + self.config = self._Config() async def complete(self, prompt, system_prompt="", **kwargs): return "Certainly! I have analyzed the files and found the bug is in line 42. I'm sorry for the inconvenience." @@ -29,8 +34,8 @@ async def get_embedding(self, text): async def test_golden_path(): """ - Tests the full 'Golden Path': - User Prompt -> Hybrid Recall -> Model completion -> Caveman Compression -> Skill Crystallization + Tests the main end-to-end path: + User Prompt -> Hybrid Recall -> Model completion -> Caveman Compression """ model_config = ModelConfig(name="test-model", endpoint="http://localhost", provider_type="local") agent = MotionAgent(model_config) diff --git a/tests/test_memory.py b/tests/test_memory.py index 3132845..02b6aa4 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -1,4 +1,6 @@ import asyncio +from pathlib import Path +import tempfile from unittest.mock import AsyncMock, MagicMock from memory.db import MemoryDB, MemoryChunk from memory.facilitator import MDFacilitator @@ -19,11 +21,14 @@ async def test_memory_pipeline(): retriever = HybridRetriever(db, emb_provider) # 1. Test Ingestion (Facilitator) - # Create a dummy md file - with open("test_doc.md", "w") as f: - f.write("The Motion Harness uses a hybrid retrieval system with FTS5 and Vector DB.") - - await facilitator.ingest_files(["test_doc.md"]) + # Create a dummy md file in an isolated temp dir + with tempfile.TemporaryDirectory() as tmpdir: + doc_path = Path(tmpdir) / "test_doc.md" + doc_path.write_text( + "The Motion Harness uses a hybrid retrieval system with FTS5 and Vector DB.", + encoding="utf-8", + ) + await facilitator.ingest_files([str(doc_path)]) # 2. Test Keyword Search (Sparse) # 'FTS5' is a very specific keyword