diff --git a/.gitignore b/.gitignore
index e381763..b028a62 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,5 +42,15 @@ bin/
skills/
!skills/.gitkeep
+# Knowledge base entries (user-generated)
+knowledge/
+!knowledge/.gitkeep
+
# 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..6a8ed88 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,82 @@ 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
+ 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=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_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
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 fedabef..f6b7c24 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
@@ -22,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
@@ -47,16 +52,30 @@
)
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
WORKSPACE = os.getenv("MOTION_WORKSPACE", os.getcwd())
+KB_DIR = os.path.join(WORKSPACE, "knowledge")
DASHBOARD_URL = "https://localhost:7860/"
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:
@@ -69,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."""
@@ -96,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:
@@ -118,39 +157,86 @@ 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 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"}
+# ─── Chat message widgets ─────────────────────────────────────────────────────
- 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 UserMessage(Static):
+ """A user message bubble — primary accent, left border."""
+ DEFAULT_CSS = """
+ UserMessage {
+ background: $surface;
+ border-left: heavy $primary;
+ border-right: blank;
+ padding: 1 2;
+ margin: 1 10 0 0;
+ color: $text;
+ }
+ """
+
+
+class ReasoningMessage(Static):
+ """Collapsible-style block used to surface model reasoning stream."""
+ DEFAULT_CSS = """
+ ReasoningMessage {
+ background: $panel;
+ border-left: heavy $warning;
+ border-right: blank;
+ padding: 1 2;
+ margin: 1 0 0 10;
+ color: $text-muted;
+ text-style: dim italic;
+ }
+ """
+
+class AgentMessage(Static):
+ """An agent message bubble — success accent, left border."""
+ DEFAULT_CSS = """
+ AgentMessage {
+ background: $surface;
+ border-left: heavy $accent;
+ border-right: blank;
+ 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):
@@ -182,7 +268,6 @@ class ProviderSelectScreen(Screen):
height: auto;
max-height: 85%;
border: round $primary;
- border-title: " Motion Harness ";
padding: 1 3;
background: $surface;
overflow-y: auto;
@@ -203,7 +288,7 @@ class ProviderSelectScreen(Screen):
max-height: 22;
border: solid $border;
padding: 0 1;
- background: $background;
+ background: $surface;
}
#provider_status {
text-align: center;
@@ -230,6 +315,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:
@@ -263,21 +349,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: $panel;
+ }
+ #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: $surface;
+ 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: $panel;
+ 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:
@@ -286,77 +490,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("🧠 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;
+ background: $surface;
+ }
+ #trace_panel {
+ width: 42;
+ height: 1fr;
+ border: round $accent;
+ background: $panel;
+ 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;
- padding: 1 0 0 0;
+ padding: 1 1 1 1;
+ background: $panel;
+ border: round $border;
+ }
+ #chat_controls {
+ height: auto;
+ width: auto;
+ padding: 0 0 0 1;
+ border-left: solid $border;
+ margin-left: 1;
+ }
+ #chat_primary_actions, #chat_secondary_actions {
+ height: auto;
+ width: auto;
+ }
+ #chat_secondary_actions {
+ margin-left: 1;
+ }
+ #chat_metrics {
+ color: $text-muted;
+ 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;
- }
- .agent_msg {
- color: $foreground;
- background: transparent;
- padding: 0 1;
- margin: 0 0;
+ width: 1fr;
}
- .system_msg {
- color: $text-muted;
- text-style: italic;
- padding: 0 1;
- margin: 0 0;
+ .chat_btn {
+ margin-left: 1;
+ min-width: 10;
}
"""
@@ -365,17 +693,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:
@@ -383,48 +772,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)
+
+ 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"}
+
+ 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))
-# ─── Tasks pane ───────────────────────────────────────────────────────────────
-class TasksPane(Container):
+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: $background;
+ 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;
@@ -435,7 +1261,7 @@ class TasksPane(Container):
padding: 1 0 0 0;
}
#task_input {
- border: round $primary;
+ border: round $border;
}
"""
@@ -445,7 +1271,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")
@@ -465,26 +1293,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:
@@ -492,9 +1333,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
@@ -502,6 +1347,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."""
@@ -509,9 +1368,8 @@ class SkillsPane(Container):
#skills_container {
height: 1fr;
border: round $primary;
- border-title: " Skills ";
padding: 1;
- background: $background;
+ background: $surface;
}
#skills_header {
color: $primary;
@@ -528,9 +1386,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;
}
"""
@@ -543,13 +1416,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()
@@ -570,10 +1453,362 @@ 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:
+ 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)."""
+
+ CSS = """
+ #kb_container {
+ height: 1fr;
+ border: round $primary;
+ 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_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 1 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("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:
+ 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"
+ 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"
+ 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:
- self._load_skills(query=event.value.strip().lower())
+ 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:
+ 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("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
+
+ 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"
+
+ 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 ──────────────────────────────────────────────────────────────
@@ -585,9 +1820,8 @@ class MemoryPane(Container):
#memory_container {
height: 1fr;
border: round $primary;
- border-title: " Memory ";
padding: 1;
- background: $background;
+ background: $surface;
}
#memory_results {
height: 1fr;
@@ -621,6 +1855,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:
@@ -664,9 +1901,8 @@ class SettingsPane(Container):
#settings_container {
height: 1fr;
border: round $primary;
- border-title: " Settings ";
padding: 1 2;
- background: $background;
+ background: $surface;
scrollbar-size: 1 1;
}
.settings_label {
@@ -716,15 +1952,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:
@@ -732,27 +1965,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")
@@ -763,17 +2003,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:
@@ -781,11 +2018,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
@@ -797,8 +2033,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
@@ -808,7 +2051,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}")
@@ -823,6 +2065,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 ──────────────────────────────────────────────────────────────────
@@ -840,10 +2090,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 = [
@@ -859,6 +2115,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)
@@ -866,17 +2125,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))