From 4f12ffc86af3f57af1daccd21c752b691ccb3fbf Mon Sep 17 00:00:00 2001
From: Mathitz
Date: Sat, 18 Jul 2026 15:18:58 -0300
Subject: [PATCH 1/8] feat(tui): v2 visual overhaul + auto-synthesis toggle
---
README.md | 79 ++++++++++++++--
ui/tui.py | 276 +++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 289 insertions(+), 66 deletions(-)
diff --git a/README.md b/README.md
index df00ef6..ec97bba 100644
--- a/README.md
+++ b/README.md
@@ -80,15 +80,43 @@ A bidirectional compression layer that strips conversational fluff.
When a complex task is solved, the harness doesn't just forget. It analyzes the trajectory and "crystallizes" the steps into a `.md` skill, allowing the agent to execute the same complex workflow in the future with a single reference.
### π¨ Pro-Grade TUI
-A high-performance terminal interface built with `Textual`. Featuring:
-- **5-Tab Interface**: Chat, Tasks, Skills, Memory, Settings
-- **Provider Dropdown**: Switch models at runtime without restarting
-- **4 Native Themes**: One Dark, Solarized Light, Nord, Dracula β cascade through every widget
-- **Live Task Monitoring**: Real-time status of parallel agent workers with β³βοΈβ
β indicators
-- **Memory Search**: Hybrid semantic + keyword search directly in the TUI
-- **Skill Browser**: Browse, search, and inspect crystallized skills
-- **Dashboard Integration**: One-click open to the admin dashboard at `https://localhost:7860/`
-- **Ctrl+C cancels requests** (doesn't kill the app), **Ctrl+Q quits**
+A high-performance terminal interface built with `Textual`, designed for daily-driver clarity rather than an engineering dashboard.
+
+**6-Tab Workspace**: Chat, Tasks, Skills, Knowledge, Memory, Settings β tab headers are the canonical navigation (no duplicate button bars).
+
+**Chat workspace regions**:
+- **Conversation canvas** β dominant ~70β75% width, markdown-first message cards with author/time headers and compact metadata.
+- **Interaction trace side panel** β progressive disclosure: collapsed by default with a summary chip (`trace Β· N events Β· last stage`); expand with `F8` or by clicking the chip.
+- **Composer / action bar** β grouped primary actions (Trace, Copy, Stats) and secondary actions (Save Skill) with a single-line session metrics strip.
+
+**Theme token model**: semantic tokens (`$background`, `$surface`, `$panel`, `$border`, `$primary`, `$accent`, `$text`, `$text-muted`, `$success`, `$warning`, `$error`) cascade through every widget via Textual's theme system. One emphasized border per visual region (chat canvas keeps `$primary`; trace, tasks, skills, KB, memory, settings use `$border`).
+
+**4 Native Themes**: One Dark, Solarized Light, Nord, Dracula β cycle with `Ctrl+T`.
+
+**Live task monitoring**: real-time status of parallel workers with β³βοΈβ
β indicators and a global activity rail (`Ctrl+B` to toggle).
+
+**Keyboard shortcuts**:
+| Key | Action |
+| :-- | :-- |
+| `Ctrl+1..6` / `Alt+1..6` / `F1..F6` | Switch tab (Chat, Tasks, Skills, KB, Memory, Settings) |
+| `Ctrl+]` / `Ctrl+[` / `Ctrl+ββ` | Next / previous tab |
+| `Ctrl+T` | Cycle theme |
+| `Ctrl+B` | Toggle activity rail |
+| `F8` / `Ctrl+Shift+T` | Toggle interaction trace panel |
+| `F9` / `Ctrl+Shift+C` | Copy last assistant response |
+| `?` | Show shortcuts overlay (generated from live bindings) |
+| `Enter` (chat input) | Send message |
+| `/skill save ` | Save last reply as a skill |
+| `Ctrl+C` | Cancel current request (does not quit) |
+| `Ctrl+Q` | Quit |
+
+**Dashboard integration**: one-click open to the admin dashboard at `https://localhost:7860/`.
+
+### β οΈ Known Limitations (v2 TUI)
+- Trace persistence is per-session (not yet written to disk).
+- Theme contrast validation is manual; the bundled themes are tuned for readability but very-low-contrast combinations are not auto-corrected.
+- Shortcut help overlay (`?`) reflects MainScreen + ChatPane bindings; pane-local bindings in Tasks/Skills/KB/Memory/Settings are not yet enumerated.
+- Clipboard copy falls back to inserting the response into the input box when the terminal lacks clipboard support.
---
@@ -118,4 +146,37 @@ Ensure all changes are validated against the integration suite:
pytest tests/test_integration.py
```
+**Visual + TUI smoke checks** (headless, no terminal required):
+```bash
+# Parse/import sanity
+python -c "from ui import tui; print('Import OK')"
+
+# Headless TUI smoke: compose MainScreen, tab nav, F8 trace toggle, ? overlay
+python - <<'PY'
+import asyncio, sys
+from textual.app import App
+from ui.tui import MainScreen, AppState
+from ui.themes import ThemeRegistry
+
+class SmokeApp(App): pass
+async def run():
+ app = SmokeApp()
+ for tid in ThemeRegistry.theme_ids():
+ app.register_theme(ThemeRegistry.get_textual_theme(tid))
+ async with app.run_test() as pilot:
+ app.push_screen(MainScreen(AppState()))
+ await pilot.pause()
+ await pilot.press("ctrl+2"); await pilot.pause()
+ await pilot.press("ctrl+1"); await pilot.pause()
+ await pilot.press("f8"); await pilot.pause()
+ await pilot.press("f8"); await pilot.pause()
+ await pilot.press("question_sign"); await pilot.pause()
+ await pilot.press("escape"); await pilot.pause()
+ sys.stderr.write("SMOKE OK\n")
+asyncio.run(run())
+PY
+```
+
+Checks cover: MainScreen compose, tab navigation (`Ctrl+1`/`Ctrl+2`), trace disclosure toggle (`F8`), and the shortcuts overlay (`?`/`Escape`).
+
For more details on our versioning and changelog, see [RELEASES.md](RELEASES.md).
diff --git a/ui/tui.py b/ui/tui.py
index f6b7c24..67c0673 100644
--- a/ui/tui.py
+++ b/ui/tui.py
@@ -88,9 +88,10 @@ def __init__(self) -> None:
self.current_provider_id: str = ""
self.current_theme: str = "one_dark"
self.caveman_enabled: bool = True
+ self.auto_synthesis_enabled: bool = False
self.ui_mode: str = "conservative"
self.show_activity_rail: bool = True
- self.show_trace_panel: bool = True
+ self.show_trace_panel: bool = False
self.last_agent_response: str = ""
self.last_turn_metrics: dict = {}
self.session_metrics: dict = {
@@ -118,6 +119,7 @@ def reconnect(self, provider_id: str) -> None:
except Exception:
pass
self.agent = MotionAgent(model_config)
+ self.agent.auto_skill_synthesis = self.auto_synthesis_enabled
self.task_manager = TaskManager(model_config, WORKSPACE)
self.current_provider_id = provider_id
@@ -187,14 +189,16 @@ def _extract_reasoning_and_answer(text: str) -> tuple[str, str]:
# βββ Chat message widgets βββββββββββββββββββββββββββββββββββββββββββββββββββββ
class UserMessage(Static):
- """A user message bubble β primary accent, left border."""
+ """A user message card β primary accent header, markdown body."""
DEFAULT_CSS = """
UserMessage {
background: $surface;
border-left: heavy $primary;
border-right: blank;
+ border-top: blank;
+ border-bottom: blank;
padding: 1 2;
- margin: 1 10 0 0;
+ margin: 1 8 0 0;
color: $text;
}
"""
@@ -207,28 +211,32 @@ class ReasoningMessage(Static):
background: $panel;
border-left: heavy $warning;
border-right: blank;
+ border-top: blank;
+ border-bottom: blank;
padding: 1 2;
- margin: 1 0 0 10;
+ margin: 1 0 0 8;
color: $text-muted;
text-style: dim italic;
}
"""
class AgentMessage(Static):
- """An agent message bubble β success accent, left border."""
+ """An agent message card β accent header, markdown body."""
DEFAULT_CSS = """
AgentMessage {
background: $surface;
border-left: heavy $accent;
border-right: blank;
+ border-top: blank;
+ border-bottom: blank;
padding: 1 2;
- margin: 1 0 0 10;
+ margin: 1 0 0 8;
color: $text;
}
"""
class SystemMessage(Static):
- """A system/info message β muted, italic."""
+ """A system/info message β muted, single-line metadata."""
DEFAULT_CSS = """
SystemMessage {
color: $text-muted;
@@ -426,23 +434,124 @@ def refresh_activity(self) -> None:
container.mount(Static(row, classes="activity_row"))
+# βββ Shortcuts overlay ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+class ShortcutsOverlay(Screen):
+ """Unified shortcuts help overlay generated from real bindings."""
+
+ CSS = """
+ ShortcutsOverlay {
+ align: center middle;
+ }
+ #shortcuts_box {
+ width: 78;
+ max-height: 80%;
+ border: round $primary;
+ background: $surface;
+ padding: 1 2;
+ scrollbar-size: 1 1;
+ }
+ #shortcuts_title {
+ color: $primary;
+ text-style: bold;
+ text-align: center;
+ margin-bottom: 1;
+ }
+ #shortcuts_body {
+ height: auto;
+ max-height: 70%;
+ scrollbar-size: 1 1;
+ }
+ .shortcut_section {
+ color: $accent;
+ text-style: bold;
+ margin-top: 1;
+ margin-bottom: 0;
+ }
+ .shortcut_row {
+ color: $text;
+ padding: 0 1;
+ }
+ #shortcuts_hint {
+ color: $text-muted;
+ text-align: center;
+ margin-top: 1;
+ }
+ """
+
+ BINDINGS = [
+ Binding("escape", "dismiss_overlay", "Close"),
+ Binding("question_sign", "dismiss_overlay", "Close"),
+ Binding("q", "dismiss_overlay", "Close"),
+ ]
+
+ def __init__(self, main_screen: "MainScreen", **kwargs) -> None:
+ super().__init__(**kwargs)
+ self._main = main_screen
+
+ def compose(self) -> ComposeResult:
+ with VerticalScroll(id="shortcuts_box"):
+ yield Label("β¨οΈ Keyboard Shortcuts", id="shortcuts_title")
+ yield VerticalScroll(id="shortcuts_body")
+ yield Label("Press ? / Esc / q to close", id="shortcuts_hint")
+
+ def on_mount(self) -> None:
+ self.query_one("#shortcuts_box", VerticalScroll).border_title = " Shortcuts "
+ body = self.query_one("#shortcuts_body", VerticalScroll)
+ sections = self._collect_bindings()
+ for section_title, rows in sections.items():
+ body.mount(Label(section_title, classes="shortcut_section"))
+ for key, label in rows:
+ body.mount(Static(f" [bold]{key}[/] [dim]Β·[/] {label}", classes="shortcut_row"))
+
+ def _collect_bindings(self) -> dict:
+ groups: dict[str, list[tuple[str, str]]] = {}
+ seen_keys: set[str] = set()
+ global_bindings = getattr(self._main, "BINDINGS", []) or []
+ groups["Global"] = []
+ for b in global_bindings:
+ key = getattr(b, "key", "")
+ label = getattr(b, "description", "") or key
+ if not key or key in seen_keys:
+ continue
+ seen_keys.add(key)
+ groups["Global"].append(self._pretty_key(key, label))
+ try:
+ chat = self._main.query_one(ChatPane)
+ chat_bindings = getattr(chat, "BINDINGS", []) or []
+ groups["Chat"] = []
+ for b in chat_bindings:
+ key = getattr(b, "key", "")
+ label = getattr(b, "description", "") or key
+ if not key or key in seen_keys:
+ continue
+ seen_keys.add(key)
+ groups["Chat"].append(self._pretty_key(key, label))
+ except Exception:
+ pass
+ return {k: v for k, v in groups.items() if v}
+
+ @staticmethod
+ def _pretty_key(key: str, label: str) -> tuple[str, str]:
+ pretty = (
+ key.replace("ctrl+", "Ctrl+")
+ .replace("alt+", "Alt+")
+ .replace("shift+", "Shift+")
+ .replace("_", " ")
+ )
+ pretty = pretty.replace("question sign", "?")
+ return pretty, label
+
+ def action_dismiss_overlay(self) -> None:
+ self.app.pop_screen()
+
+
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;
@@ -482,6 +591,7 @@ class MainScreen(Screen):
Binding("ctrl+]", "next_tab", "Next Tab", priority=True),
Binding("ctrl+[", "prev_tab", "Prev Tab", priority=True),
Binding("ctrl+q", "quit", "Quit", priority=True),
+ Binding("question_sign", "show_shortcuts", "Shortcuts", priority=True),
]
def __init__(self, state: AppState, **kwargs) -> None:
@@ -492,13 +602,6 @@ def compose(self) -> ComposeResult:
yield Header(show_clock=True)
with Horizontal(id="main_shell"):
with Vertical(id="main_body"):
- with Horizontal(id="tab_quick_nav"):
- yield Button("Chat", id="tab_btn_chat", classes="tab_nav_btn")
- yield Button("Tasks", id="tab_btn_tasks", classes="tab_nav_btn")
- yield Button("Skills", id="tab_btn_skills", classes="tab_nav_btn")
- yield Button("Knowledge", id="tab_btn_kb", classes="tab_nav_btn")
- yield Button("Memory", id="tab_btn_memory", classes="tab_nav_btn")
- yield Button("Settings", id="tab_btn_settings", classes="tab_nav_btn")
with TabbedContent(id="main_tabs"):
with TabPane("π¬ Chat", id="chat_tab"):
yield ChatPane(self.state)
@@ -517,17 +620,9 @@ def compose(self) -> ComposeResult:
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)
+ # Chat-local buttons (trace/copy/stats/save) are handled in ChatPane.
+ # No global tab buttons remain; tab headers are the canonical nav.
+ pass
def on_mount(self) -> None:
if not self.state.show_activity_rail:
@@ -603,6 +698,9 @@ def action_prev_tab(self) -> None:
idx = 0
self._activate_tab(order[(idx - 1) % len(order)])
+ def action_show_shortcuts(self) -> None:
+ self.app.push_screen(ShortcutsOverlay(self))
+
# βββ Chat pane ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -634,13 +732,13 @@ class ChatPane(Vertical):
#trace_panel {
width: 42;
height: 1fr;
- border: round $accent;
+ border: round $border;
background: $panel;
padding: 1 1;
margin-left: 1;
}
#trace_header {
- color: $accent;
+ color: $text-muted;
text-style: bold;
margin-bottom: 0;
}
@@ -650,11 +748,20 @@ class ChatPane(Vertical):
border-top: solid $border;
padding-top: 1;
}
+ #trace_summary_chip {
+ height: auto;
+ width: auto;
+ padding: 0 2;
+ margin: 0 0 1 0;
+ background: $panel;
+ border: round $border;
+ color: $text-muted;
+ }
#chat_input_row {
height: auto;
padding: 1 1 1 1;
background: $panel;
- border: round $border;
+ border: solid $border;
}
#chat_controls {
height: auto;
@@ -672,8 +779,7 @@ class ChatPane(Vertical):
}
#chat_metrics {
color: $text-muted;
- margin-top: 0;
- margin-bottom: 1;
+ margin: 0 0 0 0;
padding: 0 2;
}
#chat_input {
@@ -691,6 +797,7 @@ class ChatPane(Vertical):
def __init__(self, state: AppState, **kwargs) -> None:
super().__init__(**kwargs)
self.state = state
+ self._last_trace_stage: str = ""
def compose(self) -> ComposeResult:
with Horizontal(id="chat_body"):
@@ -698,17 +805,22 @@ def compose(self) -> ComposeResult:
with Vertical(id="trace_panel"):
yield Label("Interaction Trace", id="trace_header")
yield VerticalScroll(id="trace_log")
+ yield Label("", id="trace_summary_chip")
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("Trace", 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_click(self, event) -> None:
+ if getattr(event.control, "id", None) == "trace_summary_chip":
+ self._set_trace_panel_visible(True)
+
def on_mount(self) -> None:
name = self.state.agent.provider.config.name if self.state.agent else "?"
log = self.query_one("#chat_log", VerticalScroll)
@@ -724,9 +836,28 @@ 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"
+ chip = self.query_one("#trace_summary_chip", Label)
+ chip.styles.display = "none" if visible else "block"
button = self.query_one("#chat_toggle_trace_btn", Button)
- button.label = "Trace On" if visible else "Trace Off"
+ button.label = "Trace On" if visible else "Trace"
+ self._refresh_trace_chip()
self.notify("Trace panel shown" if visible else "Trace panel hidden")
+ # Preserve composer focus: expanding the trace panel must never steal focus
+ # from the input unless the user explicitly clicked into the panel.
+ try:
+ self.query_one("#chat_input", Input).focus()
+ except Exception:
+ pass
+
+ def _refresh_trace_chip(self) -> None:
+ try:
+ trace_log = self.query_one("#trace_log", VerticalScroll)
+ chip = self.query_one("#trace_summary_chip", Label)
+ except Exception:
+ return
+ count = len(trace_log.children)
+ last_stage = self._last_trace_stage
+ chip.update(f" trace Β· {count} events Β· {last_stage} " if last_stage else f" trace Β· {count} events ")
def action_toggle_trace_panel(self) -> None:
self._set_trace_panel_visible(not self.state.show_trace_panel)
@@ -757,6 +888,14 @@ def _copy_last_response(self) -> None:
def action_copy_last_response(self) -> None:
self._copy_last_response()
+ @staticmethod
+ def _render_user_markdown(timestamp: str, text: str):
+ safe = text.strip() or "_Empty message._"
+ return Group(
+ Text(f"{timestamp} β’ You", style="bold"),
+ RichMarkdown(safe),
+ )
+
@staticmethod
def _render_agent_markdown(timestamp: str, answer: str):
safe_answer = answer.strip() or "_No response content._"
@@ -777,7 +916,9 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
log.scroll_end(animate=False)
return
ts = datetime.now().strftime("%H:%M:%S")
- log.mount(UserMessage(f"[dim]{ts} β’ You[/]\n{text}"))
+ user_msg = UserMessage("")
+ user_msg.update(self._render_user_markdown(ts, text))
+ log.mount(user_msg)
thinking = SystemMessage("βοΈ Thinkingβ¦")
live_response = AgentMessage(f"[dim]{ts} β’ Motion[/]\n")
log.mount(thinking)
@@ -826,11 +967,13 @@ def _append_trace(self, event_type: str, detail: str = "") -> None:
line = f"[dim]{ts}[/] {label}"
if safe_detail:
line += f" [dim]Β· {safe_detail[:220]}[/]"
+ self._last_trace_stage = f"{label}"
trace_log.mount(SystemMessage(line))
trace_log.scroll_end(animate=False)
self.query_one("#trace_header", Label).update(
f"Interaction Trace ({len(trace_log.children)})"
)
+ self._refresh_trace_chip()
@work(exclusive=True, name="agent_chat")
async def _run_agent(self, prompt: str, thinking: SystemMessage, live_response: AgentMessage) -> None:
@@ -1241,7 +1384,7 @@ class TasksPane(Vertical):
}
#tasks_header_row {
height: auto;
- margin-bottom: 0;
+ margin-bottom: 1;
}
#tasks_header {
color: $primary;
@@ -1367,13 +1510,14 @@ class SkillsPane(Container):
CSS = """
#skills_container {
height: 1fr;
- border: round $primary;
+ border: round $border;
padding: 1;
background: $surface;
}
#skills_header {
color: $primary;
text-style: bold;
+ margin-bottom: 0;
}
#skills_list {
height: 1fr;
@@ -1384,7 +1528,7 @@ class SkillsPane(Container):
padding: 0 0 1 0;
}
#skills_search_input {
- border: round $primary;
+ border: round $border;
}
#skills_editor_row {
height: auto;
@@ -1394,7 +1538,7 @@ class SkillsPane(Container):
margin-right: 1;
}
#skills_content_input {
- border: round $primary;
+ border: round $border;
margin-top: 1;
}
#skills_status {
@@ -1566,13 +1710,14 @@ class KBPane(Container):
CSS = """
#kb_container {
height: 1fr;
- border: round $primary;
+ border: round $border;
padding: 1;
background: $surface;
}
#kb_header {
color: $primary;
text-style: bold;
+ margin-bottom: 0;
}
#kb_list {
height: 1fr;
@@ -1583,7 +1728,7 @@ class KBPane(Container):
padding: 0 0 1 0;
}
#kb_search_input {
- border: round $primary;
+ border: round $border;
}
#kb_add_row {
height: auto;
@@ -1602,7 +1747,7 @@ class KBPane(Container):
#kb_add_area {
height: 5;
margin-top: 1;
- border: round $primary;
+ border: round $border;
}
#kb_mode_select {
margin-top: 1;
@@ -1617,8 +1762,8 @@ class KBPane(Container):
#kb_memory_results {
height: 8;
scrollbar-size: 1 1;
- border: round $border;
- background: $background 25%;
+ border: solid $border;
+ background: $panel;
padding: 0 1;
margin-top: 1;
}
@@ -1819,7 +1964,7 @@ class MemoryPane(Container):
CSS = """
#memory_container {
height: 1fr;
- border: round $primary;
+ border: round $border;
padding: 1;
background: $surface;
}
@@ -1832,15 +1977,17 @@ class MemoryPane(Container):
padding: 0 0 1 0;
}
#memory_search_input {
- border: round $primary;
+ border: round $border;
}
#memory_header {
color: $primary;
text-style: bold;
+ margin-bottom: 0;
}
.memory_entry {
padding: 0 1;
- margin: 0 0;
+ margin: 0 0 1 0;
+ border-top: solid $border;
}
"""
@@ -1900,7 +2047,7 @@ class SettingsPane(Container):
CSS = """
#settings_container {
height: 1fr;
- border: round $primary;
+ border: round $border;
padding: 1 2;
background: $surface;
scrollbar-size: 1 1;
@@ -1987,6 +2134,11 @@ def compose(self) -> ComposeResult:
yield Label(f" Status: {cstatus}", id="settings_caveman")
yield Button("Toggle Caveman", id="btn_toggle_caveman", classes="settings_btn")
+ yield Label("Auto Skill Synthesis", classes="settings_label")
+ syn_status = "ON" if self.state.auto_synthesis_enabled else "OFF"
+ yield Label(f" Status: {syn_status} (crystallize skills from each reply)", id="settings_synthesis")
+ yield Button("Toggle Auto-Synthesis", id="btn_toggle_synthesis", classes="settings_btn")
+
yield Label("Workspace", classes="settings_label")
yield Label(f" {WORKSPACE}", classes="settings_row")
@@ -2062,6 +2214,16 @@ async def on_button_pressed(self, event: Button.Pressed) -> None:
self.query_one("#settings_caveman", Label).update(f" Status: {s}")
self.notify(f"Caveman: {s}")
+ elif bid == "btn_toggle_synthesis":
+ self.state.auto_synthesis_enabled = not self.state.auto_synthesis_enabled
+ if self.state.agent:
+ self.state.agent.auto_skill_synthesis = self.state.auto_synthesis_enabled
+ s = "ON" if self.state.auto_synthesis_enabled else "OFF"
+ self.query_one("#settings_synthesis", Label).update(
+ f" Status: {s} (crystallize skills from each reply)"
+ )
+ self.notify(f"Auto Skill Synthesis: {s}")
+
elif bid == "btn_dashboard":
webbrowser.open(f"{DASHBOARD_URL}?key={DASHBOARD_ADMIN_KEY}")
self.notify("Opening dashboard in browserβ¦")
From 73790f301b93f82a1bf94773922916b738ffe74b Mon Sep 17 00:00:00 2001
From: Mathitz
Date: Sat, 18 Jul 2026 16:38:50 -0300
Subject: [PATCH 2/8] style(tui): bolder visual hierarchy for a cooler, less
amateur UI
---
ui/tui.py | 112 +++++++++++++++++++++++++++++++++++++++++-------------
1 file changed, 86 insertions(+), 26 deletions(-)
diff --git a/ui/tui.py b/ui/tui.py
index 67c0673..f4e20a7 100644
--- a/ui/tui.py
+++ b/ui/tui.py
@@ -192,8 +192,8 @@ class UserMessage(Static):
"""A user message card β primary accent header, markdown body."""
DEFAULT_CSS = """
UserMessage {
- background: $surface;
- border-left: heavy $primary;
+ background: $panel;
+ border-left: thick $primary;
border-right: blank;
border-top: blank;
border-bottom: blank;
@@ -208,8 +208,8 @@ class ReasoningMessage(Static):
"""Collapsible-style block used to surface model reasoning stream."""
DEFAULT_CSS = """
ReasoningMessage {
- background: $panel;
- border-left: heavy $warning;
+ background: $background;
+ border-left: thick $warning;
border-right: blank;
border-top: blank;
border-bottom: blank;
@@ -225,7 +225,7 @@ class AgentMessage(Static):
DEFAULT_CSS = """
AgentMessage {
background: $surface;
- border-left: heavy $accent;
+ border-left: thick $accent;
border-right: blank;
border-top: blank;
border-bottom: blank;
@@ -367,14 +367,17 @@ class ActivityRail(Vertical):
width: 36;
min-width: 30;
max-width: 42;
- border-left: heavy $border;
+ border-left: heavy $primary;
padding: 1 1;
background: $panel;
}
#activity_header {
- color: $accent;
+ color: $primary;
text-style: bold;
margin-bottom: 1;
+ padding: 0 1;
+ background: $surface;
+ border: solid $border;
}
#activity_list {
height: 1fr;
@@ -385,7 +388,7 @@ class ActivityRail(Vertical):
margin: 0 0 1 0;
color: $text;
background: $surface;
- border: round $border;
+ border: solid $border;
}
"""
@@ -551,14 +554,15 @@ class MainScreen(Screen):
CSS = """
#main_shell { height: 1fr; background: $background; }
- #main_body { width: 1fr; padding: 0 1 0 0; }
+ #main_body { width: 1fr; padding: 0 1 0 1; }
#main_tabs { height: 1fr; }
#session_metrics_footer {
height: auto;
color: $text-muted;
- background: $background;
- border-top: solid $border;
+ background: $panel;
+ border-top: heavy $primary;
padding: 0 2;
+ text-style: bold;
}
TabbedContent TabPane {
padding: 0 0;
@@ -716,31 +720,38 @@ class ChatPane(Vertical):
DEFAULT_CSS = """
ChatPane {
height: 1fr;
+ background: $background;
+ padding: 0 0 0 0;
}
#chat_body {
height: 1fr;
- padding: 0 0 1 0;
+ padding: 1 0 1 0;
}
#chat_log {
height: 1fr;
width: 3fr;
- border: round $primary;
+ border: heavy $primary;
+ border-title-color: $primary;
+ border-title-style: bold;
padding: 1 2;
scrollbar-size: 1 1;
background: $surface;
}
#trace_panel {
- width: 42;
+ width: 40;
height: 1fr;
- border: round $border;
+ border: solid $border;
background: $panel;
padding: 1 1;
margin-left: 1;
}
#trace_header {
- color: $text-muted;
+ color: $accent;
text-style: bold;
- margin-bottom: 0;
+ margin-bottom: 1;
+ padding: 0 1;
+ background: $surface;
+ border: solid $border;
}
#trace_log {
height: 1fr;
@@ -754,14 +765,15 @@ class ChatPane(Vertical):
padding: 0 2;
margin: 0 0 1 0;
background: $panel;
- border: round $border;
- color: $text-muted;
+ border: heavy $accent;
+ color: $accent;
+ text-style: bold;
}
#chat_input_row {
height: auto;
padding: 1 1 1 1;
background: $panel;
- border: solid $border;
+ border: heavy $primary;
}
#chat_controls {
height: auto;
@@ -781,16 +793,25 @@ class ChatPane(Vertical):
color: $text-muted;
margin: 0 0 0 0;
padding: 0 2;
+ text-style: dim;
}
#chat_input {
height: 3;
- border: round $primary;
- background: $surface;
+ border: heavy $primary;
+ background: $background;
+ color: $text;
width: 1fr;
+ padding: 0 1;
}
.chat_btn {
margin-left: 1;
min-width: 10;
+ background: $surface;
+ border: solid $border;
+ }
+ .chat_btn:hover {
+ border: solid $primary;
+ color: $primary;
}
"""
@@ -892,7 +913,8 @@ def action_copy_last_response(self) -> None:
def _render_user_markdown(timestamp: str, text: str):
safe = text.strip() or "_Empty message._"
return Group(
- Text(f"{timestamp} β’ You", style="bold"),
+ Text(f"βΈ {timestamp} YOU", style="bold"),
+ Text(""),
RichMarkdown(safe),
)
@@ -900,7 +922,8 @@ def _render_user_markdown(timestamp: str, text: str):
def _render_agent_markdown(timestamp: str, answer: str):
safe_answer = answer.strip() or "_No response content._"
return Group(
- Text(f"{timestamp} β’ Motion", style="dim"),
+ Text(f"β {timestamp} MOTION", style="bold"),
+ Text(""),
RichMarkdown(safe_answer),
)
@@ -2249,9 +2272,46 @@ class MotionTUI(App):
CSS = """
Screen { background: $background; color: $foreground; }
- Header { background: $surface; }
- Footer { background: $surface; }
+ Header {
+ background: $surface;
+ border-bottom: heavy $primary;
+ color: $text;
+ text-style: bold;
+ padding: 0 1;
+ }
+ Header.-header-tall { height: 3; }
+ Footer {
+ background: $panel;
+ border-top: heavy $primary;
+ color: $text-muted;
+ padding: 0 1;
+ }
TabbedContent { height: 1fr; }
+ Tabs {
+ background: $panel;
+ height: 3;
+ border-bottom: solid $border;
+ }
+ Tabs > Tab {
+ padding: 0 2;
+ color: $text-muted;
+ text-style: bold;
+ background: $panel;
+ border: blank;
+ }
+ Tabs > Tab:hover {
+ color: $text;
+ background: $surface;
+ }
+ Tabs > Tab.-active {
+ color: $background;
+ background: $primary;
+ text-style: bold;
+ }
+ TabPane {
+ background: $background;
+ padding: 0 0;
+ }
.experimental-ui TaskRow {
padding: 1 2;
margin: 0 0 1 0;
From db66ca312120d9026f08ca7fb9addb331e6466be Mon Sep 17 00:00:00 2001
From: Mathitz
Date: Sun, 19 Jul 2026 00:42:20 -0300
Subject: [PATCH 3/8] fix: chat input text visibility via ChatInputMirror
---
ui/themes.py | 19 +-
ui/tui.py | 614 ++++++++++++++++++++++++++++++++-------------------
2 files changed, 399 insertions(+), 234 deletions(-)
diff --git a/ui/themes.py b/ui/themes.py
index dd57cd7..206a0a8 100644
--- a/ui/themes.py
+++ b/ui/themes.py
@@ -18,6 +18,23 @@
# 2. A lightweight dataclass-style Theme (backward compat for ThemeRegistry)
_RAW_THEMES: List[dict] = [
+ dict(
+ id="omni_dark",
+ name="Omni Dark",
+ background="#191622",
+ surface="#23212B",
+ panel="#2A2734",
+ foreground="#E1E1E6",
+ primary="#78D1E1",
+ secondary="#483C67",
+ accent="#78D1E1",
+ border="#41414D",
+ highlight="#2E2A3A",
+ error="#E96379",
+ success="#67E480",
+ warning="#E89E64",
+ dark=True,
+ ),
dict(
id="one_dark",
name="One Dark",
@@ -119,7 +136,7 @@ def __init__(self, name: str, background: str, foreground: str,
success=d["success"],
warning=d["warning"],
dark=d["dark"],
- panel=d["border"],
+ panel=d.get("panel", d["border"]),
boost=d["highlight"],
)
diff --git a/ui/tui.py b/ui/tui.py
index f4e20a7..d67b760 100644
--- a/ui/tui.py
+++ b/ui/tui.py
@@ -14,6 +14,15 @@
- Ctrl+C cancels the current request; Ctrl+Q quits
- KB tab: knowledge base for reference docs that don't become skills
+Visual guardrails (R3 β Ops Rounded):
+ - Maximum one permanent border per major region.
+ - No adjacent parallel separators within 1 row of each other.
+ - Spacing scale: 0, 1, 2 row gaps (2 reserved for section breaks).
+ - Persistent accent area target below ~8-10% of screen.
+ - Rounded corners on conversation bubbles and grouped setting blocks.
+ - Both User and Motion replies render as explicit rounded balloons.
+ - Accent is reserved for active tab, focused input, and high-signal state.
+
Launch: python main.py β TUI (default)
python main.py --chat β old REPL
python main.py --provider X β TUI with pre-selected provider
@@ -37,6 +46,8 @@
from textual.binding import Binding
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
from textual.screen import Screen
+from textual.strip import Strip
+from textual.widget import Widget
from textual.widgets import (
Button,
Header,
@@ -86,7 +97,7 @@ def __init__(self) -> None:
self.task_manager: Optional[TaskManager] = None
self.config_manager: ConfigManager = ConfigManager()
self.current_provider_id: str = ""
- self.current_theme: str = "one_dark"
+ self.current_theme: str = "omni_dark"
self.caveman_enabled: bool = True
self.auto_synthesis_enabled: bool = False
self.ui_mode: str = "conservative"
@@ -189,49 +200,39 @@ def _extract_reasoning_and_answer(text: str) -> tuple[str, str]:
# βββ Chat message widgets βββββββββββββββββββββββββββββββββββββββββββββββββββββ
class UserMessage(Static):
- """A user message card β primary accent header, markdown body."""
+ """User reply balloon β rounded panel surface, aligned right."""
DEFAULT_CSS = """
UserMessage {
background: $panel;
- border-left: thick $primary;
- border-right: blank;
- border-top: blank;
- border-bottom: blank;
- padding: 1 2;
- margin: 1 8 0 0;
color: $text;
+ border: round $panel;
+ padding: 1 2;
+ margin: 1 6 0 0;
}
"""
-
class ReasoningMessage(Static):
- """Collapsible-style block used to surface model reasoning stream."""
+ """Inline reasoning hint β same structural balloon, no heavy rule."""
DEFAULT_CSS = """
ReasoningMessage {
background: $background;
- border-left: thick $warning;
- border-right: blank;
- border-top: blank;
- border-bottom: blank;
- padding: 1 2;
- margin: 1 0 0 8;
color: $text-muted;
text-style: dim italic;
+ border: round $background;
+ padding: 1 2;
+ margin: 1 0 1 6;
}
"""
class AgentMessage(Static):
- """An agent message card β accent header, markdown body."""
+ """Motion reply balloon β rounded surface, aligned left."""
DEFAULT_CSS = """
AgentMessage {
background: $surface;
- border-left: thick $accent;
- border-right: blank;
- border-top: blank;
- border-bottom: blank;
- padding: 1 2;
- margin: 1 0 0 8;
color: $text;
+ border: round $surface;
+ padding: 1 2;
+ margin: 1 0 1 4;
}
"""
@@ -241,12 +242,75 @@ class SystemMessage(Static):
SystemMessage {
color: $text-muted;
text-style: dim;
- padding: 0 1;
- margin: 0 0 0 0;
+ padding: 0 2;
+ margin: 0 0 1 0;
}
"""
+class ChatInput(Input):
+ """Chat composer input with explicit visible text rendering."""
+
+ def render_line(self, y: int) -> Strip:
+ if y != 0:
+ return Strip.blank(self.size.width)
+
+ width = max(1, self.scrollable_content_region.width)
+ if self.value:
+ text = Text(self.value, style="#E1E1E6", no_wrap=True, overflow="crop", end="")
+ else:
+ text = Text(self.placeholder, style="dim #A8A4B8", no_wrap=True, overflow="crop", end="")
+
+ if self.has_focus and self._cursor_visible:
+ cursor = self.cursor_position if self.value else 0
+ if cursor >= len(text):
+ text.append(" ")
+ text.stylize("bold #191622 on #78D1E1", cursor, cursor + 1)
+
+ segments = list(self.app.console.render(text, self.app.console.options.update_width(width)))
+ strip = Strip(segments)
+ return strip.crop(0, width).extend_cell_length(width).apply_style(self.rich_style)
+class ChatInputMirror(Widget):
+ """Visible composer text surface mirrored from the real input widget."""
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self._value = ""
+ self._cursor = 0
+ self._focused = False
+
+ def set_input_state(self, value: str, cursor: int, focused: bool) -> None:
+ self._value = value
+ self._cursor = max(0, min(cursor, len(value)))
+ self._focused = focused
+ self.refresh()
+
+ def render_line(self, y: int) -> Strip:
+ width = max(1, self.scrollable_content_region.width)
+ if y != 0:
+ return Strip.blank(width)
+
+ if self._value:
+ text = Text(self._value, style="#E1E1E6", no_wrap=True, overflow="crop", end="")
+ if self._focused:
+ cursor = self._cursor
+ if cursor >= len(text):
+ text.append(" ")
+ text.stylize("bold #191622 on #78D1E1", cursor, cursor + 1)
+ else:
+ text = Text("Type a message⦠(Enter to send)", style="dim #A8A4B8", no_wrap=True, overflow="crop", end="")
+ if self._focused:
+ text.stylize("bold #191622 on #78D1E1", 0, 1)
+
+ segments = list(self.app.console.render(text, self.app.console.options.update_width(width)))
+ strip = Strip(segments)
+ return strip.crop(0, width).extend_cell_length(width).apply_style(self.rich_style)
+
+ def on_click(self) -> None:
+ try:
+ self.screen.query_one("#chat_input", Input).focus()
+ except Exception:
+ pass
+
class ProviderOption(ListItem):
"""A selectable provider row on the startup screen."""
@@ -275,7 +339,7 @@ class ProviderSelectScreen(Screen):
width: 72;
height: auto;
max-height: 85%;
- border: round $primary;
+ border: round $border;
padding: 1 3;
background: $surface;
overflow-y: auto;
@@ -283,7 +347,7 @@ class ProviderSelectScreen(Screen):
#provider_title {
text-align: center;
text-style: bold;
- color: $primary;
+ color: $text;
margin-bottom: 0;
}
#provider_subtitle {
@@ -367,17 +431,18 @@ class ActivityRail(Vertical):
width: 36;
min-width: 30;
max-width: 42;
- border-left: heavy $primary;
+ border-left: blank;
+ margin-left: 2;
padding: 1 1;
background: $panel;
}
#activity_header {
- color: $primary;
+ color: $text-muted;
text-style: bold;
- margin-bottom: 1;
+ margin-bottom: 2;
padding: 0 1;
- background: $surface;
- border: solid $border;
+ background: transparent;
+ border: blank;
}
#activity_list {
height: 1fr;
@@ -387,8 +452,11 @@ class ActivityRail(Vertical):
padding: 0 1;
margin: 0 0 1 0;
color: $text;
- background: $surface;
- border: solid $border;
+ background: transparent;
+ border: blank;
+ }
+ .activity_row:first-child {
+ border-top: blank;
}
"""
@@ -397,7 +465,7 @@ def __init__(self, state: AppState, **kwargs) -> None:
self.state = state
def compose(self) -> ComposeResult:
- yield Label("β‘ Live Activity", id="activity_header")
+ yield Label("Live Activity", id="activity_header")
yield VerticalScroll(id="activity_list")
def on_mount(self) -> None:
@@ -413,7 +481,7 @@ def refresh_activity(self) -> None:
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")
+ 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):
@@ -449,13 +517,13 @@ class ShortcutsOverlay(Screen):
#shortcuts_box {
width: 78;
max-height: 80%;
- border: round $primary;
+ border: round $border;
background: $surface;
padding: 1 2;
scrollbar-size: 1 1;
}
#shortcuts_title {
- color: $primary;
+ color: $text;
text-style: bold;
text-align: center;
margin-bottom: 1;
@@ -466,7 +534,7 @@ class ShortcutsOverlay(Screen):
scrollbar-size: 1 1;
}
.shortcut_section {
- color: $accent;
+ color: $text-muted;
text-style: bold;
margin-top: 1;
margin-bottom: 0;
@@ -559,11 +627,12 @@ class MainScreen(Screen):
#session_metrics_footer {
height: auto;
color: $text-muted;
- background: $panel;
- border-top: heavy $primary;
+ background: $background;
+ border-top: blank;
padding: 0 2;
- text-style: bold;
+ text-style: dim;
}
+ TabbedContent { height: 1fr; }
TabbedContent TabPane {
padding: 0 0;
}
@@ -607,17 +676,17 @@ def compose(self) -> ComposeResult:
with Horizontal(id="main_shell"):
with Vertical(id="main_body"):
with TabbedContent(id="main_tabs"):
- with TabPane("π¬ Chat", id="chat_tab"):
+ with TabPane("Chat", id="chat_tab"):
yield ChatPane(self.state)
- with TabPane("βοΈ Tasks", id="tasks_tab"):
+ with TabPane("Tasks", id="tasks_tab"):
yield TasksPane(self.state)
- with TabPane("π Skills", id="skills_tab"):
+ with TabPane("Skills", id="skills_tab"):
yield SkillsPane(self.state)
- with TabPane("π KB", id="kb_tab"):
+ with TabPane("KB", id="kb_tab"):
yield KBPane(self.state)
- with TabPane("π§ Memory", id="memory_tab"):
+ with TabPane("Memory", id="memory_tab"):
yield MemoryPane(self.state)
- with TabPane("π§ Settings", id="settings_tab"):
+ with TabPane("Settings", id="settings_tab"):
yield SettingsPane(self.state)
yield ActivityRail(self.state, id="activity_rail")
yield Label("", id="session_metrics_footer")
@@ -730,9 +799,7 @@ class ChatPane(Vertical):
#chat_log {
height: 1fr;
width: 3fr;
- border: heavy $primary;
- border-title-color: $primary;
- border-title-style: bold;
+ border: blank;
padding: 1 2;
scrollbar-size: 1 1;
background: $surface;
@@ -740,54 +807,38 @@ class ChatPane(Vertical):
#trace_panel {
width: 40;
height: 1fr;
- border: solid $border;
+ border: blank;
background: $panel;
padding: 1 1;
margin-left: 1;
}
#trace_header {
- color: $accent;
+ color: $text-muted;
text-style: bold;
margin-bottom: 1;
padding: 0 1;
- background: $surface;
- border: solid $border;
+ background: transparent;
+ border: blank;
}
#trace_log {
height: 1fr;
scrollbar-size: 1 1;
- border-top: solid $border;
padding-top: 1;
}
+ #trace_log > * {
+ border-top: blank;
+ padding-top: 0;
+ margin-top: 0;
+ }
#trace_summary_chip {
height: auto;
width: auto;
padding: 0 2;
margin: 0 0 1 0;
- background: $panel;
- border: heavy $accent;
- color: $accent;
- text-style: bold;
- }
- #chat_input_row {
- height: auto;
- padding: 1 1 1 1;
- background: $panel;
- border: heavy $primary;
- }
- #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;
+ background: transparent;
+ border: blank;
+ color: $text-muted;
+ text-style: dim;
}
#chat_metrics {
color: $text-muted;
@@ -795,23 +846,52 @@ class ChatPane(Vertical):
padding: 0 2;
text-style: dim;
}
+ #chat_composer {
+ height: 5;
+ min-height: 5;
+ max-height: 5;
+ padding: 1 1 1 1;
+ background: $panel;
+ border: round $panel;
+ margin: 1 1 0 1;
+ }
#chat_input {
height: 3;
- border: heavy $primary;
- background: $background;
- color: $text;
+ width: 1;
+ min-width: 1;
+ max-width: 1;
+ border: tall #41414D;
+ background: #2A2734;
+ color: #2A2734;
+ padding: 0 0;
+ }
+ #chat_input_mirror {
+ height: 1fr;
width: 1fr;
- padding: 0 1;
+ background: #23212B;
+ color: #E1E1E6;
+ padding: 0 2;
+ }
+ #chat_input:focus {
+ border: tall #2A2734;
+ background: #2A2734;
+ color: #2A2734;
}
.chat_btn {
+ height: 3;
margin-left: 1;
min-width: 10;
- background: $surface;
- border: solid $border;
+ background: transparent;
+ border: blank;
+ color: $text-muted;
}
.chat_btn:hover {
- border: solid $primary;
- color: $primary;
+ background: $surface;
+ color: $text;
+ }
+ .chat_btn:focus {
+ border-bottom: solid $primary;
+ color: $text;
}
"""
@@ -828,8 +908,9 @@ def compose(self) -> ComposeResult:
yield VerticalScroll(id="trace_log")
yield Label("", id="trace_summary_chip")
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_composer"):
+ yield ChatInputMirror(id="chat_input_mirror")
+ yield ChatInput(placeholder="Type a message⦠(Enter to send)", id="chat_input")
with Horizontal(id="chat_controls"):
with Horizontal(id="chat_primary_actions"):
yield Button("Trace", id="chat_toggle_trace_btn", classes="chat_btn")
@@ -845,13 +926,37 @@ def on_click(self, event) -> None:
def on_mount(self) -> None:
name = self.state.agent.provider.config.name if self.state.agent else "?"
log = self.query_one("#chat_log", VerticalScroll)
- log.border_title = " Chat "
log.mount(SystemMessage(f"β‘ Motion Harness β connected to {name}"))
log.mount(SystemMessage("Tip: Ctrl/Alt+1..6 or F1-F6 tabs Β· F8 Trace Β· F9 Copy Β· /skill save "))
self._append_trace("session_start", f"provider={name}")
self._set_trace_panel_visible(self.state.show_trace_panel)
self._refresh_metrics_bar()
self.query_one("#chat_input", Input).focus()
+ self._refresh_input_mirror()
+
+ def _refresh_input_mirror(self) -> None:
+ try:
+ input_box = self.query_one("#chat_input", Input)
+ mirror = self.query_one("#chat_input_mirror", ChatInputMirror)
+ except Exception:
+ return
+ mirror.set_input_state(
+ value=input_box.value,
+ cursor=input_box.cursor_position,
+ focused=input_box.has_focus,
+ )
+
+ def on_input_changed(self, event: Input.Changed) -> None:
+ if event.input.id == "chat_input":
+ self._refresh_input_mirror()
+
+ def on_descendant_focus(self, event) -> None:
+ if getattr(getattr(event, "widget", None), "id", None) == "chat_input":
+ self._refresh_input_mirror()
+
+ def on_descendant_blur(self, event) -> None:
+ if getattr(getattr(event, "widget", None), "id", None) == "chat_input":
+ self._refresh_input_mirror()
def _set_trace_panel_visible(self, visible: bool) -> None:
self.state.show_trace_panel = visible
@@ -913,7 +1018,7 @@ def action_copy_last_response(self) -> None:
def _render_user_markdown(timestamp: str, text: str):
safe = text.strip() or "_Empty message._"
return Group(
- Text(f"βΈ {timestamp} YOU", style="bold"),
+ Text(f"{timestamp} YOU", style="bold"),
Text(""),
RichMarkdown(safe),
)
@@ -922,7 +1027,7 @@ def _render_user_markdown(timestamp: str, text: str):
def _render_agent_markdown(timestamp: str, answer: str):
safe_answer = answer.strip() or "_No response content._"
return Group(
- Text(f"β {timestamp} MOTION", style="bold"),
+ Text(f"{timestamp} MOTION", style="bold"),
Text(""),
RichMarkdown(safe_answer),
)
@@ -932,6 +1037,7 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
if not text:
return
event.input.value = ""
+ self._refresh_input_mirror()
log = self.query_one("#chat_log", VerticalScroll)
if text.startswith("/skill"):
@@ -942,8 +1048,9 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
user_msg = UserMessage("")
user_msg.update(self._render_user_markdown(ts, text))
log.mount(user_msg)
- thinking = SystemMessage("βοΈ Thinkingβ¦")
- live_response = AgentMessage(f"[dim]{ts} β’ Motion[/]\n")
+ thinking = SystemMessage("Thinkingβ¦")
+ live_response = AgentMessage("")
+ live_response.update(self._render_agent_markdown(ts, "_Waiting for responseβ¦_"))
log.mount(thinking)
log.mount(live_response)
log.scroll_end(animate=False)
@@ -1004,7 +1111,6 @@ async def _run_agent(self, prompt: str, thinking: SystemMessage, live_response:
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])
@@ -1025,10 +1131,10 @@ async def on_stream_chunk(chunk: str) -> None:
reasoning, answer = _extract_reasoning_and_answer(current_raw)
if reasoning:
if reasoning_widget is None:
- reasoning_widget = ReasoningMessage("[dim]Reasoning stream[/]\n")
+ reasoning_widget = ReasoningMessage("")
log.mount(reasoning_widget, before=live_response)
- reasoning_widget.update(f"[dim]Reasoning[/]\n{reasoning[:2500]}")
- live_response.update(header + (answer or ""))
+ reasoning_widget.update(self._render_agent_markdown(header_ts, f"[dim]Reasoning[/]\n{reasoning[:2500]}"))
+ live_response.update(self._render_agent_markdown(header_ts, answer or ""))
async def on_trace_event(*args) -> None:
event_type = "trace"
payload: Dict[str, Any] = {}
@@ -1194,7 +1300,7 @@ class TaskDetailScreen(Screen):
#detail_box {
width: 80;
height: 85%;
- border: round $primary;
+ border: round $border;
background: $surface;
padding: 1 2;
overflow-y: auto;
@@ -1202,14 +1308,14 @@ class TaskDetailScreen(Screen):
}
#detail_header {
text-style: bold;
- color: $primary;
+ color: $text;
margin-bottom: 1;
}
#detail_back_btn {
margin-right: 1;
}
.detail_section {
- color: $primary;
+ color: $text-muted;
text-style: bold;
margin-top: 1;
margin-bottom: 0;
@@ -1351,14 +1457,14 @@ class TaskRow(Static):
DEFAULT_CSS = """
TaskRow {
- padding: 1 1;
+ padding: 1 1 1 2;
margin: 0 0 1 0;
- background: $background 30%;
- border: round $border;
+ background: $surface;
+ border: blank;
}
TaskRow:hover {
background: $primary 15%;
- border: round $primary;
+ color: $text;
}
"""
@@ -1398,19 +1504,21 @@ class TasksPane(Vertical):
DEFAULT_CSS = """
TasksPane {
height: 1fr;
+ padding: 1 1 0 1;
}
#tasks_container {
height: 1fr;
- border: round $border;
+ border: blank;
padding: 1;
background: $surface;
}
#tasks_header_row {
height: auto;
margin-bottom: 1;
+ padding: 0 1;
}
#tasks_header {
- color: $primary;
+ color: $text-muted;
text-style: bold;
width: 1fr;
}
@@ -1421,13 +1529,19 @@ class TasksPane(Vertical):
#task_list {
height: 1fr;
scrollbar-size: 1 1;
+ padding: 0 1;
}
#task_input_row {
height: auto;
- padding: 1 0 0 0;
+ padding: 1 1 1 1;
+ background: $panel;
}
#task_input {
- border: round $border;
+ border: blank;
+ background: $background;
+ }
+ #task_input:focus {
+ border-bottom: solid $primary;
}
"""
@@ -1438,7 +1552,7 @@ def __init__(self, state: AppState, **kwargs) -> None:
def compose(self) -> ComposeResult:
with Vertical(id="tasks_container"):
with Horizontal(id="tasks_header_row"):
- yield Label("βοΈ Tasks", id="tasks_header")
+ yield Label("Tasks", id="tasks_header")
yield Label("", id="tasks_count")
yield VerticalScroll(id="task_list")
with Horizontal(id="task_input_row"):
@@ -1503,7 +1617,7 @@ def _update_header(self) -> None:
running = sum(1 for t in s["tasks"].values() if t.status == "RUNNING")
done = sum(1 for t in s["tasks"].values() if t.status in ("COMPLETED", "FAILED"))
try:
- self.query_one("#tasks_header", Label).update("βοΈ Tasks")
+ self.query_one("#tasks_header", Label).update("Tasks")
self.query_one("#tasks_count", Label).update(
f"{running} running Β· {done}/{total} done" if total else "no tasks yet"
)
@@ -1533,40 +1647,57 @@ class SkillsPane(Container):
CSS = """
#skills_container {
height: 1fr;
- border: round $border;
+ border: blank;
padding: 1;
background: $surface;
}
#skills_header {
- color: $primary;
+ color: $text-muted;
text-style: bold;
- margin-bottom: 0;
+ margin-bottom: 1;
+ padding: 0 1;
}
#skills_list {
height: 1fr;
scrollbar-size: 1 1;
+ padding: 0 1;
}
#skills_search {
height: auto;
- padding: 0 0 1 0;
+ padding: 0 1 1 1;
}
#skills_search_input {
- border: round $border;
+ border: blank;
+ background: $background;
+ }
+ #skills_search_input:focus {
+ border-bottom: solid $primary;
}
#skills_editor_row {
height: auto;
- padding: 1 0 0 0;
+ padding: 1 1 0 1;
+ background: $panel;
}
#skills_title_input {
margin-right: 1;
+ border: blank;
+ background: $background;
+ }
+ #skills_title_input:focus {
+ border-bottom: solid $primary;
}
#skills_content_input {
- border: round $border;
+ border: blank;
+ background: $background;
margin-top: 1;
}
+ #skills_content_input:focus {
+ border-bottom: solid $primary;
+ }
#skills_status {
color: $text-muted;
margin-top: 1;
+ padding: 0 1;
}
.skill_entry {
padding: 0 1;
@@ -1580,7 +1711,7 @@ def __init__(self, state: AppState, **kwargs) -> None:
def compose(self) -> ComposeResult:
with Vertical(id="skills_container"):
- yield Label("π Crystallized Skills", id="skills_header")
+ yield Label("Skills", id="skills_header")
with Horizontal(id="skills_search"):
yield Input(placeholder="Search skillsβ¦", id="skills_search_input")
with Horizontal(id="skills_editor_row"):
@@ -1595,7 +1726,6 @@ def compose(self) -> ComposeResult:
yield VerticalScroll(id="skills_list")
def on_mount(self) -> None:
- self.query_one("#skills_container", Vertical).border_title = " Skills "
self._load_skills()
def _load_skills(self, query: str = "") -> None:
@@ -1733,33 +1863,45 @@ class KBPane(Container):
CSS = """
#kb_container {
height: 1fr;
- border: round $border;
+ border: blank;
padding: 1;
background: $surface;
}
#kb_header {
- color: $primary;
+ color: $text-muted;
text-style: bold;
- margin-bottom: 0;
+ margin-bottom: 1;
+ padding: 0 1;
}
#kb_list {
height: 1fr;
scrollbar-size: 1 1;
+ padding: 0 1;
}
#kb_search {
height: auto;
- padding: 0 0 1 0;
+ padding: 0 1 1 1;
}
#kb_search_input {
- border: round $border;
+ border: blank;
+ background: $background;
+ }
+ #kb_search_input:focus {
+ border-bottom: solid $primary;
}
#kb_add_row {
height: auto;
- padding: 0 0 0 0;
+ padding: 1 1 0 1;
+ background: $panel;
}
#kb_add_title {
height: 3;
margin-right: 1;
+ border: blank;
+ background: $background;
+ }
+ #kb_add_title:focus {
+ border-bottom: solid $primary;
}
#kb_add_btn {
margin-right: 1;
@@ -1770,22 +1912,32 @@ class KBPane(Container):
#kb_add_area {
height: 5;
margin-top: 1;
- border: round $border;
+ border: blank;
+ background: $background;
+ }
+ #kb_add_area:focus {
+ border-bottom: solid $primary;
}
#kb_mode_select {
margin-top: 1;
+ padding: 0 1;
}
#kb_memory_search_row {
height: auto;
margin-top: 1;
+ padding: 0 1;
}
#kb_memory_search_input {
- border: round $border;
+ border: blank;
+ background: $background;
+ }
+ #kb_memory_search_input:focus {
+ border-bottom: solid $primary;
}
#kb_memory_results {
height: 8;
scrollbar-size: 1 1;
- border: solid $border;
+ border: blank;
background: $panel;
padding: 0 1;
margin-top: 1;
@@ -1793,6 +1945,7 @@ class KBPane(Container):
#kb_status {
color: $text-muted;
margin-top: 1;
+ padding: 0 1;
}
.kb_entry {
padding: 0 1;
@@ -1806,7 +1959,7 @@ def __init__(self, state: AppState, **kwargs) -> None:
def compose(self) -> ComposeResult:
with Vertical(id="kb_container"):
- yield Label("π Knowledge Base", id="kb_header")
+ yield Label("Knowledge Base", id="kb_header")
with Horizontal(id="kb_search"):
yield Input(placeholder="Search knowledge baseβ¦", id="kb_search_input")
yield VerticalScroll(id="kb_list")
@@ -1828,7 +1981,6 @@ def compose(self) -> ComposeResult:
yield Label("KB indexing mode controls whether entries are annexed into memory.", id="kb_status")
def on_mount(self) -> None:
- self.query_one("#kb_container", Vertical).border_title = " Knowledge Base "
self._load_kb()
def _load_kb(self, query: str = "") -> None:
@@ -1987,30 +2139,36 @@ class MemoryPane(Container):
CSS = """
#memory_container {
height: 1fr;
- border: round $border;
+ border: blank;
padding: 1;
background: $surface;
}
#memory_results {
height: 1fr;
scrollbar-size: 1 1;
+ padding: 0 1;
}
#memory_search_row {
height: auto;
- padding: 0 0 1 0;
+ padding: 0 1 1 1;
}
#memory_search_input {
- border: round $border;
+ border: blank;
+ background: $background;
+ }
+ #memory_search_input:focus {
+ border-bottom: solid $primary;
}
#memory_header {
- color: $primary;
+ color: $text-muted;
text-style: bold;
- margin-bottom: 0;
+ margin-bottom: 1;
+ padding: 0 1;
}
.memory_entry {
padding: 0 1;
margin: 0 0 1 0;
- border-top: solid $border;
+ border-top: blank;
}
"""
@@ -2020,13 +2178,13 @@ def __init__(self, state: AppState, **kwargs) -> None:
def compose(self) -> ComposeResult:
with Vertical(id="memory_container"):
- yield Label("π§ Memory Search", id="memory_header")
+ yield Label("Memory Search", id="memory_header")
with Horizontal(id="memory_search_row"):
yield Input(placeholder="Search memories⦠(Enter to search)", id="memory_search_input")
yield VerticalScroll(id="memory_results")
def on_mount(self) -> None:
- self.query_one("#memory_container", Vertical).border_title = " Memory "
+ pass
async def on_input_submitted(self, event: Input.Submitted) -> None:
query = event.value.strip()
@@ -2070,46 +2228,48 @@ class SettingsPane(Container):
CSS = """
#settings_container {
height: 1fr;
- border: round $border;
+ border: blank;
padding: 1 2;
- background: $surface;
+ background: $background;
scrollbar-size: 1 1;
}
+ .settings_card {
+ height: auto;
+ padding: 1 2;
+ margin-top: 1;
+ background: $surface;
+ border: round $surface;
+ }
.settings_label {
text-style: bold;
- color: $primary;
- margin-top: 1;
+ color: $text;
margin-bottom: 0;
}
- .settings_section {
- margin-top: 0;
- margin-bottom: 1;
- padding: 0 0;
- }
.settings_row {
margin-top: 0;
height: auto;
color: $text-muted;
}
- #provider_select {
- margin-top: 0;
- margin-bottom: 0;
- }
- #theme_select {
- margin-top: 0;
- margin-bottom: 0;
- }
.settings_btn {
margin-top: 0;
margin-right: 1;
+ background: transparent;
+ border: blank;
+ color: $text-muted;
}
- #settings_provider {
- color: $foreground;
+ .settings_btn:hover {
+ background: $panel;
+ color: $text;
}
- #settings_caveman {
- color: $foreground;
+ .settings_btn:focus {
+ border-bottom: solid $primary;
+ color: $text;
}
- #settings_workers {
+ #provider_select, #theme_select, #ui_mode_select {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+ #settings_provider, #settings_caveman, #settings_synthesis, #settings_activity_rail, #settings_workers {
color: $text-muted;
}
"""
@@ -2120,57 +2280,55 @@ def __init__(self, state: AppState, **kwargs) -> None:
def compose(self) -> ComposeResult:
with VerticalScroll(id="settings_container"):
- yield Label("π§ Settings", classes="settings_label")
-
- yield Label("Provider / Model", classes="settings_label")
- yield Label(" Switch the active model at runtime:", classes="settings_row")
- options = AppState.build_provider_options()
- valid_values = {v for _, v in options}
- default_val = self.state.current_provider_id if self.state.current_provider_id in valid_values else (options[0][1] if options else Select.BLANK)
- yield Select(options, value=default_val, id="provider_select")
- active_label = default_val
- for label, val in options:
- if val == default_val:
- active_label = label
- break
- yield Label(f" Active: {active_label}", id="settings_provider")
-
- yield Label("Theme", classes="settings_label")
- theme_options = [(ThemeRegistry.get_theme(tid).name, tid) for tid in ThemeRegistry.theme_ids()]
- yield Select(theme_options, value=self.state.current_theme, id="theme_select")
- yield Button("Toggle Theme (Ctrl+T)", id="btn_toggle_theme", classes="settings_btn")
-
- yield Label("Interface Style", classes="settings_label")
- yield Select(
- [("Conservative", "conservative"), ("Experimental", "experimental")],
- value=self.state.ui_mode,
- id="ui_mode_select",
- )
-
- yield Label("Global Activity Rail", classes="settings_label")
- rail_state = "ON" if self.state.show_activity_rail else "OFF"
- yield Label(f" Status: {rail_state} (Ctrl+B)", id="settings_activity_rail")
- yield Button("Toggle Activity Rail", id="btn_toggle_activity_rail", classes="settings_btn")
-
- yield Label("Caveman Compression", classes="settings_label")
- cstatus = "ON" if self.state.caveman_enabled else "OFF"
- yield Label(f" Status: {cstatus}", id="settings_caveman")
- yield Button("Toggle Caveman", id="btn_toggle_caveman", classes="settings_btn")
-
- yield Label("Auto Skill Synthesis", classes="settings_label")
- syn_status = "ON" if self.state.auto_synthesis_enabled else "OFF"
- yield Label(f" Status: {syn_status} (crystallize skills from each reply)", id="settings_synthesis")
- yield Button("Toggle Auto-Synthesis", id="btn_toggle_synthesis", classes="settings_btn")
-
- yield Label("Workspace", classes="settings_label")
- yield Label(f" {WORKSPACE}", classes="settings_row")
-
- yield Label("Dashboard", classes="settings_label")
- yield Button("Open Dashboard β", id="btn_dashboard", classes="settings_btn")
+ with Container(classes="settings_card"):
+ yield Label("Provider / Model", classes="settings_label")
+ yield Label("Switch the active model at runtime", classes="settings_row")
+ options = AppState.build_provider_options()
+ valid_values = {v for _, v in options}
+ default_val = self.state.current_provider_id if self.state.current_provider_id in valid_values else (options[0][1] if options else Select.BLANK)
+ yield Select(options, value=default_val, id="provider_select")
+ active_label = default_val
+ for label, val in options:
+ if val == default_val:
+ active_label = label
+ break
+ yield Label(f"Active: {active_label}", id="settings_provider")
+
+ with Container(classes="settings_card"):
+ yield Label("Theme", classes="settings_label")
+ yield Label("Select the visual theme", classes="settings_row")
+ theme_options = [(ThemeRegistry.get_theme(tid).name, tid) for tid in ThemeRegistry.theme_ids()]
+ yield Select(theme_options, value=self.state.current_theme, id="theme_select")
+ yield Button("Cycle theme", id="btn_toggle_theme", classes="settings_btn")
+
+ with Container(classes="settings_card"):
+ yield Label("Interface Style", classes="settings_label")
+ yield Label("Toggle experimental visual mode", classes="settings_row")
+ yield Select(
+ [("Conservative", "conservative"), ("Experimental", "experimental")],
+ value=self.state.ui_mode,
+ id="ui_mode_select",
+ )
- yield Label("Workers", classes="settings_label")
- w = f" {os.cpu_count() * 2} max (CPU-aware)" if self.state.task_manager else " Not initialized"
- yield Label(w, id="settings_workers")
+ with Container(classes="settings_card"):
+ yield Label("Options", classes="settings_label")
+ yield Label("Workspace toggles and status", classes="settings_row")
+ rail_state = "ON" if self.state.show_activity_rail else "OFF"
+ yield Label(f"Activity rail: {rail_state} (Ctrl+B)", id="settings_activity_rail")
+ yield Button("Toggle rail", id="btn_toggle_activity_rail", classes="settings_btn")
+ cstatus = "ON" if self.state.caveman_enabled else "OFF"
+ yield Label(f"Caveman: {cstatus}", id="settings_caveman")
+ yield Button("Toggle caveman", id="btn_toggle_caveman", classes="settings_btn")
+ syn_status = "ON" if self.state.auto_synthesis_enabled else "OFF"
+ yield Label(f"Auto skill synthesis: {syn_status}", id="settings_synthesis")
+ yield Button("Toggle synthesis", id="btn_toggle_synthesis", classes="settings_btn")
+
+ with Container(classes="settings_card"):
+ yield Label("Workspace", classes="settings_label")
+ yield Label(f"{WORKSPACE}", classes="settings_row")
+ yield Button("Open dashboard β", id="btn_dashboard", classes="settings_btn")
+ w = f"Max workers: {os.cpu_count() * 2} (CPU-aware)" if self.state.task_manager else "Task manager not initialized"
+ yield Label(w, id="settings_workers")
async def on_select_changed(self, event: Select.Changed) -> None:
"""Handle dropdown changes for provider and theme selectors."""
@@ -2273,30 +2431,29 @@ class MotionTUI(App):
CSS = """
Screen { background: $background; color: $foreground; }
Header {
- background: $surface;
- border-bottom: heavy $primary;
- color: $text;
+ background: $background;
+ border-bottom: blank;
+ color: $text-muted;
text-style: bold;
padding: 0 1;
}
Header.-header-tall { height: 3; }
Footer {
- background: $panel;
- border-top: heavy $primary;
+ background: $background;
+ border-top: blank;
color: $text-muted;
padding: 0 1;
}
TabbedContent { height: 1fr; }
Tabs {
- background: $panel;
+ background: $background;
height: 3;
- border-bottom: solid $border;
+ border-bottom: blank;
}
Tabs > Tab {
padding: 0 2;
color: $text-muted;
- text-style: bold;
- background: $panel;
+ background: $background;
border: blank;
}
Tabs > Tab:hover {
@@ -2304,24 +2461,15 @@ class MotionTUI(App):
background: $surface;
}
Tabs > Tab.-active {
- color: $background;
- background: $primary;
+ color: $text;
+ background: $surface;
+ border-bottom: solid $primary;
text-style: bold;
}
TabPane {
background: $background;
padding: 0 0;
}
- .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 = [
From c1fbb0d6ea2675ffb1347c34fe7055846305379a Mon Sep 17 00:00:00 2001
From: Mathitz
Date: Sat, 25 Jul 2026 11:58:39 -0300
Subject: [PATCH 4/8] =?UTF-8?q?style:=20opencode-inspired=20message=20disp?=
=?UTF-8?q?lay=20=E2=80=94=20strip=20boxes,=20add=20left=20accent?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ui/tui.py | 45 +++++++++++++++++++--------------------------
1 file changed, 19 insertions(+), 26 deletions(-)
diff --git a/ui/tui.py b/ui/tui.py
index d67b760..b5c7cee 100644
--- a/ui/tui.py
+++ b/ui/tui.py
@@ -200,50 +200,48 @@ def _extract_reasoning_and_answer(text: str) -> tuple[str, str]:
# βββ Chat message widgets βββββββββββββββββββββββββββββββββββββββββββββββββββββ
class UserMessage(Static):
- """User reply balloon β rounded panel surface, aligned right."""
+ """User message β thin primary left accent bar, no box."""
DEFAULT_CSS = """
UserMessage {
- background: $panel;
+ background: transparent;
color: $text;
- border: round $panel;
- padding: 1 2;
- margin: 1 6 0 0;
+ border-left: thick $primary;
+ padding: 0 2;
+ margin: 1 0 0 1;
}
"""
class ReasoningMessage(Static):
- """Inline reasoning hint β same structural balloon, no heavy rule."""
+ """Inline reasoning β dim italic, no box (opencode Thinking: style)."""
DEFAULT_CSS = """
ReasoningMessage {
- background: $background;
+ background: transparent;
color: $text-muted;
text-style: dim italic;
- border: round $background;
- padding: 1 2;
- margin: 1 0 1 6;
+ padding: 0 2;
+ margin: 0 0 0 1;
}
"""
class AgentMessage(Static):
- """Motion reply balloon β rounded surface, aligned left."""
+ """Agent reply β no box, clean text flow."""
DEFAULT_CSS = """
AgentMessage {
- background: $surface;
+ background: transparent;
color: $text;
- border: round $surface;
- padding: 1 2;
- margin: 1 0 1 4;
+ padding: 0 2;
+ margin: 0 0 1 1;
}
"""
class SystemMessage(Static):
- """A system/info message β muted, single-line metadata."""
+ """System/info message β muted, single-line."""
DEFAULT_CSS = """
SystemMessage {
color: $text-muted;
text-style: dim;
padding: 0 2;
- margin: 0 0 1 0;
+ margin: 0 0 0 0;
}
"""
@@ -1018,19 +1016,14 @@ def action_copy_last_response(self) -> None:
def _render_user_markdown(timestamp: str, text: str):
safe = text.strip() or "_Empty message._"
return Group(
- Text(f"{timestamp} YOU", style="bold"),
- Text(""),
+ Text(f"you {timestamp}", style="dim"),
RichMarkdown(safe),
)
@staticmethod
def _render_agent_markdown(timestamp: str, answer: str):
safe_answer = answer.strip() or "_No response content._"
- return Group(
- Text(f"{timestamp} MOTION", style="bold"),
- Text(""),
- RichMarkdown(safe_answer),
- )
+ return RichMarkdown(safe_answer)
async def on_input_submitted(self, event: Input.Submitted) -> None:
text = event.value.strip()
@@ -1133,7 +1126,7 @@ async def on_stream_chunk(chunk: str) -> None:
if reasoning_widget is None:
reasoning_widget = ReasoningMessage("")
log.mount(reasoning_widget, before=live_response)
- reasoning_widget.update(self._render_agent_markdown(header_ts, f"[dim]Reasoning[/]\n{reasoning[:2500]}"))
+ reasoning_widget.update(Text(f"Thinking: {reasoning[:2500]}", style="dim italic"))
live_response.update(self._render_agent_markdown(header_ts, answer or ""))
async def on_trace_event(*args) -> None:
event_type = "trace"
@@ -1168,7 +1161,7 @@ async def on_trace_event(*args) -> None:
raw = response or ""
reasoning, answer = _extract_reasoning_and_answer(raw)
if reasoning:
- reasoning_widget = ReasoningMessage(f"[dim]Reasoning[/]\n{reasoning[:2500]}")
+ reasoning_widget = ReasoningMessage(f"Thinking: {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 ""
From 5087be604e8c18e45afb87ec03cb613f31544301 Mon Sep 17 00:00:00 2001
From: Mathitz
Date: Sat, 25 Jul 2026 12:05:32 -0300
Subject: [PATCH 5/8] =?UTF-8?q?style:=20flatten=20chat=20pane=20to=20$back?=
=?UTF-8?q?ground=20=E2=80=94=20remove=20all=20surface/panel=20fills?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ui/tui.py | 31 +++++++++++++++++--------------
1 file changed, 17 insertions(+), 14 deletions(-)
diff --git a/ui/tui.py b/ui/tui.py
index b5c7cee..f25ea4c 100644
--- a/ui/tui.py
+++ b/ui/tui.py
@@ -263,7 +263,7 @@ def render_line(self, y: int) -> Strip:
cursor = self.cursor_position if self.value else 0
if cursor >= len(text):
text.append(" ")
- text.stylize("bold #191622 on #78D1E1", cursor, cursor + 1)
+ text.stylize("on #78D1E1", cursor, cursor + 1)
segments = list(self.app.console.render(text, self.app.console.options.update_width(width)))
strip = Strip(segments)
@@ -788,11 +788,11 @@ class ChatPane(Vertical):
ChatPane {
height: 1fr;
background: $background;
- padding: 0 0 0 0;
+ padding: 0;
}
#chat_body {
height: 1fr;
- padding: 1 0 1 0;
+ padding: 0;
}
#chat_log {
height: 1fr;
@@ -800,7 +800,7 @@ class ChatPane(Vertical):
border: blank;
padding: 1 2;
scrollbar-size: 1 1;
- background: $surface;
+ background: $background;
}
#trace_panel {
width: 40;
@@ -849,31 +849,34 @@ class ChatPane(Vertical):
min-height: 5;
max-height: 5;
padding: 1 1 1 1;
- background: $panel;
- border: round $panel;
- margin: 1 1 0 1;
+ background: $background;
+ border-top: solid $border;
+ border-left: blank;
+ border-right: blank;
+ border-bottom: blank;
+ margin: 0;
}
#chat_input {
height: 3;
width: 1;
min-width: 1;
max-width: 1;
- border: tall #41414D;
- background: #2A2734;
- color: #2A2734;
+ border: blank;
+ background: $background;
+ color: $background;
padding: 0 0;
}
#chat_input_mirror {
height: 1fr;
width: 1fr;
- background: #23212B;
+ background: $background;
color: #E1E1E6;
padding: 0 2;
}
#chat_input:focus {
- border: tall #2A2734;
- background: #2A2734;
- color: #2A2734;
+ border: blank;
+ background: $background;
+ color: $background;
}
.chat_btn {
height: 3;
From 23e6eee961ba9211f2aa79a7dafbe575af2b5ab0 Mon Sep 17 00:00:00 2001
From: Mathitz
Date: Sat, 25 Jul 2026 12:43:43 -0300
Subject: [PATCH 6/8] =?UTF-8?q?style:=20fix=20composer=20separator=20color?=
=?UTF-8?q?=20=E2=80=94=20$border=E2=86=92$surface=20(subtle=20gray)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ui/tui.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/tui.py b/ui/tui.py
index f25ea4c..457f519 100644
--- a/ui/tui.py
+++ b/ui/tui.py
@@ -850,7 +850,7 @@ class ChatPane(Vertical):
max-height: 5;
padding: 1 1 1 1;
background: $background;
- border-top: solid $border;
+ border-top: solid $surface;
border-left: blank;
border-right: blank;
border-bottom: blank;
From 5005f9e87579a263507d4171aeeff99108c5830d Mon Sep 17 00:00:00 2001
From: Mathitz
Date: Sun, 9 Aug 2026 01:59:02 -0300
Subject: [PATCH 7/8] Beta v2 theme and [99;5uga[99;5u[99;5u[99;5u[99;5u]]]]]
---
.github/workflows/beta_release.yml | 4 +-
README.md | 126 +-
core/catalog.py | 202 +++
core/config.py | 10 +-
core/orchestrator.py | 11 +
core/providers.py | 72 +-
docs/roadmap.md | 82 ++
docs/ui-variants/a1-soft-cards.svg | 26 +
docs/ui-variants/a1-soft-cards.svg.png | Bin 0 -> 77185 bytes
docs/ui-variants/a2-flat-transcript.svg | 29 +
docs/ui-variants/a2-flat-transcript.svg.png | Bin 0 -> 81547 bytes
docs/ui-variants/a3-quiet-ops.svg | 37 +
docs/ui-variants/a3-quiet-ops.svg.png | Bin 0 -> 84916 bytes
docs/ui-variants/r1-balanced-rounded.svg | 29 +
docs/ui-variants/r1-balanced-rounded.svg.png | Bin 0 -> 77019 bytes
docs/ui-variants/r2-soft-rounded.svg | 28 +
docs/ui-variants/r2-soft-rounded.svg.png | Bin 0 -> 78656 bytes
docs/ui-variants/r3-ops-rounded.svg | 33 +
docs/ui-variants/r3-ops-rounded.svg.png | Bin 0 -> 79130 bytes
main.py | 27 +-
tests/test_catalog.py | 72 ++
tests/test_composer.py | 93 ++
tests/test_discovery.py | 130 ++
tests/test_session_context.py | 219 ++++
ui/themes.py | 18 +
ui/tui.py | 1178 ++++++++++++------
26 files changed, 1961 insertions(+), 465 deletions(-)
create mode 100644 core/catalog.py
create mode 100644 docs/roadmap.md
create mode 100644 docs/ui-variants/a1-soft-cards.svg
create mode 100644 docs/ui-variants/a1-soft-cards.svg.png
create mode 100644 docs/ui-variants/a2-flat-transcript.svg
create mode 100644 docs/ui-variants/a2-flat-transcript.svg.png
create mode 100644 docs/ui-variants/a3-quiet-ops.svg
create mode 100644 docs/ui-variants/a3-quiet-ops.svg.png
create mode 100644 docs/ui-variants/r1-balanced-rounded.svg
create mode 100644 docs/ui-variants/r1-balanced-rounded.svg.png
create mode 100644 docs/ui-variants/r2-soft-rounded.svg
create mode 100644 docs/ui-variants/r2-soft-rounded.svg.png
create mode 100644 docs/ui-variants/r3-ops-rounded.svg
create mode 100644 docs/ui-variants/r3-ops-rounded.svg.png
create mode 100644 tests/test_catalog.py
create mode 100644 tests/test_composer.py
create mode 100644 tests/test_discovery.py
create mode 100644 tests/test_session_context.py
diff --git a/.github/workflows/beta_release.yml b/.github/workflows/beta_release.yml
index 33af894..6d42e96 100644
--- a/.github/workflows/beta_release.yml
+++ b/.github/workflows/beta_release.yml
@@ -40,10 +40,10 @@ jobs:
body: |
## π§ͺ Beta Release v${{ env.VERSION }}
Automated release from the beta branch.
-
+
### Changes
${{ github.event.head_commit.message }}
draft: false
prerelease: true
env:
- GITHUB_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
\ No newline at end of file
+ GITHUB_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
diff --git a/README.md b/README.md
index ec97bba..011547d 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,8 @@
Quick Start Β·
Setup Guide Β·
Architecture Β·
- Skills Engine
+ Skills Engine Β·
+ Roadmap
---
@@ -36,18 +37,36 @@ It treats every successful task trajectory as a learning event, crystallizing ex
Get the harness running in under 60 seconds.
+### For complete beginners
+
+**1. Install Python 3.10+** if you don't have it. On macOS: `brew install python@3.14`. On Linux: `sudo apt install python3 python3-venv`.
+
+**2. Clone and install the harness:**
```bash
-# 1. Run the installer
+git clone motion-harness
+cd motion-harness
chmod +x install.sh && ./install.sh
+```
-# 2. Refresh your shell
-source ~/.config/fish/config.fish # or .zshrc / .bashrc
+**3. Refresh your shell** so the `motion` command is available:
+```bash
+source ~/.config/fish/config.fish # if you use fish
+source ~/.zshrc # if you use zsh
+source ~/.bashrc # if you use bash
+```
-# 3. Configure your provider
+**4. Configure your provider (API key):**
+```bash
cp config.example.yml config.yml
-# Edit config.yml β set your API keys in .env or directly in config.yml
+```
+Then open `config.yml` and set your API key. The easiest way is to create a `.env` file:
+```bash
+echo "OLLAMA_API_KEY=your-key-here" > .env
+```
+> **No need to add models one by one.** The harness ships with a built-in catalog of Anthropic, OpenAI, and Ollama Cloud models. Just add the API key and they're all available.
-# 4. Launch
+**5. Launch:**
+```bash
motion
```
@@ -82,41 +101,91 @@ When a complex task is solved, the harness doesn't just forget. It analyzes the
### π¨ Pro-Grade TUI
A high-performance terminal interface built with `Textual`, designed for daily-driver clarity rather than an engineering dashboard.
-**6-Tab Workspace**: Chat, Tasks, Skills, Knowledge, Memory, Settings β tab headers are the canonical navigation (no duplicate button bars).
+**Focused single-chat view (opencode-style)**: no top tab bar. The screen is a top bar, the conversation canvas, a compact bottom composer, and a right-hand **Context panel**. Skills, settings, and model switching are all reachable via the command palette.
+
+**Workspace regions**:
+- **Conversation canvas** β markdown-first message cards with author/time headers and compact metadata; response code blocks carry theme-aware syntax highlighting.
+- **Right Context panel** β the rolling session context and most recent turns, so you always see what the model is grounded against (`Ctrl+B` to toggle).
+- **Composer** β a compact, opencode-style prompt: auto-growing (soft-wraps instead of scrolling), a thin left accent border colored by agent mode, prompt history (β/β), and an inline `agent Β· model Β· provider` meta row. Enter sends, Shift+Enter adds a newline.
-**Chat workspace regions**:
-- **Conversation canvas** β dominant ~70β75% width, markdown-first message cards with author/time headers and compact metadata.
-- **Interaction trace side panel** β progressive disclosure: collapsed by default with a summary chip (`trace Β· N events Β· last stage`); expand with `F8` or by clicking the chip.
-- **Composer / action bar** β grouped primary actions (Trace, Copy, Stats) and secondary actions (Save Skill) with a single-line session metrics strip.
+**Agent mode colors**: `build` is **blue**, `plan` is **orange** (matches opencode).
-**Theme token model**: semantic tokens (`$background`, `$surface`, `$panel`, `$border`, `$primary`, `$accent`, `$text`, `$text-muted`, `$success`, `$warning`, `$error`) cascade through every widget via Textual's theme system. One emphasized border per visual region (chat canvas keeps `$primary`; trace, tasks, skills, KB, memory, settings use `$border`).
+**Grounded responses**: on every turn the harness passes the prior conversation (user + assistant) as history and the rolling session context as a memory-recall query, so the model references what was actually said instead of hallucinating.
-**4 Native Themes**: One Dark, Solarized Light, Nord, Dracula β cycle with `Ctrl+T`.
+**Theme token model**: semantic tokens (`$background`, `$surface`, `$panel`, `$border`, `$primary`, `$secondary`, `$accent`, `$text`, `$text-muted`, `$success`, `$warning`, `$error`) cascade through every widget via Textual's theme system.
-**Live task monitoring**: real-time status of parallel workers with β³βοΈβ
β indicators and a global activity rail (`Ctrl+B` to toggle).
+**6 Native Themes**: OpenCode (default), One Dark, Solarized Light, Nord, Dracula, Omni Dark β cycle with `Ctrl+T`.
**Keyboard shortcuts**:
| Key | Action |
| :-- | :-- |
-| `Ctrl+1..6` / `Alt+1..6` / `F1..F6` | Switch tab (Chat, Tasks, Skills, KB, Memory, Settings) |
-| `Ctrl+]` / `Ctrl+[` / `Ctrl+ββ` | Next / previous tab |
| `Ctrl+T` | Cycle theme |
-| `Ctrl+B` | Toggle activity rail |
+| `Ctrl+B` | Toggle context panel |
+| `Ctrl+O` | Switch model (provider β model) |
+| `Ctrl+K` | Command palette (all commands) |
+| `Ctrl+E` | Open external editor for the message |
| `F8` / `Ctrl+Shift+T` | Toggle interaction trace panel |
| `F9` / `Ctrl+Shift+C` | Copy last assistant response |
+| `Tab` | Toggle agent (build / plan) |
| `?` | Show shortcuts overlay (generated from live bindings) |
| `Enter` (chat input) | Send message |
+| `Shift+Enter` (chat input) | New line |
+| `β` / `β` (chat input) | Prompt history |
| `/skill save ` | Save last reply as a skill |
-| `Ctrl+C` | Cancel current request (does not quit) |
-| `Ctrl+Q` | Quit |
+| `Ctrl+C` / `Ctrl+X` | Cancel current request (does not quit) |
+| `Ctrl+Q` | Quit (kills the process) |
**Dashboard integration**: one-click open to the admin dashboard at `https://localhost:7860/`.
### β οΈ Known Limitations (v2 TUI)
- Trace persistence is per-session (not yet written to disk).
- Theme contrast validation is manual; the bundled themes are tuned for readability but very-low-contrast combinations are not auto-corrected.
-- Shortcut help overlay (`?`) reflects MainScreen + ChatPane bindings; pane-local bindings in Tasks/Skills/KB/Memory/Settings are not yet enumerated.
+- Shortcut help overlay (`?`) reflects MainScreen + ChatPane bindings; the Tasks/Skills/KB/Memory/Settings panes are defined but not mounted in the current focused-chat layout.
- Clipboard copy falls back to inserting the response into the input box when the terminal lacks clipboard support.
+- File/document ingestion (image / PDF / DOCX / XLSX) is on the roadmap β see [Roadmap](docs/roadmap.md).
+
+---
+
+## πΊοΈ Roadmap
+
+```mermaid
+%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#fab283', 'primaryBorderColor': '#484848', 'primaryTextColor': '#eeeeee', 'lineColor': '#5c9cf5' }}}%%
+flowchart LR
+ subgraph P1[Phase 1 β Core Chat UX]
+ direction TB
+ A[opencode-style prompt panel] --> B[Agent mode colors: build=blue, plan=orange]
+ B --> C[Ctrl+K palette Β· Ctrl+O model]
+ C --> D[Right-hand Context panel]
+ D --> E[Drop top tabs]
+ E --> F[Grounded responses via history + context_query]
+ end
+
+ subgraph P2[Phase 2 β File Ingestion]
+ direction TB
+ G[Images β base64 vision parts] --> H[Capability gating]
+ H --> I[PDF as own modality]
+ I --> J[DOC/DOCX β text extraction β memory]
+ J --> K[XLSX β sheets β memory]
+ K --> L[Unified /attach pipeline + graceful errors]
+ end
+
+ subgraph P3[Phase 3 β Providers & Models]
+ direction TB
+ M[Multimodal payloads in providers] --> N[Per-model capability manifest]
+ N --> O[OCR fallback for scanned pages]
+ end
+
+ subgraph P4[Phase 4 β Memory & Orchestration]
+ direction TB
+ P[Document-level memory namespaces] --> Q[Auto-compact context]
+ Q --> R[Persistent multi-session context]
+ R --> S[Attachment-aware parallel orchestration]
+ end
+
+ P1 --> P2 --> P3 --> P4
+```
+
+Detailed tracking lives in [docs/roadmap.md](docs/roadmap.md).
---
@@ -151,11 +220,11 @@ pytest tests/test_integration.py
# Parse/import sanity
python -c "from ui import tui; print('Import OK')"
-# Headless TUI smoke: compose MainScreen, tab nav, F8 trace toggle, ? overlay
+# Headless TUI smoke: compose MainScreen, Ctrl+K palette, F8 trace toggle, ? overlay
python - <<'PY'
import asyncio, sys
from textual.app import App
-from ui.tui import MainScreen, AppState
+from ui.tui import MainScreen, AppState, CommandPalette
from ui.themes import ThemeRegistry
class SmokeApp(App): pass
@@ -166,17 +235,18 @@ async def run():
async with app.run_test() as pilot:
app.push_screen(MainScreen(AppState()))
await pilot.pause()
- await pilot.press("ctrl+2"); await pilot.pause()
- await pilot.press("ctrl+1"); await pilot.pause()
- await pilot.press("f8"); await pilot.pause()
+ await pilot.press("ctrl+k"); await pilot.pause() # open command palette
+ assert isinstance(app.screen, CommandPalette)
+ await pilot.press("escape"); await pilot.pause() # close palette
+ await pilot.press("f8"); await pilot.pause() # toggle trace
await pilot.press("f8"); await pilot.pause()
- await pilot.press("question_sign"); await pilot.pause()
+ await pilot.press("question_sign"); await pilot.pause() # shortcuts overlay
await pilot.press("escape"); await pilot.pause()
sys.stderr.write("SMOKE OK\n")
asyncio.run(run())
PY
```
-Checks cover: MainScreen compose, tab navigation (`Ctrl+1`/`Ctrl+2`), trace disclosure toggle (`F8`), and the shortcuts overlay (`?`/`Escape`).
+Checks cover: MainScreen compose, the `Ctrl+K` command palette, trace disclosure toggle (`F8`), and the shortcuts overlay (`?`/`Escape`).
For more details on our versioning and changelog, see [RELEASES.md](RELEASES.md).
diff --git a/core/catalog.py b/core/catalog.py
new file mode 100644
index 0000000..0da4417
--- /dev/null
+++ b/core/catalog.py
@@ -0,0 +1,202 @@
+"""Built-in provider/model catalog.
+
+Mirrors opencode's approach: ship a pre-configured catalog of providers and
+models so users only need to add API keys (or point at a local endpoint),
+instead of configuring every model by hand.
+
+The catalog is merged with the user's ``config.yml``: user-defined providers
+override catalog entries with the same id, and catalog providers are always
+available as a fallback.
+"""
+
+from typing import Any, Dict, List, Optional
+
+import httpx
+import re
+
+from core.providers import LocalProvider, ModelConfig
+
+# ββ Built-in catalog βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Each provider: id -> {name, endpoint, provider_type, models: {model: opts}}
+# ``api_key`` is intentionally omitted β it comes from config.yml or env vars.
+
+BUILTIN_CATALOG: Dict[str, Dict[str, Any]] = {
+ "ollama-cloud": {
+ "name": "Ollama Cloud",
+ "endpoint": "https://ollama.com/v1",
+ "provider_type": "cloud",
+ "default_model": "deepseek-v4-flash",
+ "models": {
+ "deepseek-v4-flash": {"temperature": 0.7, "max_tokens": 4096},
+ "qwen3-coder:480b": {"temperature": 0.5, "max_tokens": 8192},
+ "nematron-3-super": {"temperature": 0.7, "max_tokens": 4096},
+ "glm-5.2": {"temperature": 0.7, "max_tokens": 4096},
+ "gemma4:31b": {"temperature": 0.7, "max_tokens": 4096},
+ "qwen3.5:397b": {"temperature": 0.7, "max_tokens": 4096},
+ "glm-5.1": {"temperature": 0.7, "max_tokens": 4096},
+ "minimax-m2.7": {"temperature": 0.7, "max_tokens": 4096},
+ },
+ },
+ "claude": {
+ "name": "Claude (Anthropic)",
+ "endpoint": "https://api.anthropic.com",
+ "provider_type": "cloud",
+ "default_model": "claude-opus-5",
+ "models": {
+ "claude-opus-5": {"temperature": 0.7, "max_tokens": 128000},
+ "claude-sonnet-5": {"temperature": 0.7, "max_tokens": 128000},
+ "claude-haiku-4-5": {"temperature": 0.7, "max_tokens": 64000},
+ "claude-fable-5": {"temperature": 0.7, "max_tokens": 128000},
+ "claude-opus-4-8": {"temperature": 0.7, "max_tokens": 128000},
+ "claude-opus-4-7": {"temperature": 0.7, "max_tokens": 128000},
+ "claude-opus-4-6": {"temperature": 0.7, "max_tokens": 128000},
+ "claude-sonnet-4-6": {"temperature": 0.7, "max_tokens": 128000},
+ "claude-sonnet-4-5": {"temperature": 0.7, "max_tokens": 64000},
+ "claude-opus-4-5": {"temperature": 0.7, "max_tokens": 64000},
+ },
+ },
+ "openai": {
+ "name": "OpenAI",
+ "endpoint": "https://api.openai.com/v1",
+ "provider_type": "cloud",
+ "default_model": "gpt-5.6-sol",
+ "models": {
+ "gpt-5.6-sol": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.6-terra": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.6-luna": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.5": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.4": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.4-mini": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.4-nano": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.3": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.2": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5.1": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5-mini": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-5-nano": {"temperature": 0.8, "max_tokens": 128000},
+ "gpt-4.1": {"temperature": 0.8, "max_tokens": 32000},
+ "gpt-4.1-mini": {"temperature": 0.8, "max_tokens": 32000},
+ "gpt-4.1-nano": {"temperature": 0.8, "max_tokens": 32000},
+ "gpt-4o": {"temperature": 0.8, "max_tokens": 16000},
+ "gpt-4o-mini": {"temperature": 0.8, "max_tokens": 16000},
+ "o3": {"temperature": 0.8, "max_tokens": 100000},
+ "o3-mini": {"temperature": 0.8, "max_tokens": 100000},
+ "o4-mini": {"temperature": 0.8, "max_tokens": 100000},
+ "o1": {"temperature": 0.8, "max_tokens": 100000},
+ "o1-pro": {"temperature": 0.8, "max_tokens": 100000},
+ },
+ },
+ "local-llama": {
+ "name": "Llama 3 (Local)",
+ "endpoint": "http://localhost:11434",
+ "provider_type": "local",
+ "options": {
+ "model": "llama3",
+ "temperature": 0.6,
+ "embed_model": "nomic-embed-text",
+ },
+ },
+}
+
+
+def merge_catalog(user_providers: Dict[str, Any]) -> Dict[str, Any]:
+ """Merge the built-in catalog with user-defined providers.
+
+ User providers win on id collision; catalog providers fill in anything the
+ user hasn't defined. The ``default`` key (if present) is preserved.
+ """
+ merged: Dict[str, Any] = {}
+ # Start with the catalog.
+ for pid, cfg in BUILTIN_CATALOG.items():
+ merged[pid] = dict(cfg)
+ # Overlay user providers.
+ for pid, cfg in (user_providers or {}).items():
+ if pid == "default":
+ continue
+ if pid in merged and isinstance(cfg, dict):
+ # Deep-merge: user options/models override catalog defaults.
+ base = merged[pid]
+ for key, value in cfg.items():
+ if isinstance(value, dict) and isinstance(base.get(key), dict):
+ base[key] = {**base[key], **value}
+ else:
+ base[key] = value
+ else:
+ merged[pid] = cfg
+ return merged
+
+
+async def discover_local_models(provider_id: str, cfg: Dict[str, Any]) -> List[str]:
+ """Query a local provider for installed models (Ollama /api/tags).
+
+ Returns the list of model names, or an empty list if the provider is not
+ local or the endpoint is unreachable.
+ """
+ if cfg.get("provider_type") != "local":
+ return []
+ endpoint = cfg.get("endpoint", "")
+ if not endpoint:
+ return []
+ model_config = ModelConfig(
+ name=provider_id,
+ endpoint=endpoint,
+ provider_type="local",
+ options=cfg.get("options", {}),
+ )
+ provider = LocalProvider(model_config)
+ try:
+ return await provider.list_models()
+ finally:
+ await provider.close()
+
+
+# ββ Ollama Cloud model scraper ββββββββββββββββββββββββββββββββββββββββββββββββ
+
+OLLAMA_SEARCH_URL = "https://ollama.com/search"
+
+
+async def scrape_ollama_cloud_models() -> List[str]:
+ """Fetch the list of models available on Ollama Cloud.
+
+ Scrapes the ollama.com search page and extracts model names. Returns a
+ list of model ids (e.g. ``deepseek-v4-flash``, ``glm-5.2``). Returns an
+ empty list if the fetch or parse fails.
+ """
+ try:
+ async with httpx.AsyncClient(timeout=20.0, follow_redirects=True) as client:
+ resp = await client.get(OLLAMA_SEARCH_URL)
+ resp.raise_for_status()
+ html = resp.text
+ except Exception:
+ return []
+
+ # Model names appear as links to /library/ or /search/.
+ # Match the model name tokens in the page.
+ names: List[str] = []
+ seen = set()
+ # Pattern for model links: href="/library/" or "/search/"
+ for m in re.finditer(r'href="/(?:library|search)/([a-z0-9][a-z0-9._-]*)"', html):
+ name = m.group(1)
+ if name not in seen:
+ seen.add(name)
+ names.append(name)
+ return names
+
+
+async def update_ollama_cloud_models() -> int:
+ """Scrape Ollama Cloud models and merge them into the built-in catalog.
+
+ Returns the number of models added/updated. The catalog is updated in
+ memory (BUILTIN_CATALOG) so the model dialog reflects the latest list.
+ """
+ names = await scrape_ollama_cloud_models()
+ if not names:
+ return 0
+ provider = BUILTIN_CATALOG.get("ollama-cloud", {})
+ models = provider.setdefault("models", {})
+ added = 0
+ for name in names:
+ if name not in models:
+ models[name] = {"temperature": 0.7, "max_tokens": 4096}
+ added += 1
+ return added
diff --git a/core/config.py b/core/config.py
index af9b3cc..9607491 100644
--- a/core/config.py
+++ b/core/config.py
@@ -3,6 +3,8 @@
from typing import Any, Dict, Optional
from dataclasses import dataclass
+from core.catalog import merge_catalog
+
@dataclass
class AppConfig:
workspace_path: str
@@ -26,7 +28,13 @@ def _load_config(self) -> Dict[str, Any]:
if not os.path.exists(self.config_path):
return {}
with open(self.config_path, 'r') as f:
- return yaml.safe_load(f) or {}
+ data = yaml.safe_load(f) or {}
+ # Merge the built-in catalog so pre-configured providers/models are
+ # always available, with user config taking precedence.
+ providers = data.get("providers", {})
+ merged_providers = merge_catalog(providers)
+ data["providers"] = merged_providers
+ return data
def _env(self, key: str, default: Any = None) -> Any:
"""Resolve a value from environment variables first, then config."""
diff --git a/core/orchestrator.py b/core/orchestrator.py
index 50ebce9..7b25744 100644
--- a/core/orchestrator.py
+++ b/core/orchestrator.py
@@ -227,6 +227,17 @@ async def on_stream_chunk(chunk: str) -> None:
finally:
task.end_time = datetime.now()
self.active_count -= 1
+ # Clean up the per-task memory DB file (created above).
+ try:
+ agent.memory.close()
+ except Exception:
+ pass
+ try:
+ db_path = f"memory_{request.task_id}.db"
+ if os.path.exists(db_path):
+ os.remove(db_path)
+ except Exception:
+ pass
# Save artifact
try:
task.artifact_path = self._save_artifact(task)
diff --git a/core/providers.py b/core/providers.py
index bbf6864..209b01c 100644
--- a/core/providers.py
+++ b/core/providers.py
@@ -26,13 +26,17 @@ def __init__(self, config: ModelConfig):
self._client = httpx.AsyncClient(timeout=120.0)
@abstractmethod
- async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
- """Generate a completion from the model."""
+ async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str:
+ """Generate a completion from the model.
+
+ ``history`` is an optional list of prior turns as
+ ``[{"role": "user"|"assistant", "content": "..."}, ...]``.
+ """
pass
- async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]:
+ async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]:
"""Optional streaming completion. Defaults to one-shot completion."""
- result = await self.complete(prompt, system_prompt=system_prompt, **kwargs)
+ result = await self.complete(prompt, system_prompt=system_prompt, history=history, **kwargs)
if result:
yield result
@@ -43,7 +47,7 @@ async def close(self):
class LocalProvider(BaseProvider):
"""Provider for local LLMs via Ollama-compatible /api/chat endpoint."""
- async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
+ async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str:
url = f"{self.config.endpoint.rstrip('/')}/api/chat"
model = self.config.options.get("model", self.config.name.lower())
payload = {
@@ -54,6 +58,8 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
}
if system_prompt:
payload["messages"].append({"role": "system", "content": system_prompt})
+ if history:
+ payload["messages"].extend(history)
payload["messages"].append({"role": "user", "content": prompt})
if "temperature" in self.config.options:
@@ -66,7 +72,7 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
data = resp.json()
return data.get("message", {}).get("content", "")
- async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]:
+ async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]:
url = f"{self.config.endpoint.rstrip('/')}/api/chat"
model = self.config.options.get("model", self.config.name.lower())
payload = {
@@ -77,6 +83,8 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs)
}
if system_prompt:
payload["messages"].append({"role": "system", "content": system_prompt})
+ if history:
+ payload["messages"].extend(history)
payload["messages"].append({"role": "user", "content": prompt})
if "temperature" in self.config.options:
@@ -105,27 +113,47 @@ async def embed(self, text: str) -> List[float]:
resp.raise_for_status()
return resp.json().get("embedding", [])
+ async def list_models(self) -> List[str]:
+ """Discover installed models via Ollama /api/tags.
+
+ Returns a list of model names (e.g. ``llama3:latest``). Falls back to
+ the configured model if the endpoint is unreachable or not Ollama.
+ """
+ url = f"{self.config.endpoint.rstrip('/')}/api/tags"
+ try:
+ resp = await self._client.get(url)
+ resp.raise_for_status()
+ data = resp.json()
+ models = [m.get("name", "") for m in data.get("models", [])]
+ models = [m for m in models if m]
+ if models:
+ return models
+ except Exception:
+ pass
+ configured = self.config.options.get("model")
+ return [configured] if configured else []
+
class CloudProvider(BaseProvider):
"""Provider for cloud LLMs (Anthropic, OpenAI) using their native chat APIs."""
- async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
+ async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str:
endpoint = self.config.endpoint
api_key = self.config.api_key or os.environ.get("OLLAMA_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
if "anthropic" in endpoint:
- return await self._anthropic_complete(prompt, system_prompt, api_key)
+ return await self._anthropic_complete(prompt, system_prompt, api_key, history)
elif "openai" in endpoint:
- return await self._openai_complete(prompt, system_prompt, api_key)
+ return await self._openai_complete(prompt, system_prompt, api_key, history)
else:
- return await self._openai_complete(prompt, system_prompt, api_key)
+ return await self._openai_complete(prompt, system_prompt, api_key, history)
- async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]:
+ async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]:
endpoint = self.config.endpoint
api_key = self.config.api_key or os.environ.get("OLLAMA_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("OPENAI_API_KEY", "")
if "anthropic" in endpoint:
- async for chunk in super().stream_complete(prompt, system_prompt=system_prompt, **kwargs):
+ async for chunk in super().stream_complete(prompt, system_prompt=system_prompt, history=history, **kwargs):
yield chunk
return
@@ -140,6 +168,8 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
+ if history:
+ messages.extend(history)
messages.append({"role": "user", "content": prompt})
payload = {
"model": model,
@@ -166,7 +196,7 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs)
if chunk:
yield chunk
- async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: str) -> str:
+ async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: str, history: Optional[List[Dict[str, str]]] = None) -> str:
url = "https://api.anthropic.com/v1/messages"
model = self.config.options.get("model", "claude-3-5-sonnet-20241022")
max_tokens = self.config.options.get("max_tokens", 4096)
@@ -176,11 +206,13 @@ async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: st
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
+ messages = list(history) if history else []
+ messages.append({"role": "user", "content": prompt})
payload = {
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
- "messages": [{"role": "user", "content": prompt}],
+ "messages": messages,
}
if system_prompt:
payload["system"] = system_prompt
@@ -190,7 +222,7 @@ async def _anthropic_complete(self, prompt: str, system_prompt: str, api_key: st
data = resp.json()
return data.get("content", [{}])[0].get("text", "")
- async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str) -> str:
+ async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str, history: Optional[List[Dict[str, str]]] = None) -> str:
url = f"{self.config.endpoint.rstrip('/')}/chat/completions"
model = self.config.options.get("model", "gpt-4o")
max_tokens = self.config.options.get("max_tokens", 4096)
@@ -202,6 +234,8 @@ async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
+ if history:
+ messages.extend(history)
messages.append({"role": "user", "content": prompt})
payload = {
"model": model,
@@ -218,7 +252,7 @@ async def _openai_complete(self, prompt: str, system_prompt: str, api_key: str)
class ProxyProvider(BaseProvider):
"""Provider for custom proxy/gateway endpoints (OpenAI-compatible)."""
- async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
+ async def complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> str:
url = f"{self.config.endpoint.rstrip('/')}/chat/completions"
api_key = self.config.api_key or os.environ.get("PROXY_API_KEY", "")
headers = {
@@ -228,6 +262,8 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
+ if history:
+ messages.extend(history)
messages.append({"role": "user", "content": prompt})
payload = {
"model": self.config.options.get("model", "default"),
@@ -239,7 +275,7 @@ async def complete(self, prompt: str, system_prompt: str = "", **kwargs) -> str:
data = resp.json()
return data.get("choices", [{}])[0].get("message", {}).get("content", "")
- async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs) -> AsyncIterator[str]:
+ async def stream_complete(self, prompt: str, system_prompt: str = "", history: Optional[List[Dict[str, str]]] = None, **kwargs) -> AsyncIterator[str]:
url = f"{self.config.endpoint.rstrip('/')}/chat/completions"
api_key = self.config.api_key or os.environ.get("PROXY_API_KEY", "")
headers = {
@@ -249,6 +285,8 @@ async def stream_complete(self, prompt: str, system_prompt: str = "", **kwargs)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
+ if history:
+ messages.extend(history)
messages.append({"role": "user", "content": prompt})
payload = {
"model": self.config.options.get("model", "default"),
diff --git a/docs/roadmap.md b/docs/roadmap.md
new file mode 100644
index 0000000..c73d583
--- /dev/null
+++ b/docs/roadmap.md
@@ -0,0 +1,82 @@
+# Motion Harness Roadmap
+
+This is the living roadmap for Motion Harness. Items are ordered roughly by
+priority within each phase, not strict release order. The guiding principle is
+the **Cognitive Loop** (Input β Hybrid Recall β Model Execution β Compression β
+Output): every feature must keep the agent grounded in real context and cheap to
+operate.
+
+---
+
+## Phase 1 β Core Chat UX (done)
+
+- [x] opencode-style compact prompt panel (input + agent Β· model Β· provider meta)
+- [x] Agent mode coloring β build (blue), plan (orange)
+- [x] Ctrl+K command palette, Ctrl+O model dialog
+- [x] Right-hand Context panel (rolling session context) replacing the activity rail
+- [x] Drop top tabs; single focused chat view
+- [x] Soft-wrapping, auto-growing composer (no horizontal scroll on long input)
+- [x] Grounded responses β pass prior conversation (history) + session context
+ (`context_query`) to the model on every turn to reduce hallucination
+- [x] Remove "Thinkingβ¦" placeholder from the response area
+- [x] Full theme-aware response rendering (code syntax colors follow theme)
+
+## Phase 2 β Document & File Ingestion (next)
+
+Adopts the opencode model of **base64 data-URL content parts** with **capability
+gating**, rather than blind OCR/text extraction. The model either reads the file
+natively or the harness says it can't and tells the user why.
+
+**Goal:** drop a file (image / PDF / DOCX / XLSX) and the agent understands it,
+without hallucinating or faking content.
+
+- [ ] **Image support (vision)**
+ - [ ] Convert attached images to base64 `data:;base64,...` content parts
+ - [ ] Detect a vision-capable model (`capabilities.input.image`)
+ - [ ] If unsupported, degrade gracefully: replace with
+ `ERROR: Cannot read image (this model does not support image input)`
+ - [ ] Empty / corrupt image guard before sending
+- [ ] **PDF support**
+ - [ ] Treat `application/pdf` as its own modality (`pdf`)
+ - [ ] Pass natively to models that accept PDFs; capability-gate otherwise
+- [ ] **DOC / DOCX support**
+ - [ ] Text extraction via `python-docx` (paragraphs + tables) when the model
+ cannot ingest DOCX natively
+ - [ ] Chunk + embed extracted text into `MemoryDB` for Hybrid Recall
+- [ ] **XLSX (Excel) support**
+ - [ ] Sheet β CSV-like text via `openpyxl`
+ - [ ] Chunk + embed into `MemoryDB` for Hybrid Recall
+- [ ] **Unified attachment pipeline**
+ - [ ] `/attach ` (or drag-in) in the composer
+ - [ ] Route by MIME: image β vision part; pdf β pdf part; doc/docx/xlsx β text
+ extraction β memory
+ - [ ] Capability gating mirrors opencode (`mimeToModality` + `input[modality]`)
+ - [ ] Graceful error messaging that informs the user (never silently drops)
+
+## Phase 3 β Provider & Model Enhancements
+
+- [ ] Multimodal payload support in `core/providers.py` (image/pdf content parts)
+- [ ] Per-model capability manifest (`input.image`, `input.pdf`, β¦)
+- [ ] Model switching preserves attachments (re-attach on provider change)
+- [ ] Optional vision-model fallback path for non-vision models (OCR for scanned
+ pages via `pytesseract`)
+
+## Phase 4 β Memory & Orchestration
+
+- [ ] Document-level memory (per-file retrieval namespaces)
+- [ ] Auto-compact conversation context under configurable token thresholds
+- [ ] Persistent multi-session context across restarts
+- [ ] Parallel orchestration with attachment-aware task scheduling
+
+---
+
+## Notes
+
+- The **opencode reference** for this approach is `anomalyco/opencode`:
+ - `packages/opencode/src/provider/transform.ts` β `mimeToModality()`,
+ `unsupportedParts()` (capability gating + empty-image guard)
+ - `packages/opencode/src/acp/content.ts` β `filePartToContentChunks()` +
+ `decodeDataUrl()` (base64 data-URL decoding)
+ - `packages/opencode/src/tool/code-mode.ts` β `dataUrl()` helper
+- Principle: **never pretend to read a file.** If the active model can't ingest a
+ modality, surface a clear message and inform the user.
diff --git a/docs/ui-variants/a1-soft-cards.svg b/docs/ui-variants/a1-soft-cards.svg
new file mode 100644
index 0000000..07712e5
--- /dev/null
+++ b/docs/ui-variants/a1-soft-cards.svg
@@ -0,0 +1,26 @@
+
diff --git a/docs/ui-variants/a1-soft-cards.svg.png b/docs/ui-variants/a1-soft-cards.svg.png
new file mode 100644
index 0000000000000000000000000000000000000000..a180245ed39e29b826ec99f1056d08d97824bfd1
GIT binary patch
literal 77185
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
z
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
+
+
+ A2 β Flat Transcript
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Transcript-first flow + minimal card treatment + ultra-quiet controls
+
diff --git a/docs/ui-variants/a2-flat-transcript.svg.png b/docs/ui-variants/a2-flat-transcript.svg.png
new file mode 100644
index 0000000000000000000000000000000000000000..9bdd0e986fa50e81e62849368888b48f0be2c18b
GIT binary patch
literal 81547
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