From fca37ff471b2a9b95405de02b5697a119e5e701b Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 10 Mar 2026 09:47:41 -0700 Subject: [PATCH 01/23] fix render issue --- dimos/utils/cli/dtop.py | 6 ++++-- dimos/utils/cli/lcmspy/run_lcmspy.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/dimos/utils/cli/dtop.py b/dimos/utils/cli/dtop.py index a6c4c1e13d..9aed2e7317 100644 --- a/dimos/utils/cli/dtop.py +++ b/dimos/utils/cli/dtop.py @@ -198,7 +198,11 @@ class ResourceSpyApp(App[None]): def __init__(self, topic_name: str = "/dimos/resource_stats") -> None: super().__init__() self._topic_name = topic_name + # start LCM before .run() takes over the terminal (raw mode), + # because autoconf uses typer.confirm() which deadlocks inside a TUI. self._lcm = PickleLCM(autoconf=True) + self._lcm.subscribe(Topic(self._topic_name), self._on_msg) + self._lcm.start() self._lock = threading.Lock() self._latest: dict[str, Any] | None = None self._last_msg_time: float = 0.0 @@ -209,8 +213,6 @@ def compose(self) -> ComposeResult: yield Static(id="panels") def on_mount(self) -> None: - self._lcm.subscribe(Topic(self._topic_name), self._on_msg) - self._lcm.start() self.set_interval(0.5, self._refresh) async def on_unmount(self) -> None: diff --git a/dimos/utils/cli/lcmspy/run_lcmspy.py b/dimos/utils/cli/lcmspy/run_lcmspy.py index 5be0bf28b5..d45be6b3e6 100644 --- a/dimos/utils/cli/lcmspy/run_lcmspy.py +++ b/dimos/utils/cli/lcmspy/run_lcmspy.py @@ -80,7 +80,10 @@ class LCMSpyApp(App): # type: ignore[type-arg] def __init__(self, *args, **kwargs) -> None: # type: ignore[no-untyped-def] super().__init__(*args, **kwargs) + # start LCM before .run() takes over the terminal (raw mode), + # because autoconf uses typer.confirm() which deadlocks inside a TUI. self.spy = GraphLCMSpy(autoconf=True, graph_log_window=0.5) + self.spy.start() self.table: DataTable | None = None # type: ignore[type-arg] def compose(self) -> ComposeResult: @@ -92,7 +95,6 @@ def compose(self) -> ComposeResult: yield self.table def on_mount(self) -> None: - self.spy.start() self.set_interval(self.refresh_interval, self.refresh_table) async def on_unmount(self) -> None: From 2c0f422cd0c8b121a62a53b2b95710e12821b9b9 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 10 Mar 2026 17:19:39 -0700 Subject: [PATCH 02/23] dio --- .../service/system_configurator/base.py | 17 +- .../service/system_configurator/lcm.py | 10 +- dimos/robot/all_blueprints.py | 3 + dimos/robot/cli/dimos.py | 12 + .../agentic/unitree_go2_agentic_sim.py | 20 + .../blueprints/smart/unitree_go2_replay.py | 20 + .../go2/blueprints/smart/unitree_go2_sim.py | 20 + dimos/utils/cli/dui/__init__.py | 0 dimos/utils/cli/dui/app.py | 486 +++++++++++++++ dimos/utils/cli/dui/confirm_screen.py | 195 ++++++ dimos/utils/cli/dui/dui.tcss | 83 +++ dimos/utils/cli/dui/sub_app.py | 72 +++ dimos/utils/cli/dui/sub_apps/__init__.py | 27 + dimos/utils/cli/dui/sub_apps/agentspy.py | 91 +++ dimos/utils/cli/dui/sub_apps/config.py | 166 ++++++ dimos/utils/cli/dui/sub_apps/dtop.py | 170 ++++++ dimos/utils/cli/dui/sub_apps/humancli.py | 154 +++++ dimos/utils/cli/dui/sub_apps/lcmspy.py | 92 +++ dimos/utils/cli/dui/sub_apps/runner.py | 553 ++++++++++++++++++ dimos/utils/prompt.py | 234 ++++++++ pyproject.toml | 1 + 21 files changed, 2419 insertions(+), 7 deletions(-) create mode 100644 dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_agentic_sim.py create mode 100644 dimos/robot/unitree/go2/blueprints/smart/unitree_go2_replay.py create mode 100644 dimos/robot/unitree/go2/blueprints/smart/unitree_go2_sim.py create mode 100644 dimos/utils/cli/dui/__init__.py create mode 100644 dimos/utils/cli/dui/app.py create mode 100644 dimos/utils/cli/dui/confirm_screen.py create mode 100644 dimos/utils/cli/dui/dui.tcss create mode 100644 dimos/utils/cli/dui/sub_app.py create mode 100644 dimos/utils/cli/dui/sub_apps/__init__.py create mode 100644 dimos/utils/cli/dui/sub_apps/agentspy.py create mode 100644 dimos/utils/cli/dui/sub_apps/config.py create mode 100644 dimos/utils/cli/dui/sub_apps/dtop.py create mode 100644 dimos/utils/cli/dui/sub_apps/humancli.py create mode 100644 dimos/utils/cli/dui/sub_apps/lcmspy.py create mode 100644 dimos/utils/cli/dui/sub_apps/runner.py create mode 100644 dimos/utils/prompt.py diff --git a/dimos/protocol/service/system_configurator/base.py b/dimos/protocol/service/system_configurator/base.py index c221af890f..8339964fee 100644 --- a/dimos/protocol/service/system_configurator/base.py +++ b/dimos/protocol/service/system_configurator/base.py @@ -21,7 +21,7 @@ import subprocess from typing import Any -import typer +from dimos.utils.prompt import confirm, sudo_prompt logger = logging.getLogger(__name__) @@ -114,11 +114,24 @@ def configure_system(checks: list[SystemConfigurator], check_only: bool = False) if check_only: return - if not typer.confirm("\nApply these changes now?"): + if explanations: + # Each explanation may be multi-line; indent all lines consistently + all_lines = [] + for e in explanations: + for line in e.strip().splitlines(): + all_lines.append(f" {line.strip()}") + summary = "\n".join(all_lines) + else: + summary = " (system configuration)" + if not confirm(f"Apply these changes?\n{summary}", default=True, question_id="system-configure"): if any(check.critical for check in failing): raise SystemExit(1) return + # Cache sudo credentials before running fixes (some need sudo) + if not _is_root_user(): + sudo_prompt("sudo is required to apply system configuration changes") + for check in failing: try: check.fix() diff --git a/dimos/protocol/service/system_configurator/lcm.py b/dimos/protocol/service/system_configurator/lcm.py index 79bcf6671b..7a2a6233e5 100644 --- a/dimos/protocol/service/system_configurator/lcm.py +++ b/dimos/protocol/service/system_configurator/lcm.py @@ -117,12 +117,12 @@ def check(self) -> bool: return bool(self.loopback_ok and self.route_ok) def explanation(self) -> str | None: - output = "" + lines = [] if not self.loopback_ok: - output += f"- Multicast: sudo {' '.join(self.enable_multicast_cmd)}\n" + lines.append(f"Enable multicast with: `sudo {' '.join(self.enable_multicast_cmd)}`") if not self.route_ok: - output += f"- Multicast: sudo {' '.join(self.add_route_cmd)}\n" - return output + lines.append(f"Add multicast route with: `sudo {' '.join(self.add_route_cmd)}`") + return "\n".join(lines) if lines else None def fix(self) -> None: if not self.loopback_ok: @@ -165,7 +165,7 @@ def check(self) -> bool: return False def explanation(self) -> str | None: - return f"Multicast: - sudo {' '.join(self.add_route_cmd)}" + return f"Enable multicast with: `sudo {' '.join(self.add_route_cmd)}`" def fix(self) -> None: sudo_run(*self.add_route_cmd, check=True, text=True, capture_output=True) diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index f842230550..9cac088334 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -77,10 +77,13 @@ "unitree-go2-agentic-huggingface": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_agentic_huggingface:unitree_go2_agentic_huggingface", "unitree-go2-agentic-mcp": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_agentic_mcp:unitree_go2_agentic_mcp", "unitree-go2-agentic-ollama": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_agentic_ollama:unitree_go2_agentic_ollama", + "unitree-go2-agentic-sim": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_agentic_sim:unitree_go2_agentic_sim", "unitree-go2-basic": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_basic:unitree_go2_basic", "unitree-go2-detection": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_detection:unitree_go2_detection", "unitree-go2-fleet": "dimos.robot.unitree.go2.blueprints.basic.unitree_go2_fleet:unitree_go2_fleet", + "unitree-go2-replay": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_replay:unitree_go2_replay", "unitree-go2-ros": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_ros:unitree_go2_ros", + "unitree-go2-sim": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_sim:unitree_go2_sim", "unitree-go2-spatial": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_spatial:unitree_go2_spatial", "unitree-go2-temporal-memory": "dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_temporal_memory:unitree_go2_temporal_memory", "unitree-go2-vlm-stream-test": "dimos.robot.unitree.go2.blueprints.smart.unitree_go2_vlm_stream_test:unitree_go2_vlm_stream_test", diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 1137a612f3..d6b498d6c9 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -487,6 +487,18 @@ def list_blueprints() -> None: typer.echo(blueprint_name) +@main.command() +def dio( + debug: bool = typer.Option(False, "--debug", help="Show debug panel with key event log"), +) -> None: + """Launch the DimOS Unified TUI.""" + from dimos.utils.cli.dui.app import main as dui_main + + if debug: + sys.argv.append("--debug") + dui_main() + + @main.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) def lcmspy(ctx: typer.Context) -> None: """LCM spy tool for monitoring LCM messages.""" diff --git a/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_agentic_sim.py b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_agentic_sim.py new file mode 100644 index 0000000000..de788ddffb --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/agentic/unitree_go2_agentic_sim.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.robot.unitree.go2.blueprints.agentic.unitree_go2_agentic import unitree_go2_agentic + +unitree_go2_agentic_sim = unitree_go2_agentic.global_config(simulation=True) + +__all__ = ["unitree_go2_agentic_sim"] diff --git a/dimos/robot/unitree/go2/blueprints/smart/unitree_go2_replay.py b/dimos/robot/unitree/go2/blueprints/smart/unitree_go2_replay.py new file mode 100644 index 0000000000..f8e106e0a6 --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/smart/unitree_go2_replay.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 + +unitree_go2_replay = unitree_go2.global_config(replay=True) + +__all__ = ["unitree_go2_replay"] diff --git a/dimos/robot/unitree/go2/blueprints/smart/unitree_go2_sim.py b/dimos/robot/unitree/go2/blueprints/smart/unitree_go2_sim.py new file mode 100644 index 0000000000..3e5eca10dc --- /dev/null +++ b/dimos/robot/unitree/go2/blueprints/smart/unitree_go2_sim.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2 + +unitree_go2_sim = unitree_go2.global_config(simulation=True) + +__all__ = ["unitree_go2_sim"] diff --git a/dimos/utils/cli/dui/__init__.py b/dimos/utils/cli/dui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/dimos/utils/cli/dui/app.py b/dimos/utils/cli/dui/app.py new file mode 100644 index 0000000000..da8a68b22f --- /dev/null +++ b/dimos/utils/cli/dui/app.py @@ -0,0 +1,486 @@ +"""DUI — DimOS Unified TUI.""" + +from __future__ import annotations + +import os +import sys +import threading +import time + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Container, Horizontal +from textual.events import Click, Key, Resize +from textual.widget import Widget +from textual.widgets import RichLog, Static + +from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dui.sub_apps import get_sub_apps + +_DUAL_WIDTH = 240 # >= this width: 2 panels +_TRIPLE_WIDTH = 320 # >= this width: 3 panels +_MAX_PANELS = 3 +_QUIT_WINDOW = 1.5 # seconds to press again to confirm quit + + +class DUIApp(App[None]): + CSS_PATH = "dui.tcss" + + BINDINGS = [ + Binding("alt+up", "tab_prev", "Tab prev", priority=True), + Binding("alt+down", "tab_next", "Tab next", priority=True), + Binding("ctrl+up", "tab_prev", "Tab prev", priority=True), + Binding("ctrl+down", "tab_next", "Tab next", priority=True), + Binding("alt+left", "focus_prev_panel", "Panel prev", priority=True), + Binding("alt+right", "focus_next_panel", "Panel next", priority=True), + Binding("ctrl+left", "focus_prev_panel", "Panel prev", priority=True), + Binding("ctrl+right", "focus_next_panel", "Panel next", priority=True), + Binding("escape", "quit_or_esc", "Quit", priority=True), + Binding("ctrl+c", "quit_or_esc", "Quit", priority=True), + ] + + def __init__(self, *, debug: bool = False) -> None: + super().__init__() + self._debug = debug + self._sub_app_classes = get_sub_apps() + n = len(self._sub_app_classes) + # Which sub-app index each panel shows + self._panel_idx: list[int] = [i % n for i in range(_MAX_PANELS)] + self._focused_panel: int = 0 + self._num_panels: int = 1 # how many panels are currently visible + self._initialized = False + self._instances: list[SubApp] = [] + self._quit_pressed_at: float = 0.0 + self._quit_timer: object | None = None + # Track which panel each instance is currently mounted in + self._instance_pane: dict[int, int] = {} # instance_idx -> panel (0..N-1) + # Debug log + self._debug_log_path: str | None = None + self._debug_log_file: object | None = None + if debug: + from pathlib import Path + log_path = Path.home() / ".dimos" / "dio-debug.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + f = open(log_path, "w") + self._debug_log_path = str(log_path) + self._debug_log_file = f + + # ------------------------------------------------------------------ + # Debug log + # ------------------------------------------------------------------ + + def _log(self, msg: str) -> None: + if not self._debug: + return + import re + plain = re.sub(r"\[/?[^\]]*\]", "", msg) + if self._debug_log_file is not None: + try: + self._debug_log_file.write(plain + "\n") # type: ignore[union-attr] + self._debug_log_file.flush() # type: ignore[union-attr] + except Exception: + pass + try: + panel = self.query_one("#debug-log", RichLog) + panel.write(msg) + except Exception: + pass + + # ------------------------------------------------------------------ + # Compose + # ------------------------------------------------------------------ + + def compose(self) -> ComposeResult: + with Container(id="sidebar"): + for i, cls in enumerate(self._sub_app_classes): + yield Static(cls.TITLE, classes="tab-item", id=f"tab-{i}") + with Horizontal(id="displays"): + for p in range(_MAX_PANELS): + yield Container(id=f"display-{p + 1}", classes="display-pane") + if self._debug: + yield RichLog(id="debug-log", markup=True, wrap=True, highlight=False) + yield Static("", id="hint-bar") + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def on_mount(self) -> None: + self._instances = [cls() for cls in self._sub_app_classes] + d1 = self.query_one("#display-1", Container) + for i, inst in enumerate(self._instances): + inst.styles.display = "none" + await d1.mount(inst) + self._instance_pane[i] = 0 + + if not self._initialized: + self._refresh_panel_count() + self._initialized = True + await self._place_instances() + self._sync_tabs() + self._sync_hint() + if self._instances: + self._force_focus_subapp(self._instances[self._panel_idx[0]]) + self._log(f"[dim]mounted {len(self._instances)} sub-apps, debug={self._debug}[/dim]") + if self._debug_log_path: + self._log(f"[dim]log file: {self._debug_log_path}[/dim]") + + async def on_resize(self, _event: Resize) -> None: + old = self._num_panels + self._refresh_panel_count() + if old != self._num_panels: + # Clamp focused panel + if self._focused_panel >= self._num_panels: + self._focused_panel = self._num_panels - 1 + await self._place_instances() + self._sync_tabs() + self._sync_hint() + + async def on_unmount(self) -> None: + for inst in self._instances: + inst.on_unmount_subapp() + + # ------------------------------------------------------------------ + # Focus tracking — auto-update _focused_panel when focus moves + # ------------------------------------------------------------------ + + def _panel_for_widget(self, widget: Widget | None) -> int | None: + """Return which panel (0..N-1) contains the given widget, or None.""" + node = widget + while node is not None: + node_id = getattr(node, "id", None) or "" + if node_id.startswith("display-"): + try: + p = int(node_id.split("-")[1]) - 1 + return p if p < self._num_panels else None + except (ValueError, IndexError): + return None + node = node.parent + return None + + def _sync_focused_panel(self) -> None: + """Update _focused_panel to match where the actually-focused widget lives.""" + panel = self._panel_for_widget(self.focused) + if panel is not None and panel != self._focused_panel: + old = self._focused_panel + self._focused_panel = panel + self._sync_tabs() + self._log( + f"[dim]FOCUS-TRACK: panel {old}->{panel}[/dim]" + ) + + # ------------------------------------------------------------------ + # Click-to-focus panel + # ------------------------------------------------------------------ + + def on_click(self, event: Click) -> None: + """When a display pane is clicked, focus that panel.""" + panel = self._panel_for_widget(event.widget) + if panel is not None and panel != self._focused_panel: + self._focus_panel(panel) + + # ------------------------------------------------------------------ + # Key logging + # ------------------------------------------------------------------ + + def on_key(self, event: Key) -> None: + focused = self.focused + focused_name = type(focused).__name__ if focused else "None" + focused_id = getattr(focused, "id", None) or "" + panel = self._panel_for_widget(focused) + self._log( + f"[#b5e4f4]KEY[/#b5e4f4] [bold #00eeee]{event.key!r}[/bold #00eeee]" + f" char={event.character!r}" + f" focused=[#5c9ff0]{focused_name}#{focused_id}[/#5c9ff0]" + f" _focused_panel={self._focused_panel} actual_panel={panel}" + ) + + # ------------------------------------------------------------------ + # Sync helpers + # ------------------------------------------------------------------ + + def _refresh_panel_count(self) -> None: + w = self.size.width + if w >= _TRIPLE_WIDTH: + new_count = 3 + elif w >= _DUAL_WIDTH: + new_count = 2 + else: + new_count = 1 + # Don't show more panels than sub-apps + new_count = min(new_count, len(self._sub_app_classes)) + self._num_panels = new_count + + for p in range(_MAX_PANELS): + pane = self.query_one(f"#display-{p + 1}") + pane.styles.display = "block" if p < new_count else "none" + + async def _place_instances(self) -> None: + """Show/hide sub-apps and reparent into the correct display pane.""" + panes = [self.query_one(f"#display-{p + 1}", Container) for p in range(_MAX_PANELS)] + + # Build set of visible sub-app indices + visible: dict[int, int] = {} # instance_idx -> panel + for p in range(self._num_panels): + visible[self._panel_idx[p]] = p + + for i, inst in enumerate(self._instances): + target_panel = visible.get(i) + current_panel = self._instance_pane.get(i) + + if target_panel is not None: + dest = panes[target_panel] + if current_panel != target_panel: + if inst.parent is not None: + await inst.remove() + await dest.mount(inst) + self._instance_pane[i] = target_panel + self._log(f"[dim] moved {self._sub_app_classes[i].TITLE} -> panel{target_panel}[/dim]") + inst.styles.display = "block" + else: + inst.styles.display = "none" + + names = " ".join( + f"p{p}={self._sub_app_classes[self._panel_idx[p]].TITLE}" + for p in range(self._num_panels) + ) + self._log(f"[dim]placed: {names}[/dim]") + + _TAB_SELECTED_CLASSES = [f"--selected-{i}" for i in range(1, _MAX_PANELS + 1)] + + def _sync_tabs(self) -> None: + for i in range(len(self._sub_app_classes)): + tab = self.query_one(f"#tab-{i}", Static) + tab.remove_class(*self._TAB_SELECTED_CLASSES) + for p in range(self._num_panels): + if i == self._panel_idx[p]: + tab.add_class(f"--selected-{p + 1}") + + # Panel focus borders + for p in range(_MAX_PANELS): + pane = self.query_one(f"#display-{p + 1}") + pane.remove_class("--focused") + target = self.query_one(f"#display-{self._focused_panel + 1}") + target.add_class("--focused") + + def _sync_hint(self) -> None: + bar = self.query_one("#hint-bar", Static) + parts = ["Alt+Up/Down: switch tab"] + if self._num_panels > 1: + parts.append("Alt+Left/Right: switch panel") + parts.append("Esc: quit") + bar.update(" | ".join(parts)) + + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + + async def action_tab_prev(self) -> None: + self._sync_focused_panel() + self._log(f"[#ffcc00]ACTION[/#ffcc00] tab_prev panel={self._focused_panel} idx={self._panel_idx[:self._num_panels]}") + self._clear_quit_pending() + await self._move_tab(-1) + + async def action_tab_next(self) -> None: + self._sync_focused_panel() + self._log(f"[#ffcc00]ACTION[/#ffcc00] tab_next panel={self._focused_panel} idx={self._panel_idx[:self._num_panels]}") + self._clear_quit_pending() + await self._move_tab(1) + + def action_focus_prev_panel(self) -> None: + self._sync_focused_panel() + self._log(f"[#ffcc00]ACTION[/#ffcc00] focus_prev_panel (was panel={self._focused_panel})") + self._clear_quit_pending() + new = max(0, self._focused_panel - 1) + self._focus_panel(new) + + def action_focus_next_panel(self) -> None: + self._sync_focused_panel() + self._log(f"[#ffcc00]ACTION[/#ffcc00] focus_next_panel (was panel={self._focused_panel})") + self._clear_quit_pending() + new = min(self._num_panels - 1, self._focused_panel + 1) + self._focus_panel(new) + + def action_quit_or_esc(self) -> None: + self._log(f"[#ffcc00]ACTION[/#ffcc00] quit_or_esc") + self._handle_quit_press() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _focus_panel(self, panel: int) -> None: + old = self._focused_panel + self._focused_panel = panel + idx = self._panel_idx[panel] + self._force_focus_subapp(self._instances[idx]) + self._sync_tabs() + self._sync_hint() + # Log what actually got focus + actual = self.focused + actual_name = type(actual).__name__ if actual else "None" + actual_id = getattr(actual, "id", None) or "" + actual_panel = self._panel_for_widget(actual) + self._log( + f" -> FOCUS panel {old}->{panel} sub-app={self._sub_app_classes[idx].TITLE}" + f" actual_focus={actual_name}#{actual_id} in panel={actual_panel}" + ) + + def _force_focus_subapp(self, subapp: SubApp) -> None: + """Force focus onto a widget inside the given sub-app. + + Widget.focus() can silently fail when an Input already has focus. + We find the target focusable widget and use screen.set_focus() directly. + """ + target = subapp.get_focus_target() + if target is not None: + self.screen.set_focus(target) + else: + self._log(f"[dim]WARNING: no focusable widget in {subapp.TITLE}[/dim]") + + async def _move_tab(self, delta: int) -> None: + n = len(self._sub_app_classes) + panel = self._focused_panel + old_idx = self._panel_idx[panel] + idx = (old_idx + delta) % n + + # Skip indices shown in other panels (a widget can't be in two panes) + other_indices = {self._panel_idx[p] for p in range(self._num_panels) if p != panel} + attempts = 0 + while idx in other_indices and attempts < n: + idx = (idx + delta) % n + attempts += 1 + + self._panel_idx[panel] = idx + self._log( + f" -> MOVE panel={panel} {self._sub_app_classes[old_idx].TITLE}->{self._sub_app_classes[idx].TITLE} " + f"idx={self._panel_idx[:self._num_panels]}" + ) + await self._place_instances() + self._sync_tabs() + self._force_focus_subapp(self._instances[idx]) + actual = self.focused + actual_name = type(actual).__name__ if actual else "None" + actual_id = getattr(actual, "id", None) or "" + self._log(f" -> after focus: {actual_name}#{actual_id} in panel={self._panel_for_widget(actual)}") + + def _handle_quit_press(self) -> None: + now = time.monotonic() + if now - self._quit_pressed_at < _QUIT_WINDOW: + self.exit() + return + self._quit_pressed_at = now + bar = self.query_one("#hint-bar", Static) + bar.update("Press Esc or Ctrl+C again to exit") + if self._quit_timer is not None: + self._quit_timer.stop() # type: ignore[union-attr] + self._quit_timer = self.set_timer(_QUIT_WINDOW, self._clear_quit_pending) + + def _clear_quit_pending(self) -> None: + self._quit_pressed_at = 0.0 + if self._quit_timer is not None: + self._quit_timer.stop() # type: ignore[union-attr] + self._quit_timer = None + if self._initialized: + self._sync_hint() + + # ------------------------------------------------------------------ + # Prompt hooks (with deduplication) + # ------------------------------------------------------------------ + + # _pending_confirms maps message -> (event, result_list) so that + # concurrent threads asking the same question share one modal. + _pending_confirms: dict[str, tuple[threading.Event, list[bool]]] = {} + _pending_confirms_lock = threading.Lock() + + def _handle_confirm(self, message: str, default: bool) -> bool | None: + from dimos.utils.cli.dui.confirm_screen import ConfirmScreen + + with self._pending_confirms_lock: + if message in self._pending_confirms: + # Another thread is already showing this question — wait for it + event, result = self._pending_confirms[message] + else: + event = threading.Event() + result: list[bool] = [] + self._pending_confirms[message] = (event, result) + + def _push() -> None: + def _on_result(value: bool) -> None: + result.append(value) + event.set() + self.push_screen(ConfirmScreen(message, default), callback=_on_result) + + self.call_from_thread(_push) + + event.wait() + + # First thread to wake up after the modal cleans up the entry + with self._pending_confirms_lock: + self._pending_confirms.pop(message, None) + + return result[0] if result else default + + _pending_sudos: dict[str, tuple[threading.Event, list[bool]]] = {} + _pending_sudos_lock = threading.Lock() + + def _handle_sudo(self, message: str) -> bool | None: + from dimos.utils.cli.dui.confirm_screen import SudoScreen + + with self._pending_sudos_lock: + if message in self._pending_sudos: + event, result = self._pending_sudos[message] + else: + event = threading.Event() + result: list[bool] = [] + self._pending_sudos[message] = (event, result) + + def _push() -> None: + def _on_result(value: bool) -> None: + result.append(value) + event.set() + self.push_screen(SudoScreen(message), callback=_on_result) + + self.call_from_thread(_push) + + event.wait() + + with self._pending_sudos_lock: + self._pending_sudos.pop(message, None) + + return result[0] if result else False + + +def main() -> None: + from dimos.utils.prompt import clear_dio_hook, set_dio_hook, set_dio_sudo_hook + + debug = "--debug" in sys.argv + if debug: + sys.argv.remove("--debug") + + app = DUIApp(debug=debug) + set_dio_hook(app._handle_confirm) + set_dio_sudo_hook(app._handle_sudo) + + _real_stdin = sys.stdin + sys.stdin = open(os.devnull) # noqa: SIM115 + try: + app.run() + except KeyboardInterrupt: + pass + finally: + clear_dio_hook() + sys.stdin.close() + sys.stdin = _real_stdin + if app._debug_log_path: + print(f"Debug log: {app._debug_log_path}") + if app._debug_log_file: + try: + app._debug_log_file.close() # type: ignore[union-attr] + except Exception: + pass + os._exit(0) + + +if __name__ == "__main__": + main() diff --git a/dimos/utils/cli/dui/confirm_screen.py b/dimos/utils/cli/dui/confirm_screen.py new file mode 100644 index 0000000000..be0636e5f5 --- /dev/null +++ b/dimos/utils/cli/dui/confirm_screen.py @@ -0,0 +1,195 @@ +"""Modal confirmation and sudo screens for dio.""" + +from __future__ import annotations + +import subprocess + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Center, Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Label, Static + + +class ConfirmScreen(ModalScreen[bool]): + """A modal popup that asks a yes/no question.""" + + DEFAULT_CSS = """ + ConfirmScreen { + align: center middle; + background: rgba(0, 0, 0, 0.7); + } + + ConfirmScreen > Vertical { + width: 60; + height: auto; + max-height: 20; + border: solid #00eeee; + background: #0b0f0f; + padding: 1 2; + } + + ConfirmScreen > Vertical > Label { + width: 100%; + content-align: center middle; + color: #b5e4f4; + margin-bottom: 1; + } + + ConfirmScreen > Vertical > Center { + width: 100%; + height: auto; + } + + ConfirmScreen Button { + margin: 0 2; + min-width: 14; + background: transparent; + color: #404040; + border: none; + } + + ConfirmScreen Button:focus { + background: #00eeee; + color: #0b0f0f; + border: solid #00eeee; + text-style: bold; + } + + ConfirmScreen Button:hover { + background: #00cccc; + color: #0b0f0f; + border: solid #00eeee; + } + """ + + BINDINGS = [ + Binding("y", "yes", "Yes", priority=True), + Binding("n", "no", "No", priority=True), + Binding("escape", "no", "No", priority=True), + Binding("ctrl+c", "no", "No", priority=True), + Binding("enter", "submit", "Submit", priority=True), + Binding("left", "switch_btn", "Left", priority=True), + Binding("right", "switch_btn", "Right", priority=True), + Binding("up", "switch_btn", "Up", priority=True), + Binding("down", "switch_btn", "Down", priority=True), + Binding("tab", "switch_btn", "Tab", priority=True), + ] + + def __init__(self, message: str, default: bool = False) -> None: + super().__init__() + self._message = message + self._default = default + + def compose(self) -> ComposeResult: + with Vertical(): + yield Label(self._message) + with Center(): + yield Button("Yes", id="btn-yes") + yield Button("No", id="btn-no") + + def on_mount(self) -> None: + btn_id = "#btn-yes" if self._default else "#btn-no" + self.query_one(btn_id, Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "btn-yes") + + def action_yes(self) -> None: + self.dismiss(True) + + def action_no(self) -> None: + self.dismiss(False) + + def action_submit(self) -> None: + focused = self.focused + if isinstance(focused, Button): + self.dismiss(focused.id == "btn-yes") + + def action_switch_btn(self) -> None: + btn_yes = self.query_one("#btn-yes", Button) + btn_no = self.query_one("#btn-no", Button) + if self.focused is btn_yes: + btn_no.focus() + else: + btn_yes.focus() + + +class SudoScreen(ModalScreen[bool]): + """A modal popup that asks for a sudo password and caches credentials.""" + + DEFAULT_CSS = """ + SudoScreen { + align: center middle; + background: rgba(0, 0, 0, 0.7); + } + + SudoScreen > Vertical { + width: 50; + height: auto; + max-height: 14; + border: solid #ffcc00; + background: #0b0f0f; + padding: 1 2; + } + + SudoScreen > Vertical > Label { + width: 100%; + content-align: center middle; + color: #b5e4f4; + margin-bottom: 1; + } + + SudoScreen > Vertical > #sudo-error { + color: #ff0000; + width: 100%; + content-align: center middle; + margin-top: 1; + } + + SudoScreen Input { + width: 100%; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("ctrl+c", "cancel", "Cancel"), + ] + + def __init__(self, message: str = "sudo password required") -> None: + super().__init__() + self._message = message + + def compose(self) -> ComposeResult: + with Vertical(): + yield Label(self._message) + yield Input(placeholder="Password", password=True, id="sudo-input") + yield Static("", id="sudo-error") + + def on_mount(self) -> None: + self.query_one("#sudo-input", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id != "sudo-input": + return + password = event.value + if not password: + return + + # Validate by running sudo -S true with the password on stdin + result = subprocess.run( + ["sudo", "-S", "true"], + input=password + "\n", + capture_output=True, + text=True, + ) + if result.returncode == 0: + self.dismiss(True) + else: + event.input.value = "" + error = self.query_one("#sudo-error", Static) + error.update("Incorrect password, try again") + + def action_cancel(self) -> None: + self.dismiss(False) diff --git a/dimos/utils/cli/dui/dui.tcss b/dimos/utils/cli/dui/dui.tcss new file mode 100644 index 0000000000..f950927ad1 --- /dev/null +++ b/dimos/utils/cli/dui/dui.tcss @@ -0,0 +1,83 @@ +/* DUI — DimOS Unified TUI */ + +Screen { + layout: horizontal; + background: #0b0f0f; +} + +#sidebar { + width: 22; + height: 1fr; + background: #0b0f0f; + padding: 1 0; +} + +.tab-item { + width: 100%; + height: 3; + padding: 1 2; + content-align: left middle; + background: #0b0f0f; + color: #b5e4f4; +} + +.tab-item.--selected-1 { + background: #1a2a2a; + color: #00eeee; +} + +.tab-item.--selected-2 { + background: #1a1a2a; + color: #5c9ff0; +} + +.tab-item.--selected-3 { + background: #2a1a2a; + color: #c07ff0; +} + +#displays { + width: 1fr; + height: 1fr; + layout: horizontal; +} + +.display-pane { + width: 1fr; + height: 1fr; + border: solid #404040; + background: #0b0f0f; +} + +.display-pane.--focused { + border: solid #00eeee; +} + +#display-2 { + display: none; +} + +#display-3 { + display: none; +} + +#debug-log { + width: 50; + height: 1fr; + dock: right; + border-left: solid #404040; + background: #0b0f0f; + color: #b5e4f4; + padding: 0 1; + scrollbar-size: 1 1; +} + +#hint-bar { + dock: bottom; + height: 1; + width: 100%; + background: #1a2020; + color: #404040; + padding: 0 1; + content-align: left middle; +} diff --git a/dimos/utils/cli/dui/sub_app.py b/dimos/utils/cli/dui/sub_app.py new file mode 100644 index 0000000000..d0942e0a77 --- /dev/null +++ b/dimos/utils/cli/dui/sub_app.py @@ -0,0 +1,72 @@ +"""SubApp base class for DUI sub-applications.""" + +from __future__ import annotations + +from textual.widget import Widget + + +class SubApp(Widget): + """Base class for DUI sub-applications. + + Each sub-app is a Widget that renders inside a display pane. + Subclasses must set TITLE and implement compose(). + + Lifecycle: + - on_mount_subapp() is called exactly ONCE after the widget's + children have been composed. Heavy / blocking work (LCM + connections, etc.) should be dispatched via self.run_worker(). + - on_unmount_subapp() is called when the DUI app is shutting down, + NOT on every tab switch. + """ + + TITLE: str = "Untitled" + + can_focus = False + + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self._subapp_initialized = False + + @property + def has_focus(self) -> bool: + """True if the currently focused widget is inside this sub-app.""" + focused = self.app.focused + if focused is None: + return False + # Walk up the DOM tree to see if focused widget is a descendant + node = focused + while node is not None: + if node is self: + return True + node = node.parent + return False + + def get_focus_target(self) -> Widget | None: + """Return the widget that should receive focus for this sub-app. + + Override in subclasses for custom focus logic. + Default: first visible focusable descendant. + """ + for child in self.query("*"): + if child.can_focus and child.display and child.styles.display != "none": + return child + return None + + def on_mount(self) -> None: + """Textual lifecycle — fires after compose() children exist.""" + if not self._subapp_initialized: + self._subapp_initialized = True + self.on_mount_subapp() + + def on_mount_subapp(self) -> None: + """Called exactly once after first mount. + + Override to start LCM subscriptions, timers, etc. + Heavy / blocking work should use ``self.run_worker()``. + """ + + def on_unmount_subapp(self) -> None: + """Called when the DUI app tears down this sub-app. + + Override to stop LCM subscriptions, timers, etc. + """ diff --git a/dimos/utils/cli/dui/sub_apps/__init__.py b/dimos/utils/cli/dui/sub_apps/__init__.py new file mode 100644 index 0000000000..bc95801c70 --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/__init__.py @@ -0,0 +1,27 @@ +"""Registry of available DUI sub-apps.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dimos.utils.cli.dui.sub_app import SubApp + + +def get_sub_apps() -> list[type[SubApp]]: + """Return all available sub-app classes in display order.""" + # from dimos.utils.cli.dui.sub_apps.agentspy import AgentSpySubApp + from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp + from dimos.utils.cli.dui.sub_apps.dtop import DtopSubApp + from dimos.utils.cli.dui.sub_apps.humancli import HumanCLISubApp + from dimos.utils.cli.dui.sub_apps.lcmspy import LCMSpySubApp + from dimos.utils.cli.dui.sub_apps.runner import RunnerSubApp + + return [ + RunnerSubApp, + ConfigSubApp, + DtopSubApp, + LCMSpySubApp, + HumanCLISubApp, + # AgentSpySubApp, + ] diff --git a/dimos/utils/cli/dui/sub_apps/agentspy.py b/dimos/utils/cli/dui/sub_apps/agentspy.py new file mode 100644 index 0000000000..16251b83ce --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/agentspy.py @@ -0,0 +1,91 @@ +"""AgentSpy sub-app — embedded agent message monitor.""" + +from __future__ import annotations + +from typing import Any + +from textual.app import ComposeResult +from textual.widgets import RichLog + +from dimos.utils.cli import theme +from dimos.utils.cli.dui.sub_app import SubApp + + +class AgentSpySubApp(SubApp): + TITLE = "agentspy" + + DEFAULT_CSS = f""" + AgentSpySubApp {{ + layout: vertical; + background: {theme.BACKGROUND}; + }} + AgentSpySubApp RichLog {{ + height: 1fr; + border: none; + background: {theme.BACKGROUND}; + padding: 0 1; + }} + """ + + def __init__(self) -> None: + super().__init__() + self._monitor: Any = None + + def compose(self) -> ComposeResult: + yield RichLog(id="aspy-log", wrap=True, highlight=True, markup=True) + + def on_mount_subapp(self) -> None: + self.run_worker(self._init_monitor, exclusive=True, thread=True) + + def _init_monitor(self) -> None: + """Blocking monitor init — runs in a worker thread.""" + try: + from dimos.utils.cli.agentspy.agentspy import AgentMessageMonitor + + self._monitor = AgentMessageMonitor() + self._monitor.subscribe(self._on_new_message) + self._monitor.start() + + # Write existing messages + for entry in self._monitor.get_messages(): + self.app.call_from_thread(self._write_entry_safe, entry) + except Exception: + pass + + def on_unmount_subapp(self) -> None: + if self._monitor: + try: + self._monitor.stop() + except Exception: + pass + self._monitor = None + + def _on_new_message(self, entry: Any) -> None: + try: + self.app.call_from_thread(self._write_entry_safe, entry) + except Exception: + pass + + def _write_entry_safe(self, entry: Any) -> None: + try: + log = self.query_one("#aspy-log", RichLog) + self._write_entry(log, entry) + except Exception: + pass + + def _write_entry(self, log: RichLog, entry: Any) -> None: + from dimos.utils.cli.agentspy.agentspy import ( + format_message_content, + format_timestamp, + get_message_type_and_style, + ) + + msg = entry.message + msg_type, style = get_message_type_and_style(msg) + content = format_message_content(msg) + timestamp = format_timestamp(entry.timestamp) + log.write( + f"[dim white]{timestamp}[/dim white] | " + f"[bold {style}]{msg_type}[/bold {style}] | " + f"[{style}]{content}[/{style}]" + ) diff --git a/dimos/utils/cli/dui/sub_apps/config.py b/dimos/utils/cli/dui/sub_apps/config.py new file mode 100644 index 0000000000..8797a7a774 --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/config.py @@ -0,0 +1,166 @@ +"""Config sub-app — interactive GlobalConfig editor.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widgets import Input, Label, Select, Static, Switch + +from dimos.utils.cli import theme +from dimos.utils.cli.dui.sub_app import SubApp + +_VIEWER_OPTIONS = ["rerun", "rerun-web", "rerun-connect", "foxglove", "none"] + +_DEFAULTS: dict[str, object] = { + "viewer": "rerun", + "n_workers": 2, + "robot_ip": "", + "dtop": False, +} + + +def _config_path() -> Path: + """Return the path to the persisted dio config file inside .venv.""" + # Walk up from the interpreter to find the venv root + venv = Path(sys.prefix) + return venv / "dio-config.json" + + +def _load_config() -> dict[str, object]: + """Load saved config, falling back to defaults.""" + values = dict(_DEFAULTS) + try: + data = json.loads(_config_path().read_text()) + for k in _DEFAULTS: + if k in data: + values[k] = data[k] + except Exception: + pass + return values + + +def _save_config(values: dict[str, object]) -> None: + """Persist config values to disk.""" + try: + _config_path().write_text(json.dumps(values, indent=2) + "\n") + except Exception: + pass + + +class ConfigSubApp(SubApp): + TITLE = "config" + + DEFAULT_CSS = f""" + ConfigSubApp {{ + layout: vertical; + padding: 1 2; + background: {theme.BACKGROUND}; + overflow-y: auto; + }} + ConfigSubApp .subapp-header {{ + color: #ff8800; + padding: 0; + text-style: bold; + }} + ConfigSubApp Label {{ + margin-top: 1; + color: {theme.ACCENT}; + }} + ConfigSubApp .field-label {{ + color: {theme.CYAN}; + margin-bottom: 0; + }} + ConfigSubApp Input {{ + width: 40; + }} + ConfigSubApp Select {{ + width: 40; + }} + ConfigSubApp .switch-row {{ + height: 3; + margin-top: 1; + }} + ConfigSubApp .switch-row Label {{ + margin-top: 0; + padding: 1 0; + }} + ConfigSubApp .switch-state {{ + color: {theme.DIM}; + padding: 1 1; + width: 6; + }} + ConfigSubApp .switch-state.--on {{ + color: {theme.CYAN}; + }} + """ + + def __init__(self) -> None: + super().__init__() + self.config_values: dict[str, object] = _load_config() + + def compose(self) -> ComposeResult: + v = self.config_values + yield Static("GlobalConfig Editor", classes="subapp-header") + + yield Label("viewer", classes="field-label") + yield Select( + [(opt, opt) for opt in _VIEWER_OPTIONS], + value=str(v.get("viewer", "rerun")), + id="cfg-viewer", + ) + + yield Label("n_workers", classes="field-label") + yield Input(value=str(v.get("n_workers", 2)), id="cfg-n-workers", type="integer") + + yield Label("robot_ip", classes="field-label") + yield Input(value=str(v.get("robot_ip", "")), placeholder="e.g. 192.168.12.1", id="cfg-robot-ip") + + dtop_val = bool(v.get("dtop", False)) + with Horizontal(classes="switch-row"): + yield Label("dtop", classes="field-label") + yield Switch(value=dtop_val, id="cfg-dtop") + state = Static("ON" if dtop_val else "OFF", id="cfg-dtop-state", classes="switch-state") + if dtop_val: + state.add_class("--on") + yield state + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id == "cfg-viewer": + self.config_values["viewer"] = event.value + _save_config(self.config_values) + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "cfg-n-workers": + try: + self.config_values["n_workers"] = int(event.value) + except ValueError: + pass + _save_config(self.config_values) + elif event.input.id == "cfg-robot-ip": + self.config_values["robot_ip"] = event.value + _save_config(self.config_values) + + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "cfg-dtop": + self.config_values["dtop"] = event.value + state_label = self.query_one("#cfg-dtop-state", Static) + if event.value: + state_label.update("ON") + state_label.add_class("--on") + else: + state_label.update("OFF") + state_label.remove_class("--on") + _save_config(self.config_values) + + def get_overrides(self) -> dict[str, object]: + """Return config overrides for use by the runner.""" + overrides: dict[str, object] = {} + for k, v in self.config_values.items(): + if k == "robot_ip" and not v: + continue + overrides[k] = v + return overrides diff --git a/dimos/utils/cli/dui/sub_apps/dtop.py b/dimos/utils/cli/dui/sub_apps/dtop.py new file mode 100644 index 0000000000..aa53b7298d --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/dtop.py @@ -0,0 +1,170 @@ +"""Dtop sub-app — embedded resource monitor.""" + +from __future__ import annotations + +import threading +import time +from collections import deque +from typing import Any + +from rich.console import Group, RenderableType +from rich.panel import Panel +from rich.rule import Rule +from rich.text import Text +from textual.app import ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Static + +from dimos.utils.cli import theme +from dimos.utils.cli.dtop import ( + ResourceSpyApp, + _LABEL_COLOR, + _SPARK_WIDTH, + _compute_ranges, +) +from dimos.utils.cli.dui.sub_app import SubApp + + +class DtopSubApp(SubApp): + TITLE = "dtop" + + DEFAULT_CSS = f""" + DtopSubApp {{ + layout: vertical; + background: {theme.BACKGROUND}; + }} + DtopSubApp VerticalScroll {{ + height: 1fr; + scrollbar-size: 0 0; + }} + DtopSubApp VerticalScroll.waiting {{ + align: center middle; + }} + DtopSubApp .waiting #dtop-panels {{ + width: auto; + }} + DtopSubApp #dtop-panels {{ + background: transparent; + }} + """ + + def __init__(self) -> None: + super().__init__() + self._lcm: Any = None + self._lock = threading.Lock() + self._latest: dict[str, Any] | None = None + self._last_msg_time: float = 0.0 + self._cpu_history: dict[str, deque[float]] = {} + + def compose(self) -> ComposeResult: + with VerticalScroll(id="dtop-scroll"): + yield Static(id="dtop-panels") + + def get_focus_target(self) -> object | None: + """Return the VerticalScroll for focus.""" + try: + return self.query_one("#dtop-scroll") + except Exception: + return super().get_focus_target() + + def on_mount_subapp(self) -> None: + self.run_worker(self._init_lcm, exclusive=True, thread=True) + self.set_interval(0.5, self._refresh) + + def _init_lcm(self) -> None: + """Blocking LCM init — runs in a worker thread.""" + try: + from dimos.protocol.pubsub.impl.lcmpubsub import PickleLCM, Topic + + self._lcm = PickleLCM(autoconf=True) + self._lcm.subscribe(Topic("/dimos/resource_stats"), self._on_msg) + self._lcm.start() + except Exception: + pass + + def on_unmount_subapp(self) -> None: + if self._lcm: + try: + self._lcm.stop() + except Exception: + pass + self._lcm = None + + def _on_msg(self, msg: dict[str, Any], _topic: str) -> None: + with self._lock: + self._latest = msg + self._last_msg_time = time.monotonic() + + def _refresh(self) -> None: + with self._lock: + data = self._latest + last_msg = self._last_msg_time + + try: + scroll = self.query_one(VerticalScroll) + except Exception: + return + if data is None: + scroll.add_class("waiting") + waiting = Panel( + Text( + "Waiting for resource stats...\nuse `dimos --dtop ...` to emit stats", + style=theme.FOREGROUND, + justify="center", + ), + border_style=theme.CYAN, + expand=False, + ) + self.query_one("#dtop-panels", Static).update(waiting) + return + scroll.remove_class("waiting") + + stale = (time.monotonic() - last_msg) > 2.0 + dim = "#606060" + border_style = dim if stale else "#777777" + + entries: list[tuple[str, str, dict[str, Any], str, str]] = [] + coord = data.get("coordinator", {}) + entries.append(("coordinator", theme.BRIGHT_CYAN, coord, "", str(coord.get("pid", "")))) + + for w in data.get("workers", []): + alive = w.get("alive", False) + wid = w.get("worker_id", "?") + role_style = theme.BRIGHT_GREEN if alive else theme.BRIGHT_RED + modules = ", ".join(w.get("modules", [])) or "" + entries.append((f"worker {wid}", role_style, w, modules, str(w.get("pid", "")))) + + ranges = _compute_ranges([d for _, _, d, _, _ in entries]) + + parts: list[RenderableType] = [] + for i, (role, rs, d, mods, pid) in enumerate(entries): + if role not in self._cpu_history: + self._cpu_history[role] = deque(maxlen=_SPARK_WIDTH * 2) + if not stale: + self._cpu_history[role].append(d.get("cpu_percent", 0)) + if i > 0: + title = Text(" ") + title.append(role, style=dim if stale else _LABEL_COLOR) + if mods: + title.append(": ", style=dim if stale else _LABEL_COLOR) + title.append(mods, style=dim if stale else rs) + if pid: + title.append(f" [{pid}]", style=dim if stale else "#777777") + title.append(" ") + parts.append(Rule(title=title, style=border_style)) + parts.extend( + ResourceSpyApp._make_lines(d, stale, ranges, self._cpu_history[role]) + ) + + first_role, first_rs, _, first_mods, first_pid = entries[0] + panel_title = Text(" ") + panel_title.append(first_role, style=dim if stale else _LABEL_COLOR) + if first_mods: + panel_title.append(": ", style=dim if stale else _LABEL_COLOR) + panel_title.append(first_mods, style=dim if stale else first_rs) + if first_pid: + panel_title.append(f" [{first_pid}]", style=dim if stale else "#777777") + panel_title.append(" ") + + panel = Panel(Group(*parts), title=panel_title, border_style=border_style) + self.query_one("#dtop-panels", Static).update(panel) diff --git a/dimos/utils/cli/dui/sub_apps/humancli.py b/dimos/utils/cli/dui/sub_apps/humancli.py new file mode 100644 index 0000000000..294c8bf8ce --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/humancli.py @@ -0,0 +1,154 @@ +"""HumanCLI sub-app — embedded agent chat interface.""" + +from __future__ import annotations + +import json +import textwrap +import threading +from datetime import datetime +from typing import Any + +from textual.app import ComposeResult +from textual.containers import Container +from textual.widgets import Input, RichLog + +from dimos.utils.cli import theme +from dimos.utils.cli.dui.sub_app import SubApp + + +class HumanCLISubApp(SubApp): + TITLE = "chat" + + DEFAULT_CSS = f""" + HumanCLISubApp {{ + layout: vertical; + background: {theme.BACKGROUND}; + }} + HumanCLISubApp #hcli-chat {{ + height: 1fr; + }} + HumanCLISubApp RichLog {{ + height: 1fr; + scrollbar-size: 0 0; + border: solid {theme.DIM}; + }} + HumanCLISubApp Input {{ + dock: bottom; + }} + """ + + def __init__(self) -> None: + super().__init__() + self._human_transport: Any = None + self._agent_transport: Any = None + self._running = False + + def compose(self) -> ComposeResult: + with Container(id="hcli-chat"): + yield RichLog(id="hcli-log", highlight=True, markup=True, wrap=False) + yield Input(placeholder="Type a message...", id="hcli-input") + + def on_mount_subapp(self) -> None: + self._running = True + self.run_worker(self._init_transports, exclusive=True, thread=True) + log = self.query_one("#hcli-log", RichLog) + self._add_system_message(log, "Connected to DimOS Agent Interface") + + def _init_transports(self) -> None: + """Blocking transport init — runs in a worker thread.""" + try: + from dimos.core.transport import pLCMTransport + + self._human_transport = pLCMTransport("/human_input") + self._agent_transport = pLCMTransport("/agent") + except Exception: + return + + if self._agent_transport: + self._subscribe_to_agent() + + def on_unmount_subapp(self) -> None: + self._running = False + + def _subscribe_to_agent(self) -> None: + from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + + def receive_msg(msg: Any) -> None: + if not self._running: + return + try: + log = self.query_one("#hcli-log", RichLog) + except Exception: + return + timestamp = datetime.now().strftime("%H:%M:%S") + + if isinstance(msg, SystemMessage): + self.app.call_from_thread( + self._add_message, log, timestamp, "system", str(msg.content)[:1000], theme.YELLOW + ) + elif isinstance(msg, AIMessage): + content = msg.content or "" + tool_calls = getattr(msg, "tool_calls", None) or msg.additional_kwargs.get( + "tool_calls", [] + ) + if content: + self.app.call_from_thread( + self._add_message, log, timestamp, "agent", content, theme.AGENT + ) + if tool_calls: + for tc in tool_calls: + name = tc.get("name", "unknown") + args = tc.get("args", {}) + info = f"▶ {name}({json.dumps(args, separators=(',', ':'))})" + self.app.call_from_thread( + self._add_message, log, timestamp, "tool", info, theme.TOOL + ) + elif isinstance(msg, ToolMessage): + self.app.call_from_thread( + self._add_message, log, timestamp, "tool", str(msg.content), theme.TOOL_RESULT + ) + elif isinstance(msg, HumanMessage): + self.app.call_from_thread( + self._add_message, log, timestamp, "human", str(msg.content), theme.HUMAN + ) + + self._agent_transport.subscribe(receive_msg) + + def _add_message( + self, log: RichLog, timestamp: str, sender: str, content: str, color: str + ) -> None: + content = content.strip() if content else "" + prefix = f" [{theme.TIMESTAMP}]{timestamp}[/{theme.TIMESTAMP}] [{color}]{sender:>8}[/{color}] │ " + indent = " " * 19 + "│ " + width = max(log.size.width - 24, 40) if log.size else 60 + + for i, line in enumerate(content.split("\n")): + wrapped = textwrap.wrap(line, width=width) or [""] + if i == 0: + log.write(prefix + f"[{color}]{wrapped[0]}[/{color}]") + for wl in wrapped[1:]: + log.write(indent + f"[{color}]{wl}[/{color}]") + else: + for wl in wrapped: + log.write(indent + f"[{color}]{wl}[/{color}]") + + def _add_system_message(self, log: RichLog, content: str) -> None: + timestamp = datetime.now().strftime("%H:%M:%S") + self._add_message(log, timestamp, "system", content, theme.YELLOW) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id != "hcli-input": + return + message = event.value.strip() + if not message: + return + event.input.value = "" + + if message.lower() in ("/exit", "/quit"): + return + if message.lower() == "/clear": + self.query_one("#hcli-log", RichLog).clear() + return + + if self._human_transport: + self._human_transport.publish(message) diff --git a/dimos/utils/cli/dui/sub_apps/lcmspy.py b/dimos/utils/cli/dui/sub_apps/lcmspy.py new file mode 100644 index 0000000000..9b189d22be --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/lcmspy.py @@ -0,0 +1,92 @@ +"""LCM Spy sub-app — embedded LCM traffic monitor.""" + +from __future__ import annotations + +from typing import Any + +from rich.text import Text +from textual.app import ComposeResult +from textual.widgets import DataTable + +from dimos.utils.cli import theme +from dimos.utils.cli.dui.sub_app import SubApp + + +class LCMSpySubApp(SubApp): + TITLE = "lcmspy" + + DEFAULT_CSS = f""" + LCMSpySubApp {{ + layout: vertical; + background: {theme.BACKGROUND}; + }} + LCMSpySubApp DataTable {{ + height: 1fr; + width: 1fr; + border: solid {theme.DIM}; + background: {theme.BG}; + scrollbar-size: 0 0; + }} + LCMSpySubApp DataTable > .datatable--header {{ + color: {theme.ACCENT}; + background: transparent; + }} + """ + + def __init__(self) -> None: + super().__init__() + self._spy: Any = None + + def compose(self) -> ComposeResult: + table: DataTable = DataTable(zebra_stripes=False, cursor_type=None) # type: ignore[arg-type] + table.add_column("Topic") + table.add_column("Freq (Hz)") + table.add_column("Bandwidth") + table.add_column("Total Traffic") + yield table + + def on_mount_subapp(self) -> None: + self.run_worker(self._init_lcm, exclusive=True, thread=True) + self.set_interval(0.5, self._refresh_table) + + def _init_lcm(self) -> None: + """Blocking LCM init — runs in a worker thread.""" + try: + from dimos.utils.cli.lcmspy.lcmspy import GraphLCMSpy + + self._spy = GraphLCMSpy(autoconf=True, graph_log_window=0.5) + self._spy.start() + except Exception: + pass + + def on_unmount_subapp(self) -> None: + if self._spy: + try: + self._spy.stop() + except Exception: + pass + self._spy = None + + def _refresh_table(self) -> None: + if not self._spy: + return + + from dimos.utils.cli.lcmspy.run_lcmspy import gradient, topic_text + + try: + table = self.query_one(DataTable) + except Exception: + return + topics = list(self._spy.topic.values()) + topics.sort(key=lambda t: t.total_traffic(), reverse=True) + table.clear(columns=False) + + for t in topics: + freq = t.freq(5.0) + kbps = t.kbps(5.0) + table.add_row( + topic_text(t.name), + Text(f"{freq:.1f}", style=gradient(10, freq)), + Text(t.kbps_hr(5.0), style=gradient(1024 * 3, kbps)), + Text(t.total_traffic_hr()), + ) diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py new file mode 100644 index 0000000000..19556dc840 --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -0,0 +1,553 @@ +"""Runner sub-app — blueprint launcher with log viewer.""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import threading +from typing import Any + +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button, Input, Label, ListItem, ListView, RichLog, Static + +from dimos.utils.cli import theme +from dimos.utils.cli.dui.sub_app import SubApp + + +class RunnerSubApp(SubApp): + TITLE = "runner" + + DEFAULT_CSS = f""" + RunnerSubApp {{ + layout: vertical; + background: {theme.BACKGROUND}; + }} + RunnerSubApp .subapp-header {{ + width: 100%; + height: auto; + color: #ff8800; + padding: 1 2; + text-style: bold; + }} + RunnerSubApp #runner-filter {{ + width: 100%; + margin: 0 0 0 0; + background: {theme.BACKGROUND}; + border: solid {theme.DIM}; + color: {theme.ACCENT}; + }} + RunnerSubApp #runner-filter:focus {{ + border: solid {theme.CYAN}; + }} + RunnerSubApp ListView {{ + height: 1fr; + background: {theme.BACKGROUND}; + }} + RunnerSubApp ListView > ListItem {{ + background: {theme.BACKGROUND}; + color: {theme.ACCENT}; + padding: 1 2; + }} + RunnerSubApp ListView > ListItem.--highlight {{ + background: #1a2a2a; + }} + RunnerSubApp RichLog {{ + height: 1fr; + background: {theme.BACKGROUND}; + border: solid {theme.DIM}; + scrollbar-size: 0 0; + }} + RunnerSubApp .run-controls {{ + dock: bottom; + height: auto; + padding: 0 1; + background: {theme.BACKGROUND}; + }} + RunnerSubApp .status-bar {{ + height: 1; + dock: bottom; + background: #1a2020; + color: {theme.DIM}; + padding: 0 1; + }} + RunnerSubApp .run-controls Button {{ + margin: 0 1 0 0; + min-width: 12; + background: transparent; + border: solid {theme.DIM}; + color: {theme.ACCENT}; + }} + RunnerSubApp #btn-stop {{ + border: solid #882222; + color: #cc4444; + }} + RunnerSubApp #btn-stop:hover {{ + border: solid #cc4444; + }} + RunnerSubApp #btn-stop:focus {{ + background: #882222; + color: #ffffff; + border: solid #cc4444; + }} + RunnerSubApp #btn-restart {{ + border: solid #886600; + color: #ccaa00; + }} + RunnerSubApp #btn-restart:hover {{ + border: solid #ccaa00; + }} + RunnerSubApp #btn-restart:focus {{ + background: #886600; + color: #ffffff; + border: solid #ccaa00; + }} + RunnerSubApp #btn-pick {{ + border: solid #226688; + color: #44aacc; + }} + RunnerSubApp #btn-pick:hover {{ + border: solid #44aacc; + }} + RunnerSubApp #btn-pick:focus {{ + background: #226688; + color: #ffffff; + border: solid #44aacc; + }} + """ + + def __init__(self) -> None: + super().__init__() + self._running_entry: Any = None + self._log_thread: threading.Thread | None = None + self._stop_log = False + self._blueprints: list[str] = [] + self._filtered: list[str] = [] + self._child_proc: subprocess.Popen[str] | None = None + self._launched_name: str | None = None + + def compose(self) -> ComposeResult: + yield Static("Blueprint Runner", classes="subapp-header") + yield Input(placeholder="Type to filter blueprints...", id="runner-filter") + yield ListView(id="blueprint-list") + yield RichLog(id="runner-log", markup=True, wrap=True) + with Horizontal(classes="run-controls", id="run-controls"): + yield Button("Stop", id="btn-stop", variant="error") + yield Button("Restart", id="btn-restart", variant="warning") + yield Button("Pick Blueprint", id="btn-pick") + yield Static("", id="runner-status", classes="status-bar") + + def on_mount_subapp(self) -> None: + self._check_running() + if self._running_entry is None: + self._populate_blueprints() + self._show_list_mode() + else: + self._show_log_mode() + # Poll the run registry so we can re-attach to blueprints + # launched (or stopped) from another terminal. + self.set_interval(2.0, self._poll_running) + + def get_focus_target(self) -> object | None: + """Return the widget that should receive focus.""" + if self._running_entry is not None: + try: + return self.query_one("#runner-log", RichLog) + except Exception: + pass + try: + return self.query_one("#runner-filter", Input) + except Exception: + return super().get_focus_target() + + def _check_running(self) -> None: + try: + from dimos.core.run_registry import get_most_recent + + self._running_entry = get_most_recent(alive_only=True) + except Exception: + self._running_entry = None + + def _poll_running(self) -> None: + """Periodically check the run registry for state changes.""" + old_entry = self._running_entry + self._check_running() + new_entry = self._running_entry + + old_id = getattr(old_entry, "run_id", None) + new_id = getattr(new_entry, "run_id", None) + + if old_id == new_id: + return + + if new_entry is not None and old_entry is None: + self._stop_log = True + self._show_log_mode() + elif new_entry is None and old_entry is not None: + self._stop_log = True + self._populate_blueprints() + self._show_list_mode() + self.query_one("#runner-filter", Input).value = "" + else: + self._stop_log = True + self._show_log_mode() + + def _populate_blueprints(self) -> None: + try: + from dimos.robot.all_blueprints import all_blueprints + + self._blueprints = sorted( + name for name in all_blueprints if not name.startswith("demo-") + ) + except Exception: + self._blueprints = [] + + self._filtered = list(self._blueprints) + self._rebuild_list() + + def _rebuild_list(self) -> None: + lv = self.query_one("#blueprint-list", ListView) + lv.clear() + for name in self._filtered: + lv.append(ListItem(Label(name))) + if self._filtered: + lv.index = 0 + + def _apply_filter(self, query: str) -> None: + q = query.strip().lower() + if not q: + self._filtered = list(self._blueprints) + else: + self._filtered = [n for n in self._blueprints if q in n.lower()] + self._rebuild_list() + + def _show_list_mode(self) -> None: + self.query_one("#runner-filter").styles.display = "block" + self.query_one("#blueprint-list").styles.display = "block" + self.query_one("#runner-log").styles.display = "none" + self.query_one("#run-controls").styles.display = "none" + self.query_one("#btn-pick").styles.display = "none" + status = self.query_one("#runner-status", Static) + status.update("Up/Down: navigate | Enter: run | Type to filter") + + def _show_log_mode(self) -> None: + self.query_one("#runner-filter").styles.display = "none" + self.query_one("#blueprint-list").styles.display = "none" + self.query_one("#runner-log").styles.display = "block" + self.query_one("#run-controls").styles.display = "block" + self.query_one("#btn-stop").styles.display = "block" + self.query_one("#btn-restart").styles.display = "block" + self.query_one("#btn-pick").styles.display = "none" + self.query_one("#btn-stop", Button).focus() + entry = self._running_entry + if entry: + status = self.query_one("#runner-status", Static) + status.update( + f"Running: {entry.blueprint} (PID {entry.pid})" + ) + self._start_log_follow(entry) + + def _show_failed_mode(self) -> None: + """Show log output with only a 'Pick Blueprint' button.""" + self.query_one("#runner-filter").styles.display = "none" + self.query_one("#blueprint-list").styles.display = "none" + self.query_one("#runner-log").styles.display = "block" + self.query_one("#run-controls").styles.display = "block" + self.query_one("#btn-stop").styles.display = "none" + self.query_one("#btn-restart").styles.display = "none" + self.query_one("#btn-pick").styles.display = "block" + status = self.query_one("#runner-status", Static) + status.update("Launch failed — pick a blueprint to try again") + self.query_one("#btn-pick", Button).focus() + + def _start_log_follow(self, entry: Any) -> None: + self._stop_log = False + log_widget = self.query_one("#runner-log", RichLog) + log_widget.clear() + + def _follow() -> None: + try: + from dimos.core.log_viewer import follow_log, format_line, read_log, resolve_log_path + + path = resolve_log_path(entry.run_id) + if not path: + self.app.call_from_thread(log_widget.write, "[dim]No log file found[/dim]") + return + + for line in read_log(path, 50): + if self._stop_log: + return + formatted = format_line(line) + self.app.call_from_thread(log_widget.write, formatted) + + for line in follow_log(path, stop=lambda: self._stop_log): + formatted = format_line(line) + self.app.call_from_thread(log_widget.write, formatted) + except Exception as e: + self.app.call_from_thread(log_widget.write, f"[red]Error: {e}[/red]") + + self._log_thread = threading.Thread(target=_follow, daemon=True) + self._log_thread.start() + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "runner-filter": + self._apply_filter(event.value) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "runner-filter": + lv = self.query_one("#blueprint-list", ListView) + idx = lv.index + if idx is not None and 0 <= idx < len(self._filtered): + self._launch_blueprint(self._filtered[idx]) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + lv = self.query_one("#blueprint-list", ListView) + idx = lv.index + if idx is not None and 0 <= idx < len(self._filtered): + self._launch_blueprint(self._filtered[idx]) + + def _get_visible_buttons(self) -> list[Button]: + """Return the currently visible control buttons in order.""" + buttons: list[Button] = [] + for bid in ("btn-stop", "btn-restart", "btn-pick"): + try: + btn = self.query_one(f"#{bid}", Button) + if btn.styles.display != "none": + buttons.append(btn) + except Exception: + pass + return buttons + + def _cycle_button_focus(self, delta: int) -> None: + """Move focus among visible control buttons by delta (+1 or -1).""" + buttons = self._get_visible_buttons() + if not buttons: + return + focused = self.app.focused + try: + idx = buttons.index(focused) # type: ignore[arg-type] + idx = (idx + delta) % len(buttons) + except ValueError: + idx = 0 + buttons[idx].focus() + + def on_key(self, event: Any) -> None: + key = getattr(event, "key", "") + + # In list mode: arrow keys on filter input move the list selection + is_list_mode = ( + self._running_entry is None + and self._child_proc is None + and self.query_one("#runner-filter").styles.display != "none" + ) + if is_list_mode: + focused = self.app.focused + filter_input = self.query_one("#runner-filter", Input) + if focused is filter_input and key in ("up", "down"): + lv = self.query_one("#blueprint-list", ListView) + if self._filtered: + current = lv.index or 0 + if key == "up": + lv.index = max(0, current - 1) + else: + lv.index = min(len(self._filtered) - 1, current + 1) + event.prevent_default() + event.stop() + return + + # In log/failed mode: arrow keys navigate between buttons + controls_visible = self.query_one("#run-controls").styles.display != "none" + if controls_visible and key in ("left", "right"): + self._cycle_button_focus(1 if key == "right" else -1) + event.prevent_default() + event.stop() + return + + if controls_visible and key == "enter": + focused = self.app.focused + if isinstance(focused, Button): + focused.press() + event.prevent_default() + event.stop() + return + + def _launch_blueprint(self, name: str) -> None: + """Launch a blueprint in a fully detached child process.""" + log_widget = self.query_one("#runner-log", RichLog) + log_widget.clear() + # Switch to log view immediately + self.query_one("#runner-filter").styles.display = "none" + self.query_one("#blueprint-list").styles.display = "none" + self.query_one("#runner-log").styles.display = "block" + status = self.query_one("#runner-status", Static) + status.update(f"Launching {name}...") + + # Gather config overrides on the main thread + config_args: list[str] = [] + try: + from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp + + for inst in self.app._instances: # type: ignore[attr-defined] + if isinstance(inst, ConfigSubApp): + for k, v in inst.get_overrides().items(): + cli_key = k.replace("_", "-") + if isinstance(v, bool): + config_args.append(f"--{cli_key}" if v else f"--no-{cli_key}") + else: + config_args.extend([f"--{cli_key}", str(v)]) + break + except Exception: + pass + + cmd = [sys.executable, "-m", "dimos.robot.cli.dimos", *config_args, "run", "--daemon", name] + self._launched_name = name + self.query_one("#run-controls").styles.display = "block" + log_widget.write(f"[dim]$ {' '.join(cmd)}[/dim]") + + try: + # Launch in a fully detached process so it doesn't share + # CPU scheduling priority with the TUI event loop. + self._child_proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, # detach from our process group + ) + except Exception as e: + log_widget.write(f"[red]Launch error: {e}[/red]") + return + + # Stream stdout in a background thread + proc = self._child_proc + + def _stream_output() -> None: + try: + assert proc.stdout is not None + for line in proc.stdout: + self.app.call_from_thread(log_widget.write, line.rstrip("\n")) + proc.wait() + rc = proc.returncode + if rc != 0: + self.app.call_from_thread( + log_widget.write, f"[{theme.YELLOW}]Process exited with code {rc}[/{theme.YELLOW}]" + ) + except Exception as e: + self.app.call_from_thread(log_widget.write, f"[red]Stream error: {e}[/red]") + finally: + self._child_proc = None + # After the launch command finishes, poll will pick up + # the running entry and switch to log-follow mode. + def _after() -> None: + self._check_running() + if self._running_entry: + self._show_log_mode() + else: + self._show_failed_mode() + self.app.call_from_thread(_after) + + threading.Thread(target=_stream_output, daemon=True).start() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-stop": + self._stop_running() + elif event.button.id == "btn-restart": + self._restart_running() + elif event.button.id == "btn-pick": + self._go_to_list() + + def _go_to_list(self) -> None: + """Switch back to the blueprint picker.""" + self._stop_log = True + self._running_entry = None + self._populate_blueprints() + self._show_list_mode() + self.query_one("#runner-filter", Input).value = "" + self.query_one("#runner-filter", Input).focus() + + def _stop_running(self, *, then_launch: str | None = None) -> None: + self._stop_log = True + log_widget = self.query_one("#runner-log", RichLog) + status = self.query_one("#runner-status", Static) + status.update("Stopping blueprint...") + # Disable buttons so user can't double-tap + for bid in ("btn-stop", "btn-restart"): + try: + self.query_one(f"#{bid}", Button).disabled = True + except Exception: + pass + + # Kill the launch subprocess immediately (non-blocking) + if self._child_proc is not None: + try: + self._child_proc.send_signal(signal.SIGTERM) + log_widget.write(f"[{theme.YELLOW}]Stopped launch process[/{theme.YELLOW}]") + except Exception: + pass + self._child_proc = None + + entry = self._running_entry + self._running_entry = None + + def _do_stop() -> None: + if entry: + try: + from dimos.core.run_registry import stop_entry + + msg, _ = stop_entry(entry) + self.app.call_from_thread(log_widget.write, f"[{theme.YELLOW}]{msg}[/{theme.YELLOW}]") + except Exception as e: + self.app.call_from_thread(log_widget.write, f"[red]Stop error: {e}[/red]") + + def _after_stop() -> None: + # Re-enable buttons + for bid in ("btn-stop", "btn-restart"): + try: + self.query_one(f"#{bid}", Button).disabled = False + except Exception: + pass + if then_launch: + self._launch_blueprint(then_launch) + else: + self._populate_blueprints() + self._show_list_mode() + self.query_one("#runner-filter", Input).value = "" + self.query_one("#runner-filter", Input).focus() + + self.app.call_from_thread(_after_stop) + + threading.Thread(target=_do_stop, daemon=True).start() + + def _restart_running(self) -> None: + entry = self._running_entry + name = getattr(entry, "blueprint", None) or self._launched_name + if name: + self._stop_running(then_launch=name) + + def _open_log_in_editor(self) -> None: + entry = self._running_entry + if not entry: + return + try: + from dimos.core.log_viewer import resolve_log_path + + path = resolve_log_path(entry.run_id) + if path: + editor = os.environ.get("EDITOR", "vi") + self.app.suspend() + os.system(f"{editor} {path}") + self.app.resume() + except Exception: + pass + + def on_unmount_subapp(self) -> None: + self._stop_log = True + # Kill the launch subprocess if still running (not the daemon — just the launcher) + if self._child_proc is not None: + try: + self._child_proc.send_signal(signal.SIGTERM) + except Exception: + pass diff --git a/dimos/utils/prompt.py b/dimos/utils/prompt.py new file mode 100644 index 0000000000..68a5417769 --- /dev/null +++ b/dimos/utils/prompt.py @@ -0,0 +1,234 @@ +"""Unified prompts for DimOS. + +Usage:: + + from dimos.utils.prompt import confirm, sudo_prompt + + if confirm("Apply these changes now?", question_id="autoconf"): + ... + + if sudo_prompt("Need sudo for multicast setup"): + # sudo credentials are now cached + subprocess.run(["sudo", "route", "add", ...]) + +When running inside ``dio`` (the DimOS TUI), prompts are rendered as +modal popups. Otherwise styled terminal prompts are shown via *rich*. +""" + +from __future__ import annotations + +import getpass +import subprocess +import sys +import threading +from typing import Any + +# --------------------------------------------------------------------------- +# Global hooks — set by the dio app when it is running so that prompts +# can route through the TUI instead of stdin. +# --------------------------------------------------------------------------- + +_dio_confirm_hook: Any = None # callable(message, default) -> bool | None +_dio_sudo_hook: Any = None # callable(message) -> bool | None +_lock = threading.Lock() + +# In-memory answer cache keyed by question_id +_answer_cache: dict[str, bool] = {} + + +def set_dio_hook(hook: Any) -> None: + """Register the dio TUI confirm handler (called by DUIApp on startup).""" + global _dio_confirm_hook + with _lock: + _dio_confirm_hook = hook + + +def set_dio_sudo_hook(hook: Any) -> None: + """Register the dio TUI sudo handler (called by DUIApp on startup).""" + global _dio_sudo_hook + with _lock: + _dio_sudo_hook = hook + + +def clear_dio_hook() -> None: + """Unregister all dio TUI handlers (called by DUIApp on shutdown).""" + global _dio_confirm_hook, _dio_sudo_hook + with _lock: + _dio_confirm_hook = None + _dio_sudo_hook = None + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def confirm( + message: str, + *, + default: bool = False, + question_id: str | None = None, +) -> bool: + """Ask the user a yes/no question and return the answer. + + Parameters + ---------- + message: + The question to display. + default: + The value returned when the user presses Enter without typing. + question_id: + Optional stable identifier. If provided, the answer is cached + in memory and subsequent calls with the same id return the + cached value without prompting again. + + Returns + ------- + bool + ``True`` for yes, ``False`` for no. + """ + if question_id is not None: + with _lock: + if question_id in _answer_cache: + return _answer_cache[question_id] + + with _lock: + hook = _dio_confirm_hook + + if hook is not None: + result = hook(message, default) + if result is not None: + if question_id is not None: + with _lock: + _answer_cache[question_id] = result + return result + + # Non-interactive stdin (piped, /dev/null, etc.) — auto-accept default + if not sys.stdin.isatty(): + answer_str = "yes" if default else "no" + print(f"assuming {answer_str} for: {message}") + if question_id is not None: + with _lock: + _answer_cache[question_id] = default + return default + + # Fallback: nice terminal prompt via rich + result = _terminal_confirm(message, default) + if question_id is not None: + with _lock: + _answer_cache[question_id] = result + return result + + +def sudo_prompt(message: str = "sudo password required") -> bool: + """Prompt for a sudo password and cache the credentials. + + In dio, this shows a password input modal. Outside dio, it uses + getpass in the terminal. Either way, the password is passed to + ``sudo -S true`` to validate and cache credentials. + + Returns + ------- + bool + ``True`` if sudo credentials are now cached, ``False`` if the + user cancelled or the password was wrong. + """ + # Check if sudo is already cached (no password needed) + result = subprocess.run( + ["sudo", "-n", "true"], + capture_output=True, + ) + if result.returncode == 0: + return True + + # Non-interactive stdin — can't prompt for password + if not sys.stdin.isatty(): + print(f"assuming no for: {message} (cannot prompt for password non-interactively)") + return False + + with _lock: + hook = _dio_sudo_hook + + if hook is not None: + result = hook(message) + if result is not None: + return result + + return _terminal_sudo(message) + + +# --------------------------------------------------------------------------- +# Terminal fallbacks +# --------------------------------------------------------------------------- + + +def _terminal_confirm(message: str, default: bool) -> bool: + """Rich-powered terminal yes/no prompt.""" + try: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + console = Console() + + body = Text(message, style="bold #b5e4f4") + + console.print() + console.print(Panel(body, border_style="#00eeee", padding=(1, 3), title="[bold #00eeee]confirm[/bold #00eeee]", title_align="left")) + + if default: + console.print("[bold #00eeee]y[/bold #00eeee][#404040]/n:[/#404040] ", end="") + else: + console.print("[#404040]y/[/#404040][bold #00eeee]n[/bold #00eeee][#404040]:[/#404040] ", end="") + + try: + answer = input().strip().lower() + except (EOFError, KeyboardInterrupt): + return default + if not answer: + return default + return answer in ("y", "yes") + except Exception: + # Absolute last resort + hint = "[Y/n]" if default else "[y/N]" + try: + answer = input(f"{message} {hint} ").strip().lower() + except (EOFError, KeyboardInterrupt): + return default + if not answer: + return default + return answer in ("y", "yes") + + +def _terminal_sudo(message: str) -> bool: + """Terminal sudo prompt using getpass.""" + try: + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + console = Console() + body = Text(message, style="bold #b5e4f4") + console.print() + console.print(Panel(body, border_style="#ffcc00", padding=(1, 3), title="[bold #ffcc00]sudo[/bold #ffcc00]", title_align="left")) + except Exception: + print(message) + + for attempt in range(3): + try: + password = getpass.getpass("\033[93mPassword:\033[0m ") + except (EOFError, KeyboardInterrupt): + return False + if not password: + return False + + result = subprocess.run( + ["sudo", "-S", "true"], + input=password + "\n", + capture_output=True, + text=True, + ) + if result.returncode == 0: + return True + print("\033[91mIncorrect password, try again.\033[0m" if attempt < 2 else "\033[91mIncorrect password.\033[0m") + return False diff --git a/pyproject.toml b/pyproject.toml index 422ddb2ef3..1618061aa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ foxglove-bridge = "dimos.utils.cli.foxglove_bridge.run_foxglove_bridge:main" agentspy = "dimos.utils.cli.agentspy.agentspy:main" humancli = "dimos.utils.cli.human.humanclianim:main" dimos = "dimos.robot.cli.dimos:main" +dio = "dimos.utils.cli.dui.app:main" rerun-bridge = "dimos.visualization.rerun.bridge:app" doclinks = "dimos.utils.docs.doclinks:main" dtop = "dimos.utils.cli.dtop:main" From d7b4264a1d8950b834bd3880924b7a411deaa278 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 10 Mar 2026 17:29:30 -0700 Subject: [PATCH 03/23] open log file works --- dimos/utils/cli/dui/sub_apps/runner.py | 84 ++++++++++++++++++++------ 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py index 19556dc840..f7afb03014 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Runner sub-app — blueprint launcher with log viewer.""" from __future__ import annotations @@ -7,15 +21,17 @@ import subprocess import sys import threading -from typing import Any +from typing import TYPE_CHECKING, Any -from textual.app import ComposeResult from textual.containers import Horizontal from textual.widgets import Button, Input, Label, ListItem, ListView, RichLog, Static from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_app import SubApp +if TYPE_CHECKING: + from textual.app import ComposeResult + class RunnerSubApp(SubApp): TITLE = "runner" @@ -116,6 +132,18 @@ class RunnerSubApp(SubApp): color: #ffffff; border: solid #44aacc; }} + RunnerSubApp #btn-open-log {{ + border: solid #445566; + color: #8899aa; + }} + RunnerSubApp #btn-open-log:hover {{ + border: solid #8899aa; + }} + RunnerSubApp #btn-open-log:focus {{ + background: #445566; + color: #ffffff; + border: solid #8899aa; + }} """ def __init__(self) -> None: @@ -136,6 +164,7 @@ def compose(self) -> ComposeResult: with Horizontal(classes="run-controls", id="run-controls"): yield Button("Stop", id="btn-stop", variant="error") yield Button("Restart", id="btn-restart", variant="warning") + yield Button("Open Log File", id="btn-open-log") yield Button("Pick Blueprint", id="btn-pick") yield Static("", id="runner-status", classes="status-bar") @@ -239,14 +268,13 @@ def _show_log_mode(self) -> None: self.query_one("#run-controls").styles.display = "block" self.query_one("#btn-stop").styles.display = "block" self.query_one("#btn-restart").styles.display = "block" + self.query_one("#btn-open-log").styles.display = "block" self.query_one("#btn-pick").styles.display = "none" self.query_one("#btn-stop", Button).focus() entry = self._running_entry if entry: status = self.query_one("#runner-status", Static) - status.update( - f"Running: {entry.blueprint} (PID {entry.pid})" - ) + status.update(f"Running: {entry.blueprint} (PID {entry.pid})") self._start_log_follow(entry) def _show_failed_mode(self) -> None: @@ -257,6 +285,7 @@ def _show_failed_mode(self) -> None: self.query_one("#run-controls").styles.display = "block" self.query_one("#btn-stop").styles.display = "none" self.query_one("#btn-restart").styles.display = "none" + self.query_one("#btn-open-log").styles.display = "block" self.query_one("#btn-pick").styles.display = "block" status = self.query_one("#runner-status", Static) status.update("Launch failed — pick a blueprint to try again") @@ -269,7 +298,12 @@ def _start_log_follow(self, entry: Any) -> None: def _follow() -> None: try: - from dimos.core.log_viewer import follow_log, format_line, read_log, resolve_log_path + from dimos.core.log_viewer import ( + follow_log, + format_line, + read_log, + resolve_log_path, + ) path = resolve_log_path(entry.run_id) if not path: @@ -311,7 +345,7 @@ def on_list_view_selected(self, event: ListView.Selected) -> None: def _get_visible_buttons(self) -> list[Button]: """Return the currently visible control buttons in order.""" buttons: list[Button] = [] - for bid in ("btn-stop", "btn-restart", "btn-pick"): + for bid in ("btn-stop", "btn-restart", "btn-open-log", "btn-pick"): try: btn = self.query_one(f"#{bid}", Button) if btn.styles.display != "none": @@ -433,12 +467,14 @@ def _stream_output() -> None: rc = proc.returncode if rc != 0: self.app.call_from_thread( - log_widget.write, f"[{theme.YELLOW}]Process exited with code {rc}[/{theme.YELLOW}]" + log_widget.write, + f"[{theme.YELLOW}]Process exited with code {rc}[/{theme.YELLOW}]", ) except Exception as e: self.app.call_from_thread(log_widget.write, f"[red]Stream error: {e}[/red]") finally: self._child_proc = None + # After the launch command finishes, poll will pick up # the running entry and switch to log-follow mode. def _after() -> None: @@ -447,6 +483,7 @@ def _after() -> None: self._show_log_mode() else: self._show_failed_mode() + self.app.call_from_thread(_after) threading.Thread(target=_stream_output, daemon=True).start() @@ -456,6 +493,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: self._stop_running() elif event.button.id == "btn-restart": self._restart_running() + elif event.button.id == "btn-open-log": + self._open_log_in_editor() elif event.button.id == "btn-pick": self._go_to_list() @@ -498,7 +537,9 @@ def _do_stop() -> None: from dimos.core.run_registry import stop_entry msg, _ = stop_entry(entry) - self.app.call_from_thread(log_widget.write, f"[{theme.YELLOW}]{msg}[/{theme.YELLOW}]") + self.app.call_from_thread( + log_widget.write, f"[{theme.YELLOW}]{msg}[/{theme.YELLOW}]" + ) except Exception as e: self.app.call_from_thread(log_widget.write, f"[red]Stop error: {e}[/red]") @@ -528,18 +569,25 @@ def _restart_running(self) -> None: self._stop_running(then_launch=name) def _open_log_in_editor(self) -> None: - entry = self._running_entry - if not entry: - return try: from dimos.core.log_viewer import resolve_log_path - path = resolve_log_path(entry.run_id) - if path: - editor = os.environ.get("EDITOR", "vi") - self.app.suspend() - os.system(f"{editor} {path}") - self.app.resume() + # Try the running entry first, then fall back to most recent + entry = self._running_entry + if entry: + path = resolve_log_path(entry.run_id) + else: + path = resolve_log_path() # resolves most recent + + if not path: + log_widget = self.query_one("#runner-log", RichLog) + log_widget.write("[dim]No log file found[/dim]") + return + + editor = os.environ.get("EDITOR", "vi") + self.app.suspend() + os.system(f"{editor} {path}") + self.app.resume() except Exception: pass From 36d454ea06a76c0989355b4762272d45fb79bd6d Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 10 Mar 2026 20:05:52 -0700 Subject: [PATCH 04/23] good globalconfig editing, better key control message, problem with reconnect to logger --- dimos/protocol/service/lcmservice.py | 8 +- dimos/utils/cli/dui/app.py | 1 + dimos/utils/cli/dui/sub_apps/__init__.py | 6 +- dimos/utils/cli/dui/sub_apps/config.py | 119 +++- dimos/utils/cli/dui/sub_apps/dtop.py | 27 +- dimos/utils/cli/dui/sub_apps/launcher.py | 291 +++++++++ dimos/utils/cli/dui/sub_apps/runner.py | 714 ++++++++++++----------- 7 files changed, 811 insertions(+), 355 deletions(-) create mode 100644 dimos/utils/cli/dui/sub_apps/launcher.py diff --git a/dimos/protocol/service/lcmservice.py b/dimos/protocol/service/lcmservice.py index f414ce9e23..d294217138 100644 --- a/dimos/protocol/service/lcmservice.py +++ b/dimos/protocol/service/lcmservice.py @@ -116,10 +116,10 @@ def start(self) -> None: else: self.l = lcm.LCM(self.config.url) if self.config.url else lcm.LCM() - try: - autoconf(check_only=not self.config.autoconf) - except Exception as e: - print(f"Error checking system configuration: {e}") + # try: + # autoconf(check_only=not self.config.autoconf) + # except Exception as e: + # print(f"Error checking system configuration: {e}") self._stop_event.clear() self._thread = threading.Thread(target=self._lcm_loop) diff --git a/dimos/utils/cli/dui/app.py b/dimos/utils/cli/dui/app.py index da8a68b22f..4ebda9be0b 100644 --- a/dimos/utils/cli/dui/app.py +++ b/dimos/utils/cli/dui/app.py @@ -91,6 +91,7 @@ def _log(self, msg: str) -> None: # ------------------------------------------------------------------ def compose(self) -> ComposeResult: + yield Static("", id="hint-bar") with Container(id="sidebar"): for i, cls in enumerate(self._sub_app_classes): yield Static(cls.TITLE, classes="tab-item", id=f"tab-{i}") diff --git a/dimos/utils/cli/dui/sub_apps/__init__.py b/dimos/utils/cli/dui/sub_apps/__init__.py index bc95801c70..d12c17cf30 100644 --- a/dimos/utils/cli/dui/sub_apps/__init__.py +++ b/dimos/utils/cli/dui/sub_apps/__init__.py @@ -14,11 +14,13 @@ def get_sub_apps() -> list[type[SubApp]]: from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp from dimos.utils.cli.dui.sub_apps.dtop import DtopSubApp from dimos.utils.cli.dui.sub_apps.humancli import HumanCLISubApp + from dimos.utils.cli.dui.sub_apps.launcher import LauncherSubApp from dimos.utils.cli.dui.sub_apps.lcmspy import LCMSpySubApp - from dimos.utils.cli.dui.sub_apps.runner import RunnerSubApp + from dimos.utils.cli.dui.sub_apps.runner import StatusSubApp return [ - RunnerSubApp, + LauncherSubApp, + StatusSubApp, ConfigSubApp, DtopSubApp, LCMSpySubApp, diff --git a/dimos/utils/cli/dui/sub_apps/config.py b/dimos/utils/cli/dui/sub_apps/config.py index 8797a7a774..20961466ef 100644 --- a/dimos/utils/cli/dui/sub_apps/config.py +++ b/dimos/utils/cli/dui/sub_apps/config.py @@ -5,16 +5,91 @@ import json import sys from pathlib import Path +from typing import Any +from rich.text import Text from textual.app import ComposeResult from textual.containers import Horizontal -from textual.widgets import Input, Label, Select, Static, Switch +from textual.message import Message +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Input, Label, Static, Switch from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_app import SubApp _VIEWER_OPTIONS = ["rerun", "rerun-web", "rerun-connect", "foxglove", "none"] + +class _FormNavigationMixin: + """Mixin that intercepts Up/Down to move focus between config fields.""" + + _FIELD_ORDER = ("cfg-viewer", "cfg-n-workers", "cfg-robot-ip", "cfg-dtop") + + def _navigate_field(self, delta: int) -> None: + my_id = getattr(self, "id", None) + if my_id not in self._FIELD_ORDER: + return + idx = list(self._FIELD_ORDER).index(my_id) + new_idx = (idx + delta) % len(self._FIELD_ORDER) + try: + self.screen.query_one(f"#{self._FIELD_ORDER[new_idx]}").focus() # type: ignore[attr-defined] + except Exception: + pass + + +class CycleSelect(_FormNavigationMixin, Widget, can_focus=True): + """A focusable selector that cycles through options with Left/Right.""" + + BINDINGS = [ + ("left", "cycle(-1)", "Previous"), + ("right", "cycle(1)", "Next"), + ("enter", "cycle(1)", "Next"), + ("space", "cycle(1)", "Next"), + ("down", "nav(1)", "Next field"), + ("up", "nav(-1)", "Previous field"), + ] + + current_value: reactive[str] = reactive("") + + class Changed(Message): + def __init__(self, value: str) -> None: + super().__init__() + self.value = value + + def __init__(self, options: list[str], value: str, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._options = list(options) + self._index = options.index(value) if value in options else 0 + self.current_value = options[self._index] + + def render(self) -> Text: + txt = Text(justify="left") + txt.append(" ◀ ", style="bold") + txt.append(self.current_value, style="bold") + txt.append(" ▶ ", style="bold") + return txt + + def action_cycle(self, delta: int) -> None: + self._index = (self._index + delta) % len(self._options) + self.current_value = self._options[self._index] + self.post_message(self.Changed(self.current_value)) + + def action_nav(self, delta: int) -> None: + self._navigate_field(delta) + + +class ConfigInput(_FormNavigationMixin, Input): + """Input that uses Up/Down for form navigation instead of history.""" + + BINDINGS = [ + ("down", "nav(1)", "Next field"), + ("up", "nav(-1)", "Previous field"), + ] + + def action_nav(self, delta: int) -> None: + self._navigate_field(delta) + _DEFAULTS: dict[str, object] = { "viewer": "rerun", "n_workers": 2, @@ -74,11 +149,20 @@ class ConfigSubApp(SubApp): color: {theme.CYAN}; margin-bottom: 0; }} - ConfigSubApp Input {{ + ConfigSubApp Input, ConfigSubApp ConfigInput {{ width: 40; }} - ConfigSubApp Select {{ + ConfigSubApp CycleSelect {{ width: 40; + height: 3; + background: {theme.BACKGROUND}; + color: {theme.ACCENT}; + border: solid {theme.DIM}; + content-align: left middle; + }} + ConfigSubApp CycleSelect:focus {{ + border: solid {theme.CYAN}; + color: {theme.CYAN}; }} ConfigSubApp .switch-row {{ height: 3; @@ -96,6 +180,11 @@ class ConfigSubApp(SubApp): ConfigSubApp .switch-state.--on {{ color: {theme.CYAN}; }} + ConfigSubApp #cfg-dirty-notice {{ + margin-top: 1; + color: {theme.YELLOW}; + display: none; + }} """ def __init__(self) -> None: @@ -107,17 +196,17 @@ def compose(self) -> ComposeResult: yield Static("GlobalConfig Editor", classes="subapp-header") yield Label("viewer", classes="field-label") - yield Select( - [(opt, opt) for opt in _VIEWER_OPTIONS], + yield CycleSelect( + _VIEWER_OPTIONS, value=str(v.get("viewer", "rerun")), id="cfg-viewer", ) yield Label("n_workers", classes="field-label") - yield Input(value=str(v.get("n_workers", 2)), id="cfg-n-workers", type="integer") + yield ConfigInput(value=str(v.get("n_workers", 2)), id="cfg-n-workers", type="integer") yield Label("robot_ip", classes="field-label") - yield Input(value=str(v.get("robot_ip", "")), placeholder="e.g. 192.168.12.1", id="cfg-robot-ip") + yield ConfigInput(value=str(v.get("robot_ip", "")), placeholder="e.g. 192.168.12.1", id="cfg-robot-ip") dtop_val = bool(v.get("dtop", False)) with Horizontal(classes="switch-row"): @@ -128,10 +217,15 @@ def compose(self) -> ComposeResult: state.add_class("--on") yield state - def on_select_changed(self, event: Select.Changed) -> None: - if event.select.id == "cfg-viewer": - self.config_values["viewer"] = event.value - _save_config(self.config_values) + yield Static("edits only take effect on new blueprint launch", id="cfg-dirty-notice") + + def _mark_dirty(self) -> None: + self.query_one("#cfg-dirty-notice").styles.display = "block" + + def on_cycle_select_changed(self, event: CycleSelect.Changed) -> None: + self.config_values["viewer"] = event.value + _save_config(self.config_values) + self._mark_dirty() def on_input_changed(self, event: Input.Changed) -> None: if event.input.id == "cfg-n-workers": @@ -140,9 +234,11 @@ def on_input_changed(self, event: Input.Changed) -> None: except ValueError: pass _save_config(self.config_values) + self._mark_dirty() elif event.input.id == "cfg-robot-ip": self.config_values["robot_ip"] = event.value _save_config(self.config_values) + self._mark_dirty() def on_switch_changed(self, event: Switch.Changed) -> None: if event.switch.id == "cfg-dtop": @@ -155,6 +251,7 @@ def on_switch_changed(self, event: Switch.Changed) -> None: state_label.update("OFF") state_label.remove_class("--on") _save_config(self.config_values) + self._mark_dirty() def get_overrides(self) -> dict[str, object]: """Return config overrides for use by the runner.""" diff --git a/dimos/utils/cli/dui/sub_apps/dtop.py b/dimos/utils/cli/dui/sub_apps/dtop.py index aa53b7298d..3dffca4c5a 100644 --- a/dimos/utils/cli/dui/sub_apps/dtop.py +++ b/dimos/utils/cli/dui/sub_apps/dtop.py @@ -31,6 +31,7 @@ class DtopSubApp(SubApp): DEFAULT_CSS = f""" DtopSubApp {{ layout: vertical; + height: 1fr; background: {theme.BACKGROUND}; }} DtopSubApp VerticalScroll {{ @@ -56,9 +57,20 @@ def __init__(self) -> None: self._last_msg_time: float = 0.0 self._cpu_history: dict[str, deque[float]] = {} + def _waiting_panel(self) -> Panel: + return Panel( + Text( + "Waiting for resource stats...\nuse `dimos --dtop ...` to emit stats", + style=theme.FOREGROUND, + justify="center", + ), + border_style=theme.CYAN, + expand=False, + ) + def compose(self) -> ComposeResult: - with VerticalScroll(id="dtop-scroll"): - yield Static(id="dtop-panels") + with VerticalScroll(id="dtop-scroll", classes="waiting"): + yield Static(self._waiting_panel(), id="dtop-panels") def get_focus_target(self) -> object | None: """Return the VerticalScroll for focus.""" @@ -106,16 +118,7 @@ def _refresh(self) -> None: return if data is None: scroll.add_class("waiting") - waiting = Panel( - Text( - "Waiting for resource stats...\nuse `dimos --dtop ...` to emit stats", - style=theme.FOREGROUND, - justify="center", - ), - border_style=theme.CYAN, - expand=False, - ) - self.query_one("#dtop-panels", Static).update(waiting) + self.query_one("#dtop-panels", Static).update(self._waiting_panel()) return scroll.remove_class("waiting") diff --git a/dimos/utils/cli/dui/sub_apps/launcher.py b/dimos/utils/cli/dui/sub_apps/launcher.py new file mode 100644 index 0000000000..e913791064 --- /dev/null +++ b/dimos/utils/cli/dui/sub_apps/launcher.py @@ -0,0 +1,291 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Launcher sub-app — blueprint picker and launcher.""" + +from __future__ import annotations + +import os +import subprocess +import sys +import threading +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from textual.widgets import Button, Input, Label, ListItem, ListView, Static + +from dimos.utils.cli import theme +from dimos.utils.cli.dui.sub_app import SubApp + +if TYPE_CHECKING: + from textual.app import ComposeResult + + +def _launch_log_path() -> Path: + """Well-known path for launch stdout/stderr.""" + xdg = os.environ.get("XDG_STATE_HOME") + base = Path(xdg) / "dimos" if xdg else Path.home() / ".local" / "state" / "dimos" + base.mkdir(parents=True, exist_ok=True) + return base / "launch.log" + + +def _is_blueprint_running() -> bool: + """Return True if a blueprint is currently running.""" + try: + from dimos.core.run_registry import get_most_recent + + return get_most_recent(alive_only=True) is not None + except Exception: + return False + + +class LauncherSubApp(SubApp): + TITLE = "launch" + + DEFAULT_CSS = f""" + LauncherSubApp {{ + layout: vertical; + height: 1fr; + background: {theme.BACKGROUND}; + }} + LauncherSubApp .subapp-header {{ + width: 100%; + height: auto; + color: #ff8800; + padding: 1 2; + text-style: bold; + }} + LauncherSubApp #launch-filter {{ + width: 100%; + background: {theme.BACKGROUND}; + border: solid {theme.DIM}; + color: {theme.ACCENT}; + }} + LauncherSubApp #launch-filter:focus {{ + border: solid {theme.CYAN}; + }} + LauncherSubApp ListView {{ + height: 1fr; + background: {theme.BACKGROUND}; + }} + LauncherSubApp ListView > ListItem {{ + background: {theme.BACKGROUND}; + color: {theme.ACCENT}; + padding: 1 2; + }} + LauncherSubApp ListView > ListItem.--highlight {{ + background: #1a2a2a; + }} + LauncherSubApp.--locked ListView {{ + opacity: 0.35; + }} + LauncherSubApp.--locked #launch-filter {{ + opacity: 0.35; + }} + LauncherSubApp .status-bar {{ + height: 1; + dock: bottom; + background: #1a2020; + color: {theme.DIM}; + padding: 0 1; + }} + """ + + def __init__(self) -> None: + super().__init__() + self._blueprints: list[str] = [] + self._filtered: list[str] = [] + self._launching = False + + def compose(self) -> ComposeResult: + yield Static("Blueprint Launcher", classes="subapp-header") + yield Input(placeholder="Type to filter blueprints...", id="launch-filter") + yield ListView(id="launch-list") + yield Static("", id="launch-status", classes="status-bar") + + def on_mount_subapp(self) -> None: + self._populate_blueprints() + self._sync_status() + self.set_interval(2.0, self._sync_status) + + def get_focus_target(self) -> object | None: + try: + return self.query_one("#launch-filter", Input) + except Exception: + return super().get_focus_target() + + def _populate_blueprints(self) -> None: + try: + from dimos.robot.all_blueprints import all_blueprints + + self._blueprints = sorted( + name for name in all_blueprints if not name.startswith("demo-") + ) + except Exception: + self._blueprints = [] + + self._filtered = list(self._blueprints) + self._rebuild_list() + + def _rebuild_list(self) -> None: + lv = self.query_one("#launch-list", ListView) + lv.clear() + for name in self._filtered: + lv.append(ListItem(Label(name))) + if self._filtered: + lv.index = 0 + + @property + def _is_locked(self) -> bool: + """True if launching is blocked (already running or mid-launch).""" + return self._launching or _is_blueprint_running() + + def _sync_status(self) -> None: + status = self.query_one("#launch-status", Static) + locked = self._is_locked + filter_input = self.query_one("#launch-filter", Input) + lv = self.query_one("#launch-list", ListView) + + if locked: + self.add_class("--locked") + filter_input.disabled = True + lv.disabled = True + else: + self.remove_class("--locked") + filter_input.disabled = False + lv.disabled = False + + if self._launching: + return # don't overwrite "Launching..." message + if _is_blueprint_running(): + try: + from dimos.core.run_registry import get_most_recent + + entry = get_most_recent(alive_only=True) + name = entry.blueprint if entry else "unknown" + status.update(f"Already running: {name} — stop it first") + except Exception: + status.update("A blueprint is already running") + else: + status.update("Up/Down: navigate | Enter: launch | Type to filter") + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "launch-filter": + q = event.value.strip().lower() + if not q: + self._filtered = list(self._blueprints) + else: + self._filtered = [n for n in self._blueprints if q in n.lower()] + self._rebuild_list() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "launch-filter": + if self._is_locked: + return + lv = self.query_one("#launch-list", ListView) + idx = lv.index + if idx is not None and 0 <= idx < len(self._filtered): + self._launch(self._filtered[idx]) + + def on_list_view_selected(self, event: ListView.Selected) -> None: + if self._is_locked: + return + lv = self.query_one("#launch-list", ListView) + idx = lv.index + if idx is not None and 0 <= idx < len(self._filtered): + self._launch(self._filtered[idx]) + + def on_key(self, event: Any) -> None: + key = getattr(event, "key", "") + focused = self.app.focused + filter_input = self.query_one("#launch-filter", Input) + if focused is filter_input and key in ("up", "down"): + lv = self.query_one("#launch-list", ListView) + if self._filtered: + current = lv.index or 0 + if key == "up": + lv.index = max(0, current - 1) + else: + lv.index = min(len(self._filtered) - 1, current + 1) + event.prevent_default() + event.stop() + + def _launch(self, name: str) -> None: + if self._is_locked: + self._sync_status() + return + + self._launching = True + self._sync_status() # lock the UI immediately + status = self.query_one("#launch-status", Static) + status.update(f"Launching {name}...") + + # Gather config overrides + config_args: list[str] = [] + try: + from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp + + for inst in self.app._instances: # type: ignore[attr-defined] + if isinstance(inst, ConfigSubApp): + for k, v in inst.get_overrides().items(): + cli_key = k.replace("_", "-") + if isinstance(v, bool): + config_args.append(f"--{cli_key}" if v else f"--no-{cli_key}") + else: + config_args.extend([f"--{cli_key}", str(v)]) + break + except Exception: + pass + + cmd = [sys.executable, "-m", "dimos.robot.cli.dimos", *config_args, "run", "--daemon", name] + + def _do_launch() -> None: + log_file = _launch_log_path() + # Preserve ANSI colors in piped output + env = os.environ.copy() + env["FORCE_COLOR"] = "1" + env["PYTHONUNBUFFERED"] = "1" + env["TERM"] = env.get("TERM", "xterm-256color") + try: + with open(log_file, "w") as f: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=f, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + proc.wait() + rc = proc.returncode + + def _after() -> None: + self._launching = False + if rc != 0: + s = self.query_one("#launch-status", Static) + s.update(f"Launch failed (exit code {rc})") + self._sync_status() + + self.app.call_from_thread(_after) + except Exception as e: + + def _err() -> None: + self._launching = False + s = self.query_one("#launch-status", Static) + s.update(f"Launch error: {e}") + self._sync_status() + + self.app.call_from_thread(_err) + + threading.Thread(target=_do_launch, daemon=True).start() diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py index f7afb03014..4b76e01d3d 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -12,19 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Runner sub-app — blueprint launcher with log viewer.""" +"""Status sub-app — log viewer and blueprint lifecycle controls.""" from __future__ import annotations import os -import signal import subprocess import sys import threading +import time +from pathlib import Path from typing import TYPE_CHECKING, Any -from textual.containers import Horizontal -from textual.widgets import Button, Input, Label, ListItem, ListView, RichLog, Static +from rich.panel import Panel +from rich.text import Text +from textual.containers import Horizontal, VerticalScroll +from textual.widgets import Button, RichLog, Static from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_app import SubApp @@ -33,113 +36,106 @@ from textual.app import ComposeResult -class RunnerSubApp(SubApp): - TITLE = "runner" +def _launch_log_path() -> Path: + """Well-known path for launch stdout/stderr (shared with launcher).""" + xdg = os.environ.get("XDG_STATE_HOME") + base = Path(xdg) / "dimos" if xdg else Path.home() / ".local" / "state" / "dimos" + return base / "launch.log" + + +class StatusSubApp(SubApp): + TITLE = "status" DEFAULT_CSS = f""" - RunnerSubApp {{ + StatusSubApp {{ layout: vertical; + height: 1fr; background: {theme.BACKGROUND}; }} - RunnerSubApp .subapp-header {{ + StatusSubApp .subapp-header {{ width: 100%; height: auto; color: #ff8800; padding: 1 2; text-style: bold; }} - RunnerSubApp #runner-filter {{ - width: 100%; - margin: 0 0 0 0; + StatusSubApp RichLog {{ + height: 1fr; background: {theme.BACKGROUND}; border: solid {theme.DIM}; - color: {theme.ACCENT}; - }} - RunnerSubApp #runner-filter:focus {{ - border: solid {theme.CYAN}; + scrollbar-size: 0 0; }} - RunnerSubApp ListView {{ + StatusSubApp #idle-container {{ height: 1fr; - background: {theme.BACKGROUND}; - }} - RunnerSubApp ListView > ListItem {{ - background: {theme.BACKGROUND}; - color: {theme.ACCENT}; - padding: 1 2; - }} - RunnerSubApp ListView > ListItem.--highlight {{ - background: #1a2a2a; + align: center middle; }} - RunnerSubApp RichLog {{ - height: 1fr; - background: {theme.BACKGROUND}; - border: solid {theme.DIM}; - scrollbar-size: 0 0; + StatusSubApp #idle-panel {{ + width: auto; + background: transparent; }} - RunnerSubApp .run-controls {{ - dock: bottom; + StatusSubApp #run-controls {{ height: auto; padding: 0 1; background: {theme.BACKGROUND}; }} - RunnerSubApp .status-bar {{ - height: 1; - dock: bottom; - background: #1a2020; - color: {theme.DIM}; - padding: 0 1; - }} - RunnerSubApp .run-controls Button {{ + StatusSubApp #run-controls Button {{ margin: 0 1 0 0; min-width: 12; background: transparent; border: solid {theme.DIM}; color: {theme.ACCENT}; }} - RunnerSubApp #btn-stop {{ + StatusSubApp .status-bar {{ + height: 1; + dock: bottom; + background: #1a2020; + color: {theme.DIM}; + padding: 0 1; + }} + StatusSubApp #btn-stop {{ border: solid #882222; color: #cc4444; }} - RunnerSubApp #btn-stop:hover {{ + StatusSubApp #btn-stop:hover {{ border: solid #cc4444; }} - RunnerSubApp #btn-stop:focus {{ + StatusSubApp #btn-stop:focus {{ background: #882222; color: #ffffff; border: solid #cc4444; }} - RunnerSubApp #btn-restart {{ + StatusSubApp #btn-sudo-kill {{ + border: solid #882222; + color: #ff4444; + }} + StatusSubApp #btn-sudo-kill:hover {{ + border: solid #ff4444; + }} + StatusSubApp #btn-sudo-kill:focus {{ + background: #882222; + color: #ffffff; + border: solid #ff4444; + }} + StatusSubApp #btn-restart {{ border: solid #886600; color: #ccaa00; }} - RunnerSubApp #btn-restart:hover {{ + StatusSubApp #btn-restart:hover {{ border: solid #ccaa00; }} - RunnerSubApp #btn-restart:focus {{ + StatusSubApp #btn-restart:focus {{ background: #886600; color: #ffffff; border: solid #ccaa00; }} - RunnerSubApp #btn-pick {{ - border: solid #226688; - color: #44aacc; - }} - RunnerSubApp #btn-pick:hover {{ - border: solid #44aacc; - }} - RunnerSubApp #btn-pick:focus {{ - background: #226688; - color: #ffffff; - border: solid #44aacc; - }} - RunnerSubApp #btn-open-log {{ + StatusSubApp #btn-open-log {{ border: solid #445566; color: #8899aa; }} - RunnerSubApp #btn-open-log:hover {{ + StatusSubApp #btn-open-log:hover {{ border: solid #8899aa; }} - RunnerSubApp #btn-open-log:focus {{ + StatusSubApp #btn-open-log:focus {{ background: #445566; color: #ffffff; border: solid #8899aa; @@ -151,45 +147,52 @@ def __init__(self) -> None: self._running_entry: Any = None self._log_thread: threading.Thread | None = None self._stop_log = False - self._blueprints: list[str] = [] - self._filtered: list[str] = [] - self._child_proc: subprocess.Popen[str] | None = None - self._launched_name: str | None = None + self._failed_stop_pid: int | None = None + self._following_launch_log = False + self._launch_log_mtime: float = 0.0 def compose(self) -> ComposeResult: - yield Static("Blueprint Runner", classes="subapp-header") - yield Input(placeholder="Type to filter blueprints...", id="runner-filter") - yield ListView(id="blueprint-list") + yield Static("Blueprint Status", classes="subapp-header") + with VerticalScroll(id="idle-container"): + yield Static(self._idle_panel(), id="idle-panel") yield RichLog(id="runner-log", markup=True, wrap=True) - with Horizontal(classes="run-controls", id="run-controls"): + with Horizontal(id="run-controls"): yield Button("Stop", id="btn-stop", variant="error") + yield Button("Force Kill (sudo)", id="btn-sudo-kill") yield Button("Restart", id="btn-restart", variant="warning") yield Button("Open Log File", id="btn-open-log") - yield Button("Pick Blueprint", id="btn-pick") yield Static("", id="runner-status", classes="status-bar") + def _idle_panel(self) -> Panel: + msg = Text(justify="center") + msg.append("No Blueprint Running\n\n", style="bold #cc4444") + msg.append("Use the ", style=theme.DIM) + msg.append("launch", style=f"bold {theme.CYAN}") + msg.append(" tab to start a blueprint", style=theme.DIM) + return Panel(msg, border_style=theme.DIM, expand=False) + def on_mount_subapp(self) -> None: self._check_running() - if self._running_entry is None: - self._populate_blueprints() - self._show_list_mode() + if self._running_entry is not None: + self._show_running() else: - self._show_log_mode() - # Poll the run registry so we can re-attach to blueprints - # launched (or stopped) from another terminal. - self.set_interval(2.0, self._poll_running) + # Check if there's a fresh launch log to tail + self._check_launch_log() + if not self._following_launch_log: + self._show_idle() + self.set_interval(1.0, self._poll_running) def get_focus_target(self) -> object | None: - """Return the widget that should receive focus.""" if self._running_entry is not None: try: return self.query_one("#runner-log", RichLog) except Exception: pass - try: - return self.query_one("#runner-filter", Input) - except Exception: - return super().get_focus_target() + return super().get_focus_target() + + # ------------------------------------------------------------------ + # State management + # ------------------------------------------------------------------ def _check_running(self) -> None: try: @@ -200,7 +203,6 @@ def _check_running(self) -> None: self._running_entry = None def _poll_running(self) -> None: - """Periodically check the run registry for state changes.""" old_entry = self._running_entry self._check_running() new_entry = self._running_entry @@ -208,88 +210,131 @@ def _poll_running(self) -> None: old_id = getattr(old_entry, "run_id", None) new_id = getattr(new_entry, "run_id", None) - if old_id == new_id: - return + if old_id != new_id: + if new_entry is not None: + # A daemon appeared — switch from launch log to JSONL follow + self._stop_log = True + self._following_launch_log = False + self._show_running() + return + elif old_entry is not None: + # Process ended + self._stop_log = True + self._following_launch_log = False + self._show_stopped("Process ended") + return - if new_entry is not None and old_entry is None: - self._stop_log = True - self._show_log_mode() - elif new_entry is None and old_entry is not None: - self._stop_log = True - self._populate_blueprints() - self._show_list_mode() - self.query_one("#runner-filter", Input).value = "" - else: - self._stop_log = True - self._show_log_mode() + # If nothing is running yet, check for a fresh launch log + if new_entry is None and not self._following_launch_log: + self._check_launch_log() - def _populate_blueprints(self) -> None: + def _check_launch_log(self) -> None: + """Detect a new/updated launch.log and start tailing it.""" + log_path = _launch_log_path() try: - from dimos.robot.all_blueprints import all_blueprints - - self._blueprints = sorted( - name for name in all_blueprints if not name.startswith("demo-") - ) - except Exception: - self._blueprints = [] - - self._filtered = list(self._blueprints) - self._rebuild_list() - - def _rebuild_list(self) -> None: - lv = self.query_one("#blueprint-list", ListView) - lv.clear() - for name in self._filtered: - lv.append(ListItem(Label(name))) - if self._filtered: - lv.index = 0 - - def _apply_filter(self, query: str) -> None: - q = query.strip().lower() - if not q: - self._filtered = list(self._blueprints) - else: - self._filtered = [n for n in self._blueprints if q in n.lower()] - self._rebuild_list() + mtime = log_path.stat().st_mtime + except FileNotFoundError: + return + # Only pick up if it was modified recently (within 30s) and is newer than last seen + if mtime <= self._launch_log_mtime: + return + if time.time() - mtime > 30: + return + self._launch_log_mtime = mtime + self._following_launch_log = True + self._show_launching(log_path) - def _show_list_mode(self) -> None: - self.query_one("#runner-filter").styles.display = "block" - self.query_one("#blueprint-list").styles.display = "block" - self.query_one("#runner-log").styles.display = "none" + def _show_launching(self, log_path: Path) -> None: + """Show the launch log output while the daemon is starting.""" + self.query_one("#idle-container").styles.display = "none" + self.query_one("#runner-log").styles.display = "block" self.query_one("#run-controls").styles.display = "none" - self.query_one("#btn-pick").styles.display = "none" status = self.query_one("#runner-status", Static) - status.update("Up/Down: navigate | Enter: run | Type to filter") + status.update("Launching blueprint...") + + self._stop_log = False + log_widget = self.query_one("#runner-log", RichLog) + log_widget.clear() - def _show_log_mode(self) -> None: - self.query_one("#runner-filter").styles.display = "none" - self.query_one("#blueprint-list").styles.display = "none" + def _tail() -> None: + try: + with open(log_path) as f: + while not self._stop_log: + line = f.readline() + if line: + rendered = Text.from_ansi(line.rstrip("\n")) + self.app.call_from_thread(log_widget.write, rendered) + else: + time.sleep(0.2) + # Check if the launch process finished (file stopped growing) + # and a daemon entry appeared — poll handles the transition + except Exception as e: + self.app.call_from_thread(log_widget.write, f"[red]Error reading launch log: {e}[/red]") + + self._log_thread = threading.Thread(target=_tail, daemon=True) + self._log_thread.start() + + def _show_running(self) -> None: + """Show controls for a running blueprint.""" + self.query_one("#idle-container").styles.display = "none" self.query_one("#runner-log").styles.display = "block" self.query_one("#run-controls").styles.display = "block" self.query_one("#btn-stop").styles.display = "block" + self.query_one("#btn-sudo-kill").styles.display = "none" self.query_one("#btn-restart").styles.display = "block" self.query_one("#btn-open-log").styles.display = "block" - self.query_one("#btn-pick").styles.display = "none" - self.query_one("#btn-stop", Button).focus() + self._failed_stop_pid = None entry = self._running_entry if entry: status = self.query_one("#runner-status", Static) status.update(f"Running: {entry.blueprint} (PID {entry.pid})") self._start_log_follow(entry) - def _show_failed_mode(self) -> None: - """Show log output with only a 'Pick Blueprint' button.""" - self.query_one("#runner-filter").styles.display = "none" - self.query_one("#blueprint-list").styles.display = "none" + def _show_stopped(self, message: str = "Stopped") -> None: + """Show controls for a stopped state with logs still visible.""" + self.query_one("#idle-container").styles.display = "none" self.query_one("#runner-log").styles.display = "block" self.query_one("#run-controls").styles.display = "block" self.query_one("#btn-stop").styles.display = "none" + self.query_one("#btn-sudo-kill").styles.display = "none" self.query_one("#btn-restart").styles.display = "none" self.query_one("#btn-open-log").styles.display = "block" - self.query_one("#btn-pick").styles.display = "block" + self._failed_stop_pid = None + self._following_launch_log = False + # Reset so the next launch.log write is always detected + self._launch_log_mtime = 0.0 status = self.query_one("#runner-status", Static) - status.update("Launch failed — pick a blueprint to try again") - self.query_one("#btn-pick", Button).focus() + status.update(message) + + def _show_idle(self) -> None: + """Show big idle message — no blueprint running.""" + self.query_one("#idle-container").styles.display = "block" + self.query_one("#runner-log").styles.display = "none" + self.query_one("#run-controls").styles.display = "none" + self._failed_stop_pid = None + self._following_launch_log = False + self._launch_log_mtime = 0.0 + + # Check if there are past logs to show in status bar + has_past = False + try: + from dimos.core.run_registry import get_most_recent + + entry = get_most_recent() + if entry: + has_past = True + status = self.query_one("#runner-status", Static) + status.update(f"Last run: {entry.blueprint} (run {entry.run_id})") + except Exception: + pass + + if not has_past: + status = self.query_one("#runner-status", Static) + status.update("No blueprint running") + + # ------------------------------------------------------------------ + # Log streaming + # ------------------------------------------------------------------ def _start_log_follow(self, entry: Any) -> None: self._stop_log = False @@ -325,27 +370,36 @@ def _follow() -> None: self._log_thread = threading.Thread(target=_follow, daemon=True) self._log_thread.start() - def on_input_changed(self, event: Input.Changed) -> None: - if event.input.id == "runner-filter": - self._apply_filter(event.value) + # ------------------------------------------------------------------ + # Button handling + # ------------------------------------------------------------------ - def on_input_submitted(self, event: Input.Submitted) -> None: - if event.input.id == "runner-filter": - lv = self.query_one("#blueprint-list", ListView) - idx = lv.index - if idx is not None and 0 <= idx < len(self._filtered): - self._launch_blueprint(self._filtered[idx]) + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn-stop": + self._stop_running() + elif event.button.id == "btn-sudo-kill": + self._sudo_kill() + elif event.button.id == "btn-restart": + self._restart_running() + elif event.button.id == "btn-open-log": + self._open_log_in_editor() - def on_list_view_selected(self, event: ListView.Selected) -> None: - lv = self.query_one("#blueprint-list", ListView) - idx = lv.index - if idx is not None and 0 <= idx < len(self._filtered): - self._launch_blueprint(self._filtered[idx]) + def on_key(self, event: Any) -> None: + key = getattr(event, "key", "") + if key in ("left", "right"): + self._cycle_button_focus(1 if key == "right" else -1) + event.prevent_default() + event.stop() + elif key == "enter": + focused = self.app.focused + if isinstance(focused, Button): + focused.press() + event.prevent_default() + event.stop() def _get_visible_buttons(self) -> list[Button]: - """Return the currently visible control buttons in order.""" buttons: list[Button] = [] - for bid in ("btn-stop", "btn-restart", "btn-open-log", "btn-pick"): + for bid in ("btn-stop", "btn-sudo-kill", "btn-restart", "btn-open-log"): try: btn = self.query_one(f"#{bid}", Button) if btn.styles.display != "none": @@ -355,7 +409,6 @@ def _get_visible_buttons(self) -> list[Button]: return buttons def _cycle_button_focus(self, delta: int) -> None: - """Move focus among visible control buttons by delta (+1 or -1).""" buttons = self._get_visible_buttons() if not buttons: return @@ -367,171 +420,26 @@ def _cycle_button_focus(self, delta: int) -> None: idx = 0 buttons[idx].focus() - def on_key(self, event: Any) -> None: - key = getattr(event, "key", "") - - # In list mode: arrow keys on filter input move the list selection - is_list_mode = ( - self._running_entry is None - and self._child_proc is None - and self.query_one("#runner-filter").styles.display != "none" - ) - if is_list_mode: - focused = self.app.focused - filter_input = self.query_one("#runner-filter", Input) - if focused is filter_input and key in ("up", "down"): - lv = self.query_one("#blueprint-list", ListView) - if self._filtered: - current = lv.index or 0 - if key == "up": - lv.index = max(0, current - 1) - else: - lv.index = min(len(self._filtered) - 1, current + 1) - event.prevent_default() - event.stop() - return - - # In log/failed mode: arrow keys navigate between buttons - controls_visible = self.query_one("#run-controls").styles.display != "none" - if controls_visible and key in ("left", "right"): - self._cycle_button_focus(1 if key == "right" else -1) - event.prevent_default() - event.stop() - return + # ------------------------------------------------------------------ + # Stop / restart / kill + # ------------------------------------------------------------------ - if controls_visible and key == "enter": - focused = self.app.focused - if isinstance(focused, Button): - focused.press() - event.prevent_default() - event.stop() - return - - def _launch_blueprint(self, name: str) -> None: - """Launch a blueprint in a fully detached child process.""" - log_widget = self.query_one("#runner-log", RichLog) - log_widget.clear() - # Switch to log view immediately - self.query_one("#runner-filter").styles.display = "none" - self.query_one("#blueprint-list").styles.display = "none" - self.query_one("#runner-log").styles.display = "block" - status = self.query_one("#runner-status", Static) - status.update(f"Launching {name}...") - - # Gather config overrides on the main thread - config_args: list[str] = [] - try: - from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp - - for inst in self.app._instances: # type: ignore[attr-defined] - if isinstance(inst, ConfigSubApp): - for k, v in inst.get_overrides().items(): - cli_key = k.replace("_", "-") - if isinstance(v, bool): - config_args.append(f"--{cli_key}" if v else f"--no-{cli_key}") - else: - config_args.extend([f"--{cli_key}", str(v)]) - break - except Exception: - pass - - cmd = [sys.executable, "-m", "dimos.robot.cli.dimos", *config_args, "run", "--daemon", name] - self._launched_name = name - self.query_one("#run-controls").styles.display = "block" - log_widget.write(f"[dim]$ {' '.join(cmd)}[/dim]") - - try: - # Launch in a fully detached process so it doesn't share - # CPU scheduling priority with the TUI event loop. - self._child_proc = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - start_new_session=True, # detach from our process group - ) - except Exception as e: - log_widget.write(f"[red]Launch error: {e}[/red]") - return - - # Stream stdout in a background thread - proc = self._child_proc - - def _stream_output() -> None: - try: - assert proc.stdout is not None - for line in proc.stdout: - self.app.call_from_thread(log_widget.write, line.rstrip("\n")) - proc.wait() - rc = proc.returncode - if rc != 0: - self.app.call_from_thread( - log_widget.write, - f"[{theme.YELLOW}]Process exited with code {rc}[/{theme.YELLOW}]", - ) - except Exception as e: - self.app.call_from_thread(log_widget.write, f"[red]Stream error: {e}[/red]") - finally: - self._child_proc = None - - # After the launch command finishes, poll will pick up - # the running entry and switch to log-follow mode. - def _after() -> None: - self._check_running() - if self._running_entry: - self._show_log_mode() - else: - self._show_failed_mode() - - self.app.call_from_thread(_after) - - threading.Thread(target=_stream_output, daemon=True).start() - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn-stop": - self._stop_running() - elif event.button.id == "btn-restart": - self._restart_running() - elif event.button.id == "btn-open-log": - self._open_log_in_editor() - elif event.button.id == "btn-pick": - self._go_to_list() - - def _go_to_list(self) -> None: - """Switch back to the blueprint picker.""" - self._stop_log = True - self._running_entry = None - self._populate_blueprints() - self._show_list_mode() - self.query_one("#runner-filter", Input).value = "" - self.query_one("#runner-filter", Input).focus() - - def _stop_running(self, *, then_launch: str | None = None) -> None: + def _stop_running(self) -> None: self._stop_log = True log_widget = self.query_one("#runner-log", RichLog) status = self.query_one("#runner-status", Static) status.update("Stopping blueprint...") - # Disable buttons so user can't double-tap for bid in ("btn-stop", "btn-restart"): try: self.query_one(f"#{bid}", Button).disabled = True except Exception: pass - # Kill the launch subprocess immediately (non-blocking) - if self._child_proc is not None: - try: - self._child_proc.send_signal(signal.SIGTERM) - log_widget.write(f"[{theme.YELLOW}]Stopped launch process[/{theme.YELLOW}]") - except Exception: - pass - self._child_proc = None - entry = self._running_entry self._running_entry = None def _do_stop() -> None: + permission_error = False if entry: try: from dimos.core.run_registry import stop_entry @@ -540,23 +448,31 @@ def _do_stop() -> None: self.app.call_from_thread( log_widget.write, f"[{theme.YELLOW}]{msg}[/{theme.YELLOW}]" ) + except PermissionError: + permission_error = True + self.app.call_from_thread( + log_widget.write, + f"[red]Permission denied — cannot stop PID {entry.pid}[/red]", + ) except Exception as e: + if "permission" in str(e).lower() or "operation not permitted" in str(e).lower(): + permission_error = True self.app.call_from_thread(log_widget.write, f"[red]Stop error: {e}[/red]") def _after_stop() -> None: - # Re-enable buttons for bid in ("btn-stop", "btn-restart"): try: self.query_one(f"#{bid}", Button).disabled = False except Exception: pass - if then_launch: - self._launch_blueprint(then_launch) + if permission_error and entry: + self._failed_stop_pid = entry.pid + self.query_one("#btn-sudo-kill").styles.display = "block" + self.query_one("#btn-sudo-kill", Button).focus() + s = self.query_one("#runner-status", Static) + s.update(f"Stop failed (permission denied) — try Force Kill for PID {entry.pid}") else: - self._populate_blueprints() - self._show_list_mode() - self.query_one("#runner-filter", Input).value = "" - self.query_one("#runner-filter", Input).focus() + self._show_stopped("Stopped") self.app.call_from_thread(_after_stop) @@ -564,20 +480,172 @@ def _after_stop() -> None: def _restart_running(self) -> None: entry = self._running_entry - name = getattr(entry, "blueprint", None) or self._launched_name - if name: - self._stop_running(then_launch=name) + name = getattr(entry, "blueprint", None) + if not name: + return + self._stop_log = True + log_widget = self.query_one("#runner-log", RichLog) + status = self.query_one("#runner-status", Static) + status.update(f"Restarting {name}...") + for bid in ("btn-stop", "btn-restart"): + try: + self.query_one(f"#{bid}", Button).disabled = True + except Exception: + pass + + old_entry = self._running_entry + self._running_entry = None + + def _do_restart() -> None: + if old_entry: + try: + from dimos.core.run_registry import stop_entry + stop_entry(old_entry) + except Exception: + pass + + config_args: list[str] = [] + try: + from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp + for inst in self.app._instances: # type: ignore[attr-defined] + if isinstance(inst, ConfigSubApp): + for k, v in inst.get_overrides().items(): + cli_key = k.replace("_", "-") + if isinstance(v, bool): + config_args.append(f"--{cli_key}" if v else f"--no-{cli_key}") + else: + config_args.extend([f"--{cli_key}", str(v)]) + break + except Exception: + pass + + cmd = [sys.executable, "-m", "dimos.robot.cli.dimos", *config_args, "run", "--daemon", name] + env = os.environ.copy() + env["FORCE_COLOR"] = "1" + env["PYTHONUNBUFFERED"] = "1" + env["TERM"] = env.get("TERM", "xterm-256color") + try: + log_file = _launch_log_path() + with open(log_file, "w") as f: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=f, + stderr=subprocess.STDOUT, + env=env, + start_new_session=True, + ) + proc.wait() + except Exception as e: + self.app.call_from_thread(log_widget.write, f"[red]Restart error: {e}[/red]") + + def _after() -> None: + for bid in ("btn-stop", "btn-restart"): + try: + self.query_one(f"#{bid}", Button).disabled = False + except Exception: + pass + self._check_running() + if self._running_entry: + self._show_running() + else: + self._show_stopped("Restart failed") + + self.app.call_from_thread(_after) + + threading.Thread(target=_do_restart, daemon=True).start() + + def _sudo_kill(self) -> None: + pid = self._failed_stop_pid + if pid is None: + return + log_widget = self.query_one("#runner-log", RichLog) + self.query_one("#btn-sudo-kill", Button).disabled = True + + def _do_kill() -> None: + try: + result = subprocess.run( + ["sudo", "-n", "kill", "-9", str(pid)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + self.app.call_from_thread( + log_widget.write, + f"[{theme.YELLOW}]Killed PID {pid} with sudo[/{theme.YELLOW}]", + ) + try: + from dimos.core.run_registry import get_most_recent + entry = get_most_recent() + if entry and entry.pid == pid: + entry.remove() + except Exception: + pass + + def _after() -> None: + self._failed_stop_pid = None + self._running_entry = None + self._show_stopped("Killed with sudo") + + self.app.call_from_thread(_after) + else: + from dimos.utils.prompt import sudo_prompt + got_sudo = sudo_prompt("sudo is required to force-kill the process") + if got_sudo: + result2 = subprocess.run( + ["sudo", "-n", "kill", "-9", str(pid)], + capture_output=True, + text=True, + ) + if result2.returncode == 0: + self.app.call_from_thread( + log_widget.write, + f"[{theme.YELLOW}]Killed PID {pid} with sudo[/{theme.YELLOW}]", + ) + try: + from dimos.core.run_registry import get_most_recent + entry = get_most_recent() + if entry and entry.pid == pid: + entry.remove() + except Exception: + pass + + def _after2() -> None: + self._failed_stop_pid = None + self._running_entry = None + self._show_stopped("Killed with sudo") + + self.app.call_from_thread(_after2) + return + + self.app.call_from_thread( + log_widget.write, + "[red]sudo kill failed — could not obtain sudo credentials[/red]", + ) + + def _reenable() -> None: + self.query_one("#btn-sudo-kill", Button).disabled = False + + self.app.call_from_thread(_reenable) + except Exception as e: + self.app.call_from_thread(log_widget.write, f"[red]sudo kill error: {e}[/red]") + + def _reenable2() -> None: + self.query_one("#btn-sudo-kill", Button).disabled = False + + self.app.call_from_thread(_reenable2) + + threading.Thread(target=_do_kill, daemon=True).start() def _open_log_in_editor(self) -> None: try: from dimos.core.log_viewer import resolve_log_path - # Try the running entry first, then fall back to most recent entry = self._running_entry if entry: path = resolve_log_path(entry.run_id) else: - path = resolve_log_path() # resolves most recent + path = resolve_log_path() # most recent if not path: log_widget = self.query_one("#runner-log", RichLog) @@ -593,9 +661,3 @@ def _open_log_in_editor(self) -> None: def on_unmount_subapp(self) -> None: self._stop_log = True - # Kill the launch subprocess if still running (not the daemon — just the launcher) - if self._child_proc is not None: - try: - self._child_proc.send_signal(signal.SIGTERM) - except Exception: - pass From 97206e439598d6830b20c5c6ddde32e2953fc828 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 10 Mar 2026 20:06:25 -0700 Subject: [PATCH 05/23] - --- dimos/utils/cli/dui/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/dimos/utils/cli/dui/app.py b/dimos/utils/cli/dui/app.py index 4ebda9be0b..273480e081 100644 --- a/dimos/utils/cli/dui/app.py +++ b/dimos/utils/cli/dui/app.py @@ -100,7 +100,6 @@ def compose(self) -> ComposeResult: yield Container(id=f"display-{p + 1}", classes="display-pane") if self._debug: yield RichLog(id="debug-log", markup=True, wrap=True, highlight=False) - yield Static("", id="hint-bar") # ------------------------------------------------------------------ # Lifecycle From 30e620e433194460afcc338f277e2e4f72fd15ca Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 10 Mar 2026 20:07:05 -0700 Subject: [PATCH 06/23] - --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4045db012e..f9015232e7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ **/*.ignore.* .vscode/ +MUJOCO_LOG.TXT # Ignore Python cache files __pycache__/ From 860252ed5717e0d3fe7a4a2bebaaba601553e552 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 08:53:01 -0700 Subject: [PATCH 07/23] color syntax logging --- dimos/utils/cli/dui/app.py | 75 ++++++-- dimos/utils/cli/dui/dui.tcss | 2 +- dimos/utils/cli/dui/sub_app.py | 33 +++- dimos/utils/cli/dui/sub_apps/config.py | 27 ++- dimos/utils/cli/dui/sub_apps/dtop.py | 34 +++- dimos/utils/cli/dui/sub_apps/launcher.py | 13 +- dimos/utils/cli/dui/sub_apps/lcmspy.py | 26 ++- dimos/utils/cli/dui/sub_apps/runner.py | 213 +++++++++++++++++++---- dimos/utils/logging_config.py | 4 +- 9 files changed, 359 insertions(+), 68 deletions(-) diff --git a/dimos/utils/cli/dui/app.py b/dimos/utils/cli/dui/app.py index 273480e081..7c12d6a03a 100644 --- a/dimos/utils/cli/dui/app.py +++ b/dimos/utils/cli/dui/app.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """DUI — DimOS Unified TUI.""" from __future__ import annotations @@ -6,18 +20,22 @@ import sys import threading import time +from typing import TYPE_CHECKING from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal -from textual.events import Click, Key, Resize -from textual.widget import Widget from textual.widgets import RichLog, Static -from dimos.utils.cli.dui.sub_app import SubApp from dimos.utils.cli.dui.sub_apps import get_sub_apps -_DUAL_WIDTH = 240 # >= this width: 2 panels +if TYPE_CHECKING: + from textual.events import Click, Key, Resize + from textual.widget import Widget + + from dimos.utils.cli.dui.sub_app import SubApp + +_DUAL_WIDTH = 240 # >= this width: 2 panels _TRIPLE_WIDTH = 320 # >= this width: 3 panels _MAX_PANELS = 3 _QUIT_WINDOW = 1.5 # seconds to press again to confirm quit @@ -36,7 +54,8 @@ class DUIApp(App[None]): Binding("ctrl+left", "focus_prev_panel", "Panel prev", priority=True), Binding("ctrl+right", "focus_next_panel", "Panel next", priority=True), Binding("escape", "quit_or_esc", "Quit", priority=True), - Binding("ctrl+c", "quit_or_esc", "Quit", priority=True), + Binding("ctrl+c", "copy_text", "Copy", priority=True), + Binding("ctrl+q", "quit_or_esc", "Quit", priority=True), ] def __init__(self, *, debug: bool = False) -> None: @@ -59,6 +78,7 @@ def __init__(self, *, debug: bool = False) -> None: self._debug_log_file: object | None = None if debug: from pathlib import Path + log_path = Path.home() / ".dimos" / "dio-debug.log" log_path.parent.mkdir(parents=True, exist_ok=True) f = open(log_path, "w") @@ -73,6 +93,7 @@ def _log(self, msg: str) -> None: if not self._debug: return import re + plain = re.sub(r"\[/?[^\]]*\]", "", msg) if self._debug_log_file is not None: try: @@ -165,9 +186,7 @@ def _sync_focused_panel(self) -> None: old = self._focused_panel self._focused_panel = panel self._sync_tabs() - self._log( - f"[dim]FOCUS-TRACK: panel {old}->{panel}[/dim]" - ) + self._log(f"[dim]FOCUS-TRACK: panel {old}->{panel}[/dim]") # ------------------------------------------------------------------ # Click-to-focus panel @@ -235,7 +254,9 @@ async def _place_instances(self) -> None: await inst.remove() await dest.mount(inst) self._instance_pane[i] = target_panel - self._log(f"[dim] moved {self._sub_app_classes[i].TITLE} -> panel{target_panel}[/dim]") + self._log( + f"[dim] moved {self._sub_app_classes[i].TITLE} -> panel{target_panel}[/dim]" + ) inst.styles.display = "block" else: inst.styles.display = "none" @@ -268,7 +289,8 @@ def _sync_hint(self) -> None: parts = ["Alt+Up/Down: switch tab"] if self._num_panels > 1: parts.append("Alt+Left/Right: switch panel") - parts.append("Esc: quit") + parts.append("Ctrl+C: copy") + parts.append("Ctrl+Q/Esc: quit") bar.update(" | ".join(parts)) # ------------------------------------------------------------------ @@ -277,13 +299,17 @@ def _sync_hint(self) -> None: async def action_tab_prev(self) -> None: self._sync_focused_panel() - self._log(f"[#ffcc00]ACTION[/#ffcc00] tab_prev panel={self._focused_panel} idx={self._panel_idx[:self._num_panels]}") + self._log( + f"[#ffcc00]ACTION[/#ffcc00] tab_prev panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" + ) self._clear_quit_pending() await self._move_tab(-1) async def action_tab_next(self) -> None: self._sync_focused_panel() - self._log(f"[#ffcc00]ACTION[/#ffcc00] tab_next panel={self._focused_panel} idx={self._panel_idx[:self._num_panels]}") + self._log( + f"[#ffcc00]ACTION[/#ffcc00] tab_next panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" + ) self._clear_quit_pending() await self._move_tab(1) @@ -301,8 +327,19 @@ def action_focus_next_panel(self) -> None: new = min(self._num_panels - 1, self._focused_panel + 1) self._focus_panel(new) + def action_copy_text(self) -> None: + """Copy selected text to clipboard, or quit if no selection.""" + selected = self.screen.get_selected_text() + if selected: + self.copy_to_clipboard(selected) + self.screen.clear_selection() + self._log("[#ffcc00]ACTION[/#ffcc00] copy_text (copied to clipboard)") + else: + self._log("[#ffcc00]ACTION[/#ffcc00] copy_text -> no selection, treating as quit") + self._handle_quit_press() + def action_quit_or_esc(self) -> None: - self._log(f"[#ffcc00]ACTION[/#ffcc00] quit_or_esc") + self._log("[#ffcc00]ACTION[/#ffcc00] quit_or_esc") self._handle_quit_press() # ------------------------------------------------------------------ @@ -354,7 +391,7 @@ async def _move_tab(self, delta: int) -> None: self._panel_idx[panel] = idx self._log( f" -> MOVE panel={panel} {self._sub_app_classes[old_idx].TITLE}->{self._sub_app_classes[idx].TITLE} " - f"idx={self._panel_idx[:self._num_panels]}" + f"idx={self._panel_idx[: self._num_panels]}" ) await self._place_instances() self._sync_tabs() @@ -362,7 +399,9 @@ async def _move_tab(self, delta: int) -> None: actual = self.focused actual_name = type(actual).__name__ if actual else "None" actual_id = getattr(actual, "id", None) or "" - self._log(f" -> after focus: {actual_name}#{actual_id} in panel={self._panel_for_widget(actual)}") + self._log( + f" -> after focus: {actual_name}#{actual_id} in panel={self._panel_for_widget(actual)}" + ) def _handle_quit_press(self) -> None: now = time.monotonic() @@ -371,7 +410,7 @@ def _handle_quit_press(self) -> None: return self._quit_pressed_at = now bar = self.query_one("#hint-bar", Static) - bar.update("Press Esc or Ctrl+C again to exit") + bar.update("Press Esc or Ctrl+Q again to exit") if self._quit_timer is not None: self._quit_timer.stop() # type: ignore[union-attr] self._quit_timer = self.set_timer(_QUIT_WINDOW, self._clear_quit_pending) @@ -409,6 +448,7 @@ def _push() -> None: def _on_result(value: bool) -> None: result.append(value) event.set() + self.push_screen(ConfirmScreen(message, default), callback=_on_result) self.call_from_thread(_push) @@ -439,6 +479,7 @@ def _push() -> None: def _on_result(value: bool) -> None: result.append(value) event.set() + self.push_screen(SudoScreen(message), callback=_on_result) self.call_from_thread(_push) @@ -463,7 +504,7 @@ def main() -> None: set_dio_sudo_hook(app._handle_sudo) _real_stdin = sys.stdin - sys.stdin = open(os.devnull) # noqa: SIM115 + sys.stdin = open(os.devnull) try: app.run() except KeyboardInterrupt: diff --git a/dimos/utils/cli/dui/dui.tcss b/dimos/utils/cli/dui/dui.tcss index f950927ad1..68ba50c06b 100644 --- a/dimos/utils/cli/dui/dui.tcss +++ b/dimos/utils/cli/dui/dui.tcss @@ -73,7 +73,7 @@ Screen { } #hint-bar { - dock: bottom; + dock: top; height: 1; width: 100%; background: #1a2020; diff --git a/dimos/utils/cli/dui/sub_app.py b/dimos/utils/cli/dui/sub_app.py index d0942e0a77..c638105ad4 100644 --- a/dimos/utils/cli/dui/sub_app.py +++ b/dimos/utils/cli/dui/sub_app.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """SubApp base class for DUI sub-applications.""" from __future__ import annotations @@ -15,6 +29,9 @@ class SubApp(Widget): - on_mount_subapp() is called exactly ONCE after the widget's children have been composed. Heavy / blocking work (LCM connections, etc.) should be dispatched via self.run_worker(). + - on_resume_subapp() is called on every subsequent remount + (e.g. when the widget is moved between display panels). + Use this to restart timers killed by remove(). - on_unmount_subapp() is called when the DUI app is shutting down, NOT on every tab switch. """ @@ -53,10 +70,17 @@ def get_focus_target(self) -> Widget | None: return None def on_mount(self) -> None: - """Textual lifecycle — fires after compose() children exist.""" + """Textual lifecycle — fires after compose() children exist. + + Fires on EVERY mount (including after remove+remount when moving + between display panels). First mount triggers on_mount_subapp(); + subsequent mounts trigger on_resume_subapp(). + """ if not self._subapp_initialized: self._subapp_initialized = True self.on_mount_subapp() + else: + self.on_resume_subapp() def on_mount_subapp(self) -> None: """Called exactly once after first mount. @@ -65,6 +89,13 @@ def on_mount_subapp(self) -> None: Heavy / blocking work should use ``self.run_worker()``. """ + def on_resume_subapp(self) -> None: + """Called on every remount after the first. + + Override to restart timers that were killed when the widget + was removed from the DOM (e.g. during panel rearrangement). + """ + def on_unmount_subapp(self) -> None: """Called when the DUI app tears down this sub-app. diff --git a/dimos/utils/cli/dui/sub_apps/config.py b/dimos/utils/cli/dui/sub_apps/config.py index 20961466ef..104cd055ed 100644 --- a/dimos/utils/cli/dui/sub_apps/config.py +++ b/dimos/utils/cli/dui/sub_apps/config.py @@ -1,14 +1,27 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Config sub-app — interactive GlobalConfig editor.""" from __future__ import annotations import json -import sys from pathlib import Path -from typing import Any +import sys +from typing import TYPE_CHECKING, Any from rich.text import Text -from textual.app import ComposeResult from textual.containers import Horizontal from textual.message import Message from textual.reactive import reactive @@ -18,6 +31,9 @@ from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_app import SubApp +if TYPE_CHECKING: + from textual.app import ComposeResult + _VIEWER_OPTIONS = ["rerun", "rerun-web", "rerun-connect", "foxglove", "none"] @@ -90,6 +106,7 @@ class ConfigInput(_FormNavigationMixin, Input): def action_nav(self, delta: int) -> None: self._navigate_field(delta) + _DEFAULTS: dict[str, object] = { "viewer": "rerun", "n_workers": 2, @@ -206,7 +223,9 @@ def compose(self) -> ComposeResult: yield ConfigInput(value=str(v.get("n_workers", 2)), id="cfg-n-workers", type="integer") yield Label("robot_ip", classes="field-label") - yield ConfigInput(value=str(v.get("robot_ip", "")), placeholder="e.g. 192.168.12.1", id="cfg-robot-ip") + yield ConfigInput( + value=str(v.get("robot_ip", "")), placeholder="e.g. 192.168.12.1", id="cfg-robot-ip" + ) dtop_val = bool(v.get("dtop", False)) with Horizontal(classes="switch-row"): diff --git a/dimos/utils/cli/dui/sub_apps/dtop.py b/dimos/utils/cli/dui/sub_apps/dtop.py index 3dffca4c5a..f46638f504 100644 --- a/dimos/utils/cli/dui/sub_apps/dtop.py +++ b/dimos/utils/cli/dui/sub_apps/dtop.py @@ -1,29 +1,45 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Dtop sub-app — embedded resource monitor.""" from __future__ import annotations +from collections import deque import threading import time -from collections import deque -from typing import Any +from typing import TYPE_CHECKING, Any from rich.console import Group, RenderableType from rich.panel import Panel from rich.rule import Rule from rich.text import Text -from textual.app import ComposeResult from textual.containers import VerticalScroll from textual.widgets import Static from dimos.utils.cli import theme from dimos.utils.cli.dtop import ( - ResourceSpyApp, _LABEL_COLOR, _SPARK_WIDTH, + ResourceSpyApp, _compute_ranges, ) from dimos.utils.cli.dui.sub_app import SubApp +if TYPE_CHECKING: + from textual.app import ComposeResult + class DtopSubApp(SubApp): TITLE = "dtop" @@ -81,6 +97,12 @@ def get_focus_target(self) -> object | None: def on_mount_subapp(self) -> None: self.run_worker(self._init_lcm, exclusive=True, thread=True) + self._start_refresh_timer() + + def on_resume_subapp(self) -> None: + self._start_refresh_timer() + + def _start_refresh_timer(self) -> None: self.set_interval(0.5, self._refresh) def _init_lcm(self) -> None: @@ -155,9 +177,7 @@ def _refresh(self) -> None: title.append(f" [{pid}]", style=dim if stale else "#777777") title.append(" ") parts.append(Rule(title=title, style=border_style)) - parts.extend( - ResourceSpyApp._make_lines(d, stale, ranges, self._cpu_history[role]) - ) + parts.extend(ResourceSpyApp._make_lines(d, stale, ranges, self._cpu_history[role])) first_role, first_rs, _, first_mods, first_pid = entries[0] panel_title = Text(" ") diff --git a/dimos/utils/cli/dui/sub_apps/launcher.py b/dimos/utils/cli/dui/sub_apps/launcher.py index e913791064..355387582b 100644 --- a/dimos/utils/cli/dui/sub_apps/launcher.py +++ b/dimos/utils/cli/dui/sub_apps/launcher.py @@ -17,13 +17,13 @@ from __future__ import annotations import os +from pathlib import Path import subprocess import sys import threading -from pathlib import Path from typing import TYPE_CHECKING, Any -from textual.widgets import Button, Input, Label, ListItem, ListView, Static +from textual.widgets import Input, Label, ListItem, ListView, Static from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_app import SubApp @@ -117,6 +117,13 @@ def compose(self) -> ComposeResult: def on_mount_subapp(self) -> None: self._populate_blueprints() self._sync_status() + self._start_poll_timer() + + def on_resume_subapp(self) -> None: + self._start_poll_timer() + self._sync_status() + + def _start_poll_timer(self) -> None: self.set_interval(2.0, self._sync_status) def get_focus_target(self) -> object | None: @@ -278,7 +285,7 @@ def _after() -> None: self._sync_status() self.app.call_from_thread(_after) - except Exception as e: + except Exception: def _err() -> None: self._launching = False diff --git a/dimos/utils/cli/dui/sub_apps/lcmspy.py b/dimos/utils/cli/dui/sub_apps/lcmspy.py index 9b189d22be..fb24fc823d 100644 --- a/dimos/utils/cli/dui/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dui/sub_apps/lcmspy.py @@ -1,16 +1,32 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """LCM Spy sub-app — embedded LCM traffic monitor.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from rich.text import Text -from textual.app import ComposeResult from textual.widgets import DataTable from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_app import SubApp +if TYPE_CHECKING: + from textual.app import ComposeResult + class LCMSpySubApp(SubApp): TITLE = "lcmspy" @@ -47,6 +63,12 @@ def compose(self) -> ComposeResult: def on_mount_subapp(self) -> None: self.run_worker(self._init_lcm, exclusive=True, thread=True) + self._start_refresh_timer() + + def on_resume_subapp(self) -> None: + self._start_refresh_timer() + + def _start_refresh_timer(self) -> None: self.set_interval(0.5, self._refresh_table) def _init_lcm(self) -> None: diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py index 4b76e01d3d..2789493454 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -17,11 +17,11 @@ from __future__ import annotations import os +from pathlib import Path import subprocess import sys import threading import time -from pathlib import Path from typing import TYPE_CHECKING, Any from rich.panel import Panel @@ -150,12 +150,20 @@ def __init__(self) -> None: self._failed_stop_pid: int | None = None self._following_launch_log = False self._launch_log_mtime: float = 0.0 + self._poll_count = 0 + + def _debug(self, msg: str) -> None: + """Log to the DUI debug panel if available.""" + try: + self.app._log(f"[#8899aa]STATUS:[/#8899aa] {msg}") # type: ignore[attr-defined] + except Exception: + pass def compose(self) -> ComposeResult: yield Static("Blueprint Status", classes="subapp-header") with VerticalScroll(id="idle-container"): yield Static(self._idle_panel(), id="idle-panel") - yield RichLog(id="runner-log", markup=True, wrap=True) + yield RichLog(id="runner-log", markup=True, wrap=True, auto_scroll=True) with Horizontal(id="run-controls"): yield Button("Stop", id="btn-stop", variant="error") yield Button("Force Kill (sudo)", id="btn-sudo-kill") @@ -172,15 +180,33 @@ def _idle_panel(self) -> Panel: return Panel(msg, border_style=theme.DIM, expand=False) def on_mount_subapp(self) -> None: + self._debug("on_mount_subapp called") self._check_running() - if self._running_entry is not None: + entry = self._running_entry + self._debug(f"initial check: entry={getattr(entry, 'run_id', None)}") + if entry is not None: + self._debug("-> _show_running") self._show_running() else: - # Check if there's a fresh launch log to tail self._check_launch_log() if not self._following_launch_log: + self._debug("-> _show_idle") self._show_idle() + else: + self._debug("-> following launch log") + self._start_poll_timer() + + def on_resume_subapp(self) -> None: + self._debug("on_resume_subapp: restarting timer after remount") + self._start_poll_timer() + # Re-sync UI state with current data + self._check_running() + if self._running_entry is not None: + self._show_running() + + def _start_poll_timer(self) -> None: self.set_interval(1.0, self._poll_running) + self._debug("timer started") def get_focus_target(self) -> object | None: if self._running_entry is not None: @@ -199,10 +225,12 @@ def _check_running(self) -> None: from dimos.core.run_registry import get_most_recent self._running_entry = get_most_recent(alive_only=True) - except Exception: + except Exception as e: + self._debug(f"_check_running exception: {e}") self._running_entry = None def _poll_running(self) -> None: + self._poll_count += 1 old_entry = self._running_entry self._check_running() new_entry = self._running_entry @@ -210,15 +238,23 @@ def _poll_running(self) -> None: old_id = getattr(old_entry, "run_id", None) new_id = getattr(new_entry, "run_id", None) - if old_id != new_id: + # Log every 10th poll or on state change + changed = old_id != new_id + if changed or self._poll_count % 10 == 1: + self._debug( + f"poll #{self._poll_count}: old={old_id} new={new_id} " + f"changed={changed} following_launch={self._following_launch_log}" + ) + + if changed: if new_entry is not None: - # A daemon appeared — switch from launch log to JSONL follow + self._debug(f"-> _show_running (new entry: {new_id})") self._stop_log = True self._following_launch_log = False self._show_running() return elif old_entry is not None: - # Process ended + self._debug(f"-> _show_stopped (entry gone: {old_id})") self._stop_log = True self._following_launch_log = False self._show_stopped("Process ended") @@ -235,17 +271,19 @@ def _check_launch_log(self) -> None: mtime = log_path.stat().st_mtime except FileNotFoundError: return - # Only pick up if it was modified recently (within 30s) and is newer than last seen + age = time.time() - mtime if mtime <= self._launch_log_mtime: return - if time.time() - mtime > 30: + if age > 30: return + self._debug(f"_check_launch_log: new launch.log detected (age={age:.1f}s)") self._launch_log_mtime = mtime self._following_launch_log = True self._show_launching(log_path) def _show_launching(self, log_path: Path) -> None: """Show the launch log output while the daemon is starting.""" + self._debug(f"_show_launching: {log_path}") self.query_one("#idle-container").styles.display = "none" self.query_one("#runner-log").styles.display = "block" self.query_one("#run-controls").styles.display = "none" @@ -269,26 +307,34 @@ def _tail() -> None: # Check if the launch process finished (file stopped growing) # and a daemon entry appeared — poll handles the transition except Exception as e: - self.app.call_from_thread(log_widget.write, f"[red]Error reading launch log: {e}[/red]") + self.app.call_from_thread( + log_widget.write, f"[red]Error reading launch log: {e}[/red]" + ) self._log_thread = threading.Thread(target=_tail, daemon=True) self._log_thread.start() def _show_running(self) -> None: """Show controls for a running blueprint.""" - self.query_one("#idle-container").styles.display = "none" - self.query_one("#runner-log").styles.display = "block" - self.query_one("#run-controls").styles.display = "block" - self.query_one("#btn-stop").styles.display = "block" - self.query_one("#btn-sudo-kill").styles.display = "none" - self.query_one("#btn-restart").styles.display = "block" - self.query_one("#btn-open-log").styles.display = "block" - self._failed_stop_pid = None - entry = self._running_entry - if entry: - status = self.query_one("#runner-status", Static) - status.update(f"Running: {entry.blueprint} (PID {entry.pid})") - self._start_log_follow(entry) + self._debug("_show_running: setting widget display states") + try: + self.query_one("#idle-container").styles.display = "none" + self.query_one("#runner-log").styles.display = "block" + self.query_one("#run-controls").styles.display = "block" + self.query_one("#btn-stop").styles.display = "block" + self.query_one("#btn-sudo-kill").styles.display = "none" + self.query_one("#btn-restart").styles.display = "block" + self.query_one("#btn-open-log").styles.display = "block" + self._failed_stop_pid = None + entry = self._running_entry + if entry: + status = self.query_one("#runner-status", Static) + status.update(self._format_status_line(entry)) + self._debug(f"_show_running: starting log follow for {entry.run_id}") + self._start_log_follow(entry) + self._debug("_show_running: done") + except Exception as e: + self._debug(f"_show_running CRASHED: {e}") def _show_stopped(self, message: str = "Stopped") -> None: """Show controls for a stopped state with logs still visible.""" @@ -308,6 +354,7 @@ def _show_stopped(self, message: str = "Stopped") -> None: def _show_idle(self) -> None: """Show big idle message — no blueprint running.""" + self._debug("_show_idle called") self.query_one("#idle-container").styles.display = "block" self.query_one("#runner-log").styles.display = "none" self.query_one("#run-controls").styles.display = "none" @@ -332,20 +379,104 @@ def _show_idle(self) -> None: status = self.query_one("#runner-status", Static) status.update("No blueprint running") + # ------------------------------------------------------------------ + # Entry info formatting + # ------------------------------------------------------------------ + + @staticmethod + def _format_status_line(entry: Any) -> str: + """One-line status bar summary including config overrides.""" + overrides = getattr(entry, "config_overrides", None) or {} + parts = [f"Running: {entry.blueprint} (PID {entry.pid})"] + if overrides: + flags = " ".join( + f"--{k.replace('_', '-')}" + if isinstance(v, bool) and v + else f"--no-{k.replace('_', '-')}" + if isinstance(v, bool) + else f"--{k.replace('_', '-')}={v}" + for k, v in overrides.items() + ) + parts.append(flags) + return " | ".join(parts) + + @staticmethod + def _format_launch_header(entry: Any) -> list[str]: + """Rich-markup lines summarising how the blueprint was launched.""" + lines: list[str] = [] + argv = getattr(entry, "original_argv", None) or [] + overrides = getattr(entry, "config_overrides", None) or {} + if argv: + lines.append(f"[dim]$ {' '.join(argv)}[/dim]") + if overrides: + items = " ".join(f"[{theme.CYAN}]{k}[/{theme.CYAN}]={v}" for k, v in overrides.items()) + lines.append(f"[dim]config overrides:[/dim] {items}") + lines.append("") # blank separator + return lines + # ------------------------------------------------------------------ # Log streaming # ------------------------------------------------------------------ + # Rich styles matching the structlog compact console color scheme + _LEVEL_STYLES: dict[str, str] = { + "dbg": "bold cyan", + "deb": "bold cyan", + "inf": "bold green", + "war": "bold yellow", + "err": "bold red", + "cri": "bold red", + } + + @staticmethod + def _format_jsonl_line(raw: str) -> Text: + """Parse a JSONL log line and return a colorized Rich Text object.""" + import json + from pathlib import Path as P + + _STANDARD_KEYS = {"timestamp", "level", "logger", "event", "func_name", "lineno"} + + try: + rec: dict[str, object] = json.loads(raw) + except (json.JSONDecodeError, ValueError): + return Text(raw.rstrip()) + + ts = str(rec.get("timestamp", "")) + hms = ts[11:19] if len(ts) >= 19 else ts + level = str(rec.get("level", "?"))[:3].lower() + logger = P(str(rec.get("logger", "?"))).name + event = str(rec.get("event", "")) + + line = Text() + line.append(hms, style="dim") + lvl_style = StatusSubApp._LEVEL_STYLES.get(level, "") + line.append(f"[{level}]", style=lvl_style) + line.append(f"[{logger:17}] ", style="dim") + line.append(event, style="blue") + + extras = {k: v for k, v in rec.items() if k not in _STANDARD_KEYS} + if extras: + line.append(" ") + for k, v in sorted(extras.items()): + line.append(f"{k}", style="cyan") + line.append("=", style="white") + line.append(f"{v}", style="magenta") + line.append(" ") + + return line + def _start_log_follow(self, entry: Any) -> None: self._stop_log = False log_widget = self.query_one("#runner-log", RichLog) log_widget.clear() + # Print launch info header + for line in self._format_launch_header(entry): + log_widget.write(line) def _follow() -> None: try: from dimos.core.log_viewer import ( follow_log, - format_line, read_log, resolve_log_path, ) @@ -358,12 +489,12 @@ def _follow() -> None: for line in read_log(path, 50): if self._stop_log: return - formatted = format_line(line) - self.app.call_from_thread(log_widget.write, formatted) + rendered = self._format_jsonl_line(line) + self.app.call_from_thread(log_widget.write, rendered) for line in follow_log(path, stop=lambda: self._stop_log): - formatted = format_line(line) - self.app.call_from_thread(log_widget.write, formatted) + rendered = self._format_jsonl_line(line) + self.app.call_from_thread(log_widget.write, rendered) except Exception as e: self.app.call_from_thread(log_widget.write, f"[red]Error: {e}[/red]") @@ -455,7 +586,10 @@ def _do_stop() -> None: f"[red]Permission denied — cannot stop PID {entry.pid}[/red]", ) except Exception as e: - if "permission" in str(e).lower() or "operation not permitted" in str(e).lower(): + if ( + "permission" in str(e).lower() + or "operation not permitted" in str(e).lower() + ): permission_error = True self.app.call_from_thread(log_widget.write, f"[red]Stop error: {e}[/red]") @@ -470,7 +604,9 @@ def _after_stop() -> None: self.query_one("#btn-sudo-kill").styles.display = "block" self.query_one("#btn-sudo-kill", Button).focus() s = self.query_one("#runner-status", Static) - s.update(f"Stop failed (permission denied) — try Force Kill for PID {entry.pid}") + s.update( + f"Stop failed (permission denied) — try Force Kill for PID {entry.pid}" + ) else: self._show_stopped("Stopped") @@ -500,6 +636,7 @@ def _do_restart() -> None: if old_entry: try: from dimos.core.run_registry import stop_entry + stop_entry(old_entry) except Exception: pass @@ -507,6 +644,7 @@ def _do_restart() -> None: config_args: list[str] = [] try: from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp + for inst in self.app._instances: # type: ignore[attr-defined] if isinstance(inst, ConfigSubApp): for k, v in inst.get_overrides().items(): @@ -519,7 +657,15 @@ def _do_restart() -> None: except Exception: pass - cmd = [sys.executable, "-m", "dimos.robot.cli.dimos", *config_args, "run", "--daemon", name] + cmd = [ + sys.executable, + "-m", + "dimos.robot.cli.dimos", + *config_args, + "run", + "--daemon", + name, + ] env = os.environ.copy() env["FORCE_COLOR"] = "1" env["PYTHONUNBUFFERED"] = "1" @@ -576,6 +722,7 @@ def _do_kill() -> None: ) try: from dimos.core.run_registry import get_most_recent + entry = get_most_recent() if entry and entry.pid == pid: entry.remove() @@ -590,6 +737,7 @@ def _after() -> None: self.app.call_from_thread(_after) else: from dimos.utils.prompt import sudo_prompt + got_sudo = sudo_prompt("sudo is required to force-kill the process") if got_sudo: result2 = subprocess.run( @@ -604,6 +752,7 @@ def _after() -> None: ) try: from dimos.core.run_registry import get_most_recent + entry = get_most_recent() if entry and entry.pid == pid: entry.remove() diff --git a/dimos/utils/logging_config.py b/dimos/utils/logging_config.py index bf7632fa60..dea16a1f7c 100644 --- a/dimos/utils/logging_config.py +++ b/dimos/utils/logging_config.py @@ -152,7 +152,9 @@ def _configure_structlog() -> Path: _CONSOLE_PATH_WIDTH = 30 -_CONSOLE_USE_COLORS = hasattr(sys.stdout, "isatty") and sys.stdout.isatty() +_CONSOLE_USE_COLORS = os.environ.get("FORCE_COLOR", "") == "1" or ( + hasattr(sys.stdout, "isatty") and sys.stdout.isatty() +) _CONSOLE_LEVEL_COLORS = { "dbg": "\033[1;36m", # bold cyan From bf95013d74ed2725b2e07a04b0b5593292c9855e Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 09:58:18 -0700 Subject: [PATCH 08/23] human CLI works great --- dimos/utils/cli/dui/app.py | 13 - dimos/utils/cli/dui/sub_apps/dtop.py | 99 ++++++-- dimos/utils/cli/dui/sub_apps/humancli.py | 301 +++++++++++++++++++++-- dimos/utils/cli/dui/sub_apps/runner.py | 167 +++++++++++-- 4 files changed, 514 insertions(+), 66 deletions(-) diff --git a/dimos/utils/cli/dui/app.py b/dimos/utils/cli/dui/app.py index 7c12d6a03a..c9850e6087 100644 --- a/dimos/utils/cli/dui/app.py +++ b/dimos/utils/cli/dui/app.py @@ -179,15 +179,6 @@ def _panel_for_widget(self, widget: Widget | None) -> int | None: node = node.parent return None - def _sync_focused_panel(self) -> None: - """Update _focused_panel to match where the actually-focused widget lives.""" - panel = self._panel_for_widget(self.focused) - if panel is not None and panel != self._focused_panel: - old = self._focused_panel - self._focused_panel = panel - self._sync_tabs() - self._log(f"[dim]FOCUS-TRACK: panel {old}->{panel}[/dim]") - # ------------------------------------------------------------------ # Click-to-focus panel # ------------------------------------------------------------------ @@ -298,7 +289,6 @@ def _sync_hint(self) -> None: # ------------------------------------------------------------------ async def action_tab_prev(self) -> None: - self._sync_focused_panel() self._log( f"[#ffcc00]ACTION[/#ffcc00] tab_prev panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" ) @@ -306,7 +296,6 @@ async def action_tab_prev(self) -> None: await self._move_tab(-1) async def action_tab_next(self) -> None: - self._sync_focused_panel() self._log( f"[#ffcc00]ACTION[/#ffcc00] tab_next panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" ) @@ -314,14 +303,12 @@ async def action_tab_next(self) -> None: await self._move_tab(1) def action_focus_prev_panel(self) -> None: - self._sync_focused_panel() self._log(f"[#ffcc00]ACTION[/#ffcc00] focus_prev_panel (was panel={self._focused_panel})") self._clear_quit_pending() new = max(0, self._focused_panel - 1) self._focus_panel(new) def action_focus_next_panel(self) -> None: - self._sync_focused_panel() self._log(f"[#ffcc00]ACTION[/#ffcc00] focus_next_panel (was panel={self._focused_panel})") self._clear_quit_pending() new = min(self._num_panels - 1, self._focused_panel + 1) diff --git a/dimos/utils/cli/dui/sub_apps/dtop.py b/dimos/utils/cli/dui/sub_apps/dtop.py index f46638f504..3b89bc63ef 100644 --- a/dimos/utils/cli/dui/sub_apps/dtop.py +++ b/dimos/utils/cli/dui/sub_apps/dtop.py @@ -72,17 +72,29 @@ def __init__(self) -> None: self._latest: dict[str, Any] | None = None self._last_msg_time: float = 0.0 self._cpu_history: dict[str, deque[float]] = {} + self._reconnecting = False + + def _debug(self, msg: str) -> None: + try: + self.app._log(f"[#8899aa]DTOP:[/#8899aa] {msg}") # type: ignore[attr-defined] + except Exception: + pass + + # How long without a message before we consider the connection stale + _STALE_TIMEOUT = 5.0 + # How long without a message before we attempt to reconnect LCM + _RECONNECT_TIMEOUT = 15.0 def _waiting_panel(self) -> Panel: - return Panel( - Text( - "Waiting for resource stats...\nuse `dimos --dtop ...` to emit stats", - style=theme.FOREGROUND, - justify="center", - ), - border_style=theme.CYAN, - expand=False, - ) + msg = Text(justify="center") + msg.append("Waiting for resource stats...\n\n", style=theme.FOREGROUND) + msg.append("Blueprint must be launched with ", style=theme.DIM) + msg.append("--dtop", style=f"bold {theme.CYAN}") + msg.append(" to emit stats.\n", style=theme.DIM) + msg.append("Enable it in the ", style=theme.DIM) + msg.append("config", style=f"bold {theme.CYAN}") + msg.append(" tab before launching.", style=theme.DIM) + return Panel(msg, border_style=theme.CYAN, expand=False) def compose(self) -> ComposeResult: with VerticalScroll(id="dtop-scroll", classes="waiting"): @@ -101,20 +113,53 @@ def on_mount_subapp(self) -> None: def on_resume_subapp(self) -> None: self._start_refresh_timer() + # Reinitialize LCM if it was lost (e.g. after unmount/remount) + if self._lcm is None: + self.run_worker(self._init_lcm, exclusive=True, thread=True) def _start_refresh_timer(self) -> None: self.set_interval(0.5, self._refresh) def _init_lcm(self) -> None: """Blocking LCM init — runs in a worker thread.""" + self._debug("_init_lcm: starting...") + + # Stop any existing LCM instance first + if self._lcm is not None: + self._debug("_init_lcm: stopping existing LCM instance") + try: + self._lcm.stop() + except Exception as e: + self._debug(f"_init_lcm: stop failed: {e}") + self._lcm = None + try: from dimos.protocol.pubsub.impl.lcmpubsub import PickleLCM, Topic - self._lcm = PickleLCM(autoconf=True) - self._lcm.subscribe(Topic("/dimos/resource_stats"), self._on_msg) - self._lcm.start() - except Exception: - pass + self._debug("_init_lcm: creating PickleLCM...") + plcm = PickleLCM() + self._debug( + f"_init_lcm: PickleLCM created, l={plcm.l is not None}, " + f"url={plcm.config.url}" + ) + + self._debug("_init_lcm: subscribing to /dimos/resource_stats...") + plcm.subscribe(Topic("/dimos/resource_stats"), self._on_msg) + + self._debug("_init_lcm: calling start()...") + plcm.start() + self._debug( + f"_init_lcm: started, thread={plcm._thread is not None}, " + f"thread.alive={plcm._thread.is_alive() if plcm._thread else 'N/A'}" + ) + + self._lcm = plcm + self._debug("_init_lcm: DONE — listening for messages") + except Exception as e: + import traceback + + self._debug(f"_init_lcm FAILED: {e}\n{traceback.format_exc()}") + self._lcm = None def on_unmount_subapp(self) -> None: if self._lcm: @@ -124,10 +169,25 @@ def on_unmount_subapp(self) -> None: pass self._lcm = None + def _reconnect_lcm(self) -> None: + """Tear down and re-create the LCM subscription.""" + try: + self._init_lcm() + self._debug("LCM reconnected") + except Exception as e: + self._debug(f"reconnect failed: {e}") + finally: + self._reconnecting = False + def _on_msg(self, msg: dict[str, Any], _topic: str) -> None: + first = False with self._lock: + if self._latest is None: + first = True self._latest = msg self._last_msg_time = time.monotonic() + if first: + self._debug(f"_on_msg: FIRST message received! keys={list(msg.keys())}") def _refresh(self) -> None: with self._lock: @@ -138,13 +198,22 @@ def _refresh(self) -> None: scroll = self.query_one(VerticalScroll) except Exception: return + + now = time.monotonic() + if data is None: scroll.add_class("waiting") self.query_one("#dtop-panels", Static).update(self._waiting_panel()) return scroll.remove_class("waiting") - stale = (time.monotonic() - last_msg) > 2.0 + stale = (now - last_msg) > self._STALE_TIMEOUT + + # Auto-reconnect if we haven't received data in a while + if (now - last_msg) > self._RECONNECT_TIMEOUT and not self._reconnecting: + self._reconnecting = True + self._debug(f"No data for {now - last_msg:.0f}s, reconnecting LCM...") + self.run_worker(self._reconnect_lcm, exclusive=True, thread=True) dim = "#606060" border_style = dim if stale else "#777777" diff --git a/dimos/utils/cli/dui/sub_apps/humancli.py b/dimos/utils/cli/dui/sub_apps/humancli.py index 294c8bf8ce..7c55d77107 100644 --- a/dimos/utils/cli/dui/sub_apps/humancli.py +++ b/dimos/utils/cli/dui/sub_apps/humancli.py @@ -1,20 +1,124 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """HumanCLI sub-app — embedded agent chat interface.""" from __future__ import annotations +from collections import deque +from datetime import datetime +from enum import Enum, auto import json import textwrap import threading -from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any -from textual.app import ComposeResult from textual.containers import Container -from textual.widgets import Input, RichLog +from textual.geometry import Size +from textual.widgets import Input, RichLog, Static from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_app import SubApp +if TYPE_CHECKING: + from textual.app import ComposeResult + + +class _ConnState(Enum): + DISCONNECTED = auto() + CONNECTING = auto() # LCM transports initializing + WAITING_FOR_AGENT = auto() # transports ready, no agent response yet + CONNECTED = auto() # agent confirmed present + NO_AGENT = auto() # timed out waiting for agent + ERROR = auto() + + +_STATUS_STYLE: dict[_ConnState, tuple[str, str]] = { + _ConnState.DISCONNECTED: ("disconnected", theme.DIM), + _ConnState.CONNECTING: ("connecting…", theme.YELLOW), + _ConnState.WAITING_FOR_AGENT: ("waiting for agent…", theme.YELLOW), + _ConnState.CONNECTED: ("connected", theme.GREEN), + _ConnState.NO_AGENT: ("no agent — blueprint may not include one", theme.DIM), + _ConnState.ERROR: ("error", theme.RED), +} + +# Seconds to wait for an agent response before showing "no agent" +_AGENT_DETECT_TIMEOUT = 8.0 + + +class _ThinkingIndicator: + """Animated 'thinking…' line inside a RichLog.""" + + def __init__(self, app: Any, chat_log: RichLog, add_message_fn: Any) -> None: + self._app = app + self._log = chat_log + self._add = add_message_fn + self._timer: Any = None + self._strips: list[Any] = [] + self.visible = False + self._dim = False + + def show(self) -> None: + if self.visible: + return + self.visible = True + self._dim = False + self._write() + self._timer = self._app.set_interval(0.6, self._toggle) + + def hide(self) -> None: + if not self.visible: + return + self.visible = False + if self._timer is not None: + self._timer.stop() + self._timer = None + self._remove() + + def detach_if_needed(self) -> bool: + if self.visible and self._strips: + self._remove() + return True + return False + + def reattach(self) -> None: + self._write() + + def _write(self) -> None: + before = len(self._log.lines) + color = theme.DIM if self._dim else theme.ACCENT + ts = datetime.now().strftime("%H:%M:%S") + self._add(ts, "", "[italic]thinking…[/italic]", color) + self._strips = list(self._log.lines[before:]) + + def _remove(self) -> None: + if not self._strips: + return + ids = {id(s) for s in self._strips} + self._log.lines = [l for l in self._log.lines if id(l) not in ids] + self._strips = [] + self._log._line_cache.clear() + self._log.virtual_size = Size(self._log.virtual_size.width, len(self._log.lines)) + self._log.refresh() + + def _toggle(self) -> None: + if not self.visible: + return + self._remove() + self._dim = not self._dim + self._write() + class HumanCLISubApp(SubApp): TITLE = "chat" @@ -24,6 +128,13 @@ class HumanCLISubApp(SubApp): layout: vertical; background: {theme.BACKGROUND}; }} + HumanCLISubApp #hcli-status-bar {{ + height: 1; + dock: top; + padding: 0 1; + background: {theme.BG}; + color: {theme.DIM}; + }} HumanCLISubApp #hcli-chat {{ height: 1fr; }} @@ -41,34 +152,119 @@ def __init__(self) -> None: super().__init__() self._human_transport: Any = None self._agent_transport: Any = None + self._idle_transport: Any = None self._running = False + self._conn_state = _ConnState.DISCONNECTED + self._conn_error: str = "" + self._send_queue: deque[str] = deque() + self._queue_lock = threading.Lock() + self._thinking: _ThinkingIndicator | None = None + self._agent_seen = False + self._agent_timeout_timer: Any = None def compose(self) -> ComposeResult: + yield Static("", id="hcli-status-bar") with Container(id="hcli-chat"): yield RichLog(id="hcli-log", highlight=True, markup=True, wrap=False) - yield Input(placeholder="Type a message...", id="hcli-input") + yield Input(placeholder="Type a message…", id="hcli-input") + + def get_focus_target(self) -> Any: + return self.query_one("#hcli-input", Input) def on_mount_subapp(self) -> None: self._running = True - self.run_worker(self._init_transports, exclusive=True, thread=True) log = self.query_one("#hcli-log", RichLog) - self._add_system_message(log, "Connected to DimOS Agent Interface") + self._thinking = _ThinkingIndicator(self.app, log, self._add_message_raw) + self._set_conn_state(_ConnState.CONNECTING) + self.run_worker(self._init_transports, exclusive=True, thread=True) + + def on_unmount_subapp(self) -> None: + self._running = False + if self._agent_timeout_timer is not None: + self._agent_timeout_timer.stop() + self._agent_timeout_timer = None + if self._thinking: + self._thinking.hide() + + # ── connection state ────────────────────────────────────────── + + def _set_conn_state(self, state: _ConnState, error: str = "") -> None: + self._conn_state = state + self._conn_error = error + label, color = _STATUS_STYLE[state] + if state == _ConnState.ERROR and error: + label = f"error: {error}" + try: + bar = self.query_one("#hcli-status-bar", Static) + bar.update(f"[{color}]● {label}[/{color}]") + except Exception: + pass + + # ── transport init (worker thread) ──────────────────────────── def _init_transports(self) -> None: - """Blocking transport init — runs in a worker thread.""" try: from dimos.core.transport import pLCMTransport self._human_transport = pLCMTransport("/human_input") self._agent_transport = pLCMTransport("/agent") - except Exception: + self._idle_transport = pLCMTransport("/agent_idle") + except Exception as exc: + self.app.call_from_thread(self._set_conn_state, _ConnState.ERROR, str(exc)) return - if self._agent_transport: - self._subscribe_to_agent() + self._subscribe_to_agent() + self._subscribe_to_idle() + self.app.call_from_thread(self._on_transport_ready) - def on_unmount_subapp(self) -> None: - self._running = False + def _on_transport_ready(self) -> None: + self._set_conn_state(_ConnState.WAITING_FOR_AGENT) + # Messages can be sent over LCM even while waiting — flush the queue + self._flush_queue() + # Start a timer: if no agent responds within the timeout, show "no agent" + self._agent_timeout_timer = self.set_timer( + _AGENT_DETECT_TIMEOUT, self._on_agent_detect_timeout + ) + + def _on_agent_detected(self) -> None: + """Called (on main thread) the first time we hear from an agent.""" + if self._agent_seen: + return + self._agent_seen = True + if self._agent_timeout_timer is not None: + self._agent_timeout_timer.stop() + self._agent_timeout_timer = None + self._set_conn_state(_ConnState.CONNECTED) + log = self.query_one("#hcli-log", RichLog) + self._add_system_message(log, "Agent connected") + + def _on_agent_detect_timeout(self) -> None: + """Fired if no agent message arrived within the timeout window.""" + self._agent_timeout_timer = None + if not self._agent_seen: + self._set_conn_state(_ConnState.NO_AGENT) + log = self.query_one("#hcli-log", RichLog) + self._add_system_message(log, "No agent detected — this blueprint may not include one") + + # ── message queue ───────────────────────────────────────────── + + def _enqueue(self, message: str) -> None: + with self._queue_lock: + self._send_queue.append(message) + + def _flush_queue(self) -> None: + with self._queue_lock: + queued = list(self._send_queue) + self._send_queue.clear() + for msg in queued: + self._do_send(msg) + + def _do_send(self, message: str) -> None: + """Actually publish a message. Expects to be called on main thread.""" + if self._human_transport: + self._human_transport.publish(message) + + # ── subscriptions ───────────────────────────────────────────── def _subscribe_to_agent(self) -> None: from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage @@ -76,6 +272,9 @@ def _subscribe_to_agent(self) -> None: def receive_msg(msg: Any) -> None: if not self._running: return + # Any message from the agent transport proves an agent exists + if not self._agent_seen: + self.app.call_from_thread(self._on_agent_detected) try: log = self.query_one("#hcli-log", RichLog) except Exception: @@ -84,7 +283,12 @@ def receive_msg(msg: Any) -> None: if isinstance(msg, SystemMessage): self.app.call_from_thread( - self._add_message, log, timestamp, "system", str(msg.content)[:1000], theme.YELLOW + self._add_message, + log, + timestamp, + "system", + str(msg.content)[:1000], + theme.YELLOW, ) elif isinstance(msg, AIMessage): content = msg.content or "" @@ -92,6 +296,17 @@ def receive_msg(msg: Any) -> None: "tool_calls", [] ) if content: + # Check for common API key errors + content_lower = str(content).lower() + if any( + phrase in content_lower + for phrase in ["api key", "authentication", "unauthorized", "invalid key"] + ): + self.app.call_from_thread( + self._set_conn_state, + _ConnState.ERROR, + "API key issue — check your config", + ) self.app.call_from_thread( self._add_message, log, timestamp, "agent", content, theme.AGENT ) @@ -108,17 +323,45 @@ def receive_msg(msg: Any) -> None: self._add_message, log, timestamp, "tool", str(msg.content), theme.TOOL_RESULT ) elif isinstance(msg, HumanMessage): - self.app.call_from_thread( - self._add_message, log, timestamp, "human", str(msg.content), theme.HUMAN - ) + pass # We already display user messages locally self._agent_transport.subscribe(receive_msg) + def _subscribe_to_idle(self) -> None: + def receive_idle(is_idle: bool) -> None: + if not self._running: + return + # Any idle signal proves an agent exists + if not self._agent_seen: + self.app.call_from_thread(self._on_agent_detected) + if self._thinking: + self.app.call_from_thread(self._thinking.hide if is_idle else self._thinking.show) + + self._idle_transport.subscribe(receive_idle) + + # ── display helpers ─────────────────────────────────────────── + def _add_message( self, log: RichLog, timestamp: str, sender: str, content: str, color: str ) -> None: + if self._thinking: + reattach = self._thinking.detach_if_needed() + else: + reattach = False + self._add_message_raw(timestamp, sender, content, color) + if reattach and self._thinking: + self._thinking.reattach() + + def _add_message_raw(self, timestamp: str, sender: str, content: str, color: str) -> None: + """Write a formatted message line to the log (no thinking-indicator management).""" + try: + log = self.query_one("#hcli-log", RichLog) + except Exception: + return content = content.strip() if content else "" - prefix = f" [{theme.TIMESTAMP}]{timestamp}[/{theme.TIMESTAMP}] [{color}]{sender:>8}[/{color}] │ " + prefix = ( + f" [{theme.TIMESTAMP}]{timestamp}[/{theme.TIMESTAMP}] [{color}]{sender:>8}[/{color}] │ " + ) indent = " " * 19 + "│ " width = max(log.size.width - 24, 40) if log.size else 60 @@ -136,6 +379,14 @@ def _add_system_message(self, log: RichLog, content: str) -> None: timestamp = datetime.now().strftime("%H:%M:%S") self._add_message(log, timestamp, "system", content, theme.YELLOW) + def _add_user_message(self, log: RichLog, content: str, queued: bool = False) -> None: + """Show user's own message immediately.""" + timestamp = datetime.now().strftime("%H:%M:%S") + suffix = f" [{theme.DIM}](queued)[/{theme.DIM}]" if queued else "" + self._add_message(log, timestamp, "you", content + suffix, theme.HUMAN) + + # ── input handling ──────────────────────────────────────────── + def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id != "hcli-input": return @@ -150,5 +401,17 @@ def on_input_submitted(self, event: Input.Submitted) -> None: self.query_one("#hcli-log", RichLog).clear() return - if self._human_transport: + log = self.query_one("#hcli-log", RichLog) + transport_ready = self._conn_state in ( + _ConnState.WAITING_FOR_AGENT, + _ConnState.CONNECTED, + _ConnState.NO_AGENT, + ) + + if transport_ready and self._human_transport: + self._add_user_message(log, message) self._human_transport.publish(message) + else: + # Transport not ready yet — queue the message + self._add_user_message(log, message, queued=True) + self._enqueue(message) diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py index 2789493454..41e2ffb282 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -151,6 +151,9 @@ def __init__(self) -> None: self._following_launch_log = False self._launch_log_mtime: float = 0.0 self._poll_count = 0 + self._last_click_time: float = 0.0 + self._last_click_y: int = -1 + self._saved_status: str = "" def _debug(self, msg: str) -> None: """Log to the DUI debug panel if available.""" @@ -288,7 +291,7 @@ def _show_launching(self, log_path: Path) -> None: self.query_one("#runner-log").styles.display = "block" self.query_one("#run-controls").styles.display = "none" status = self.query_one("#runner-status", Static) - status.update("Launching blueprint...") + status.update("Launching blueprint... — double-click log to open") self._stop_log = False log_widget = self.query_one("#runner-log", RichLog) @@ -301,14 +304,12 @@ def _tail() -> None: line = f.readline() if line: rendered = Text.from_ansi(line.rstrip("\n")) - self.app.call_from_thread(log_widget.write, rendered) + self.app.call_from_thread(self._write_log_line, log_widget, rendered) else: time.sleep(0.2) - # Check if the launch process finished (file stopped growing) - # and a daemon entry appeared — poll handles the transition except Exception as e: self.app.call_from_thread( - log_widget.write, f"[red]Error reading launch log: {e}[/red]" + self._write_log_line, log_widget, f"[red]Error reading launch log: {e}[/red]" ) self._log_thread = threading.Thread(target=_tail, daemon=True) @@ -387,7 +388,7 @@ def _show_idle(self) -> None: def _format_status_line(entry: Any) -> str: """One-line status bar summary including config overrides.""" overrides = getattr(entry, "config_overrides", None) or {} - parts = [f"Running: {entry.blueprint} (PID {entry.pid})"] + parts = [f"Running: {entry.blueprint} (PID {entry.pid}) — double-click log to open"] if overrides: flags = " ".join( f"--{k.replace('_', '-')}" @@ -444,14 +445,14 @@ def _format_jsonl_line(raw: str) -> Text: ts = str(rec.get("timestamp", "")) hms = ts[11:19] if len(ts) >= 19 else ts level = str(rec.get("level", "?"))[:3].lower() - logger = P(str(rec.get("logger", "?"))).name + logger_name = P(str(rec.get("logger", "?"))).name event = str(rec.get("event", "")) line = Text() line.append(hms, style="dim") lvl_style = StatusSubApp._LEVEL_STYLES.get(level, "") line.append(f"[{level}]", style=lvl_style) - line.append(f"[{logger:17}] ", style="dim") + line.append(f"[{logger_name:17}] ", style="dim") line.append(event, style="blue") extras = {k: v for k, v in rec.items() if k not in _STANDARD_KEYS} @@ -465,13 +466,17 @@ def _format_jsonl_line(raw: str) -> Text: return line + def _write_log_line(self, log_widget: RichLog, rendered: Text | str) -> None: + """Write a line to the log widget.""" + log_widget.write(rendered) + def _start_log_follow(self, entry: Any) -> None: self._stop_log = False log_widget = self.query_one("#runner-log", RichLog) log_widget.clear() # Print launch info header for line in self._format_launch_header(entry): - log_widget.write(line) + self._write_log_line(log_widget, line) def _follow() -> None: try: @@ -483,20 +488,24 @@ def _follow() -> None: path = resolve_log_path(entry.run_id) if not path: - self.app.call_from_thread(log_widget.write, "[dim]No log file found[/dim]") + self.app.call_from_thread( + self._write_log_line, log_widget, "[dim]No log file found[/dim]" + ) return for line in read_log(path, 50): if self._stop_log: return rendered = self._format_jsonl_line(line) - self.app.call_from_thread(log_widget.write, rendered) + self.app.call_from_thread(self._write_log_line, log_widget, rendered) for line in follow_log(path, stop=lambda: self._stop_log): rendered = self._format_jsonl_line(line) - self.app.call_from_thread(log_widget.write, rendered) + self.app.call_from_thread(self._write_log_line, log_widget, rendered) except Exception as e: - self.app.call_from_thread(log_widget.write, f"[red]Error: {e}[/red]") + self.app.call_from_thread( + self._write_log_line, log_widget, f"[red]Error: {e}[/red]" + ) self._log_thread = threading.Thread(target=_follow, daemon=True) self._log_thread.start() @@ -505,6 +514,129 @@ def _follow() -> None: # Button handling # ------------------------------------------------------------------ + def _is_click_on_log(self, event: Any) -> bool: + """Return True if the click event is inside the runner-log RichLog.""" + try: + node = event.widget + while node is not None: + if getattr(node, "id", None) == "runner-log": + return True + node = node.parent + except Exception: + pass + return False + + def on_click(self, event: Any) -> None: + """Single click: show hint. Double click: open source file.""" + if not self._is_click_on_log(event): + return + + now = time.monotonic() + click_y = getattr(event, "screen_y", -1) + is_double = (now - self._last_click_time) < 0.4 and abs(click_y - self._last_click_y) <= 1 + self._last_click_time = now + self._last_click_y = click_y + + if is_double: + self._handle_double_click(event) + else: + # Save current status and show hint + status = self.query_one("#runner-status", Static) + current = status.renderable + if not isinstance(current, str) or "double-click" not in current: + self._saved_status = str(current) + status.update("double-click to open log file") + # Restore after 2 seconds + self.set_timer(2.0, self._restore_status) + + def _restore_status(self) -> None: + """Restore the status bar after the hint.""" + try: + status = self.query_one("#runner-status", Static) + current = str(status.renderable) + if "double-click" in current and self._saved_status: + status.update(self._saved_status) + except Exception: + pass + + def _handle_double_click(self, event: Any) -> None: + """Open launch.log in the user's editor.""" + log_path = _launch_log_path() + if log_path.exists(): + self._open_source_file(str(log_path), 0) + else: + self.app.notify("No launch log found", severity="warning") + + def _open_source_file(self, file_path: str, lineno: int) -> None: + """Open a source file in the user's preferred GUI editor. + + Only launches background (GUI) editors — never suspends the TUI. + Falls back to copying the path to clipboard + notification. + """ + import shutil + + # Resolve relative paths against the project root + full_path = Path(file_path) + if not full_path.is_absolute(): + for base in [Path.cwd(), Path(__file__).resolve().parents[5]]: + candidate = base / file_path + if candidate.exists(): + full_path = candidate + break + + loc = f"{full_path}:{lineno}" if lineno else str(full_path) + loc_short = f"{full_path.name}:{lineno}" if lineno else full_path.name + + if not full_path.exists(): + self.app.copy_to_clipboard(loc) + self.app.notify(f"File not found, copied path: {loc}", severity="warning") + return + + # GUI editors that can be launched as background processes + _GUI_EDITORS: list[tuple[str, list[str]]] = [] + + # Check $VISUAL and $EDITOR for GUI editors + for env_var in ("VISUAL", "EDITOR"): + cmd = os.environ.get(env_var, "") + if not cmd or not shutil.which(cmd): + continue + cmd_name = Path(cmd).name + if cmd_name in ("code", "code-insiders"): + _GUI_EDITORS.append((cmd, ["-g", loc])) + elif cmd_name in ("subl", "sublime", "subl3"): + _GUI_EDITORS.append((cmd, [loc])) + elif cmd_name in ("atom", "zed", "fleet"): + _GUI_EDITORS.append((cmd, [loc])) + elif cmd_name in ("idea", "pycharm", "goland", "webstorm", "clion"): + _GUI_EDITORS.append((cmd, ["--line", str(lineno), str(full_path)])) + + # Fallback: try well-known GUI editors + for cmd, args in [ + ("code", ["-g", loc]), + ("subl", [loc]), + ("zed", [loc]), + ]: + if shutil.which(cmd): + _GUI_EDITORS.append((cmd, args)) + + for cmd, args in _GUI_EDITORS: + try: + subprocess.Popen( + [cmd, *args], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + self.app.notify(f"Opened {loc_short}") + return + except Exception: + continue + + # No GUI editor found — copy path to clipboard as fallback + self.app.copy_to_clipboard(loc) + self.app.notify(f"Copied to clipboard: {loc}") + def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-stop": self._stop_running() @@ -787,6 +919,7 @@ def _reenable2() -> None: threading.Thread(target=_do_kill, daemon=True).start() def _open_log_in_editor(self) -> None: + """Open the log file in the user's editor (non-blocking).""" try: from dimos.core.log_viewer import resolve_log_path @@ -797,14 +930,10 @@ def _open_log_in_editor(self) -> None: path = resolve_log_path() # most recent if not path: - log_widget = self.query_one("#runner-log", RichLog) - log_widget.write("[dim]No log file found[/dim]") + self.app.notify("No log file found", severity="warning") return - editor = os.environ.get("EDITOR", "vi") - self.app.suspend() - os.system(f"{editor} {path}") - self.app.resume() + self._open_source_file(str(path), 0) except Exception: pass From ab8fe2fa4a247f612d34435f081f4ca2ad637270 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 10:22:15 -0700 Subject: [PATCH 09/23] misc --- dimos/utils/cli/dui/sub_apps/dtop.py | 68 +++++++++++++++++++++++- dimos/utils/cli/dui/sub_apps/launcher.py | 33 ++++++++++-- dimos/utils/cli/dui/sub_apps/runner.py | 45 ++++++++++++---- 3 files changed, 130 insertions(+), 16 deletions(-) diff --git a/dimos/utils/cli/dui/sub_apps/dtop.py b/dimos/utils/cli/dui/sub_apps/dtop.py index 3b89bc63ef..6bdbda459a 100644 --- a/dimos/utils/cli/dui/sub_apps/dtop.py +++ b/dimos/utils/cli/dui/sub_apps/dtop.py @@ -73,6 +73,8 @@ def __init__(self) -> None: self._last_msg_time: float = 0.0 self._cpu_history: dict[str, deque[float]] = {} self._reconnecting = False + self._refresh_count = 0 + self._msg_count = 0 def _debug(self, msg: str) -> None: try: @@ -139,8 +141,7 @@ def _init_lcm(self) -> None: self._debug("_init_lcm: creating PickleLCM...") plcm = PickleLCM() self._debug( - f"_init_lcm: PickleLCM created, l={plcm.l is not None}, " - f"url={plcm.config.url}" + f"_init_lcm: PickleLCM created, l={plcm.l is not None}, url={plcm.config.url}" ) self._debug("_init_lcm: subscribing to /dimos/resource_stats...") @@ -155,6 +156,53 @@ def _init_lcm(self) -> None: self._lcm = plcm self._debug("_init_lcm: DONE — listening for messages") + + # Self-test: publish a test message and see if we receive it + self._debug("_init_lcm: running self-test...") + self._self_test_received = False + test_topic = "/dimos/_dtop_self_test" + test_plcm = PickleLCM() + + def _on_test(msg: Any, _topic: Any) -> None: + self._self_test_received = True + + test_plcm.subscribe(Topic(test_topic), _on_test) + test_plcm.start() + import time as _time + + _time.sleep(0.2) + test_plcm.publish(test_topic, {"test": True}) + _time.sleep(0.5) + if self._self_test_received: + self._debug("_init_lcm: self-test PASSED — LCM multicast is working") + else: + self._debug( + "_init_lcm: self-test FAILED — LCM multicast NOT working! " + "Check multicast routes and firewall." + ) + try: + test_plcm.stop() + except Exception: + pass + + # Also try publishing on the real topic to see if our main + # subscription picks it up + self._debug("_init_lcm: testing subscription on /dimos/resource_stats...") + plcm.publish("/dimos/resource_stats", { + "coordinator": {"pid": 0, "cpu_percent": 0, "alive": True}, + "workers": [], + }) + _time.sleep(0.3) + if self._latest is not None: + self._debug("_init_lcm: real-topic self-test PASSED") + else: + self._debug( + "_init_lcm: real-topic self-test FAILED — subscription to " + "/dimos/resource_stats may not be working" + ) + # Clear the test data so the real data takes over + with self._lock: + self._latest = None except Exception as e: import traceback @@ -180,6 +228,7 @@ def _reconnect_lcm(self) -> None: self._reconnecting = False def _on_msg(self, msg: dict[str, Any], _topic: str) -> None: + self._msg_count += 1 first = False with self._lock: if self._latest is None: @@ -188,8 +237,11 @@ def _on_msg(self, msg: dict[str, Any], _topic: str) -> None: self._last_msg_time = time.monotonic() if first: self._debug(f"_on_msg: FIRST message received! keys={list(msg.keys())}") + elif self._msg_count % 10 == 0: + self._debug(f"_on_msg: {self._msg_count} messages received so far") def _refresh(self) -> None: + self._refresh_count += 1 with self._lock: data = self._latest last_msg = self._last_msg_time @@ -201,6 +253,18 @@ def _refresh(self) -> None: now = time.monotonic() + # Log status every 10 seconds (20 refresh cycles at 0.5s) + if self._refresh_count % 20 == 0: + lcm = self._lcm + thread_alive = "N/A" + if lcm is not None and hasattr(lcm, "_thread") and lcm._thread is not None: + thread_alive = str(lcm._thread.is_alive()) + self._debug( + f"_refresh #{self._refresh_count}: data={'yes' if data else 'no'}, " + f"msgs={self._msg_count}, lcm={'yes' if lcm else 'no'}, " + f"thread_alive={thread_alive}" + ) + if data is None: scroll.add_class("waiting") self.query_one("#dtop-panels", Static).update(self._waiting_panel()) diff --git a/dimos/utils/cli/dui/sub_apps/launcher.py b/dimos/utils/cli/dui/sub_apps/launcher.py index 355387582b..4557777fee 100644 --- a/dimos/utils/cli/dui/sub_apps/launcher.py +++ b/dimos/utils/cli/dui/sub_apps/launcher.py @@ -32,12 +32,22 @@ from textual.app import ComposeResult -def _launch_log_path() -> Path: - """Well-known path for launch stdout/stderr.""" +def _launch_log_dir() -> Path: + """Base directory for launch logs.""" xdg = os.environ.get("XDG_STATE_HOME") base = Path(xdg) / "dimos" if xdg else Path.home() / ".local" / "state" / "dimos" base.mkdir(parents=True, exist_ok=True) - return base / "launch.log" + return base + + +def _launch_log_path() -> Path: + """Well-known path for launch stdout/stderr (with ANSI colors).""" + return _launch_log_dir() / "launch.log" + + +def _launch_log_plain_path() -> Path: + """Well-known path for launch stdout/stderr (plain text, no ANSI).""" + return _launch_log_dir() / "launch.plain.log" def _is_blueprint_running() -> bool: @@ -258,22 +268,35 @@ def _launch(self, name: str) -> None: cmd = [sys.executable, "-m", "dimos.robot.cli.dimos", *config_args, "run", "--daemon", name] def _do_launch() -> None: + import re + + _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") log_file = _launch_log_path() + plain_file = _launch_log_plain_path() # Preserve ANSI colors in piped output env = os.environ.copy() env["FORCE_COLOR"] = "1" env["PYTHONUNBUFFERED"] = "1" env["TERM"] = env.get("TERM", "xterm-256color") try: - with open(log_file, "w") as f: + with ( + open(log_file, "w") as f_color, + open(plain_file, "w") as f_plain, + ): proc = subprocess.Popen( cmd, stdin=subprocess.DEVNULL, - stdout=f, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True, ) + for raw_line in proc.stdout: # type: ignore[union-attr] + line = raw_line.decode("utf-8", errors="replace") + f_color.write(line) + f_color.flush() + f_plain.write(_ANSI_RE.sub("", line)) + f_plain.flush() proc.wait() rc = proc.returncode diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py index 41e2ffb282..23f5992aa3 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -36,11 +36,20 @@ from textual.app import ComposeResult -def _launch_log_path() -> Path: - """Well-known path for launch stdout/stderr (shared with launcher).""" +def _launch_log_dir() -> Path: + """Base directory for launch logs.""" xdg = os.environ.get("XDG_STATE_HOME") - base = Path(xdg) / "dimos" if xdg else Path.home() / ".local" / "state" / "dimos" - return base / "launch.log" + return Path(xdg) / "dimos" if xdg else Path.home() / ".local" / "state" / "dimos" + + +def _launch_log_path() -> Path: + """Well-known path for launch stdout/stderr (with ANSI colors).""" + return _launch_log_dir() / "launch.log" + + +def _launch_log_plain_path() -> Path: + """Well-known path for launch stdout/stderr (plain text, no ANSI).""" + return _launch_log_dir() / "launch.plain.log" class StatusSubApp(SubApp): @@ -560,12 +569,17 @@ def _restore_status(self) -> None: pass def _handle_double_click(self, event: Any) -> None: - """Open launch.log in the user's editor.""" - log_path = _launch_log_path() + """Open the plain (no ANSI) launch log in the user's editor.""" + log_path = _launch_log_plain_path() if log_path.exists(): self._open_source_file(str(log_path), 0) else: - self.app.notify("No launch log found", severity="warning") + # Fall back to colored version + log_path = _launch_log_path() + if log_path.exists(): + self._open_source_file(str(log_path), 0) + else: + self.app.notify("No launch log found", severity="warning") def _open_source_file(self, file_path: str, lineno: int) -> None: """Open a source file in the user's preferred GUI editor. @@ -803,16 +817,29 @@ def _do_restart() -> None: env["PYTHONUNBUFFERED"] = "1" env["TERM"] = env.get("TERM", "xterm-256color") try: + import re as _re + + _ANSI_RE = _re.compile(r"\x1b\[[0-9;]*m") log_file = _launch_log_path() - with open(log_file, "w") as f: + plain_file = _launch_log_plain_path() + with ( + open(log_file, "w") as f_color, + open(plain_file, "w") as f_plain, + ): proc = subprocess.Popen( cmd, stdin=subprocess.DEVNULL, - stdout=f, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True, ) + for raw_line in proc.stdout: # type: ignore[union-attr] + line = raw_line.decode("utf-8", errors="replace") + f_color.write(line) + f_color.flush() + f_plain.write(_ANSI_RE.sub("", line)) + f_plain.flush() proc.wait() except Exception as e: self.app.call_from_thread(log_widget.write, f"[red]Restart error: {e}[/red]") From 9d6e2aae28f07828d60ccb2e02a39a3894bc881a Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 10:25:40 -0700 Subject: [PATCH 10/23] improve logger text, start on getting theme consolidated --- dimos/utils/cli/dui/app.py | 24 ++ dimos/utils/cli/dui/dui.tcss | 36 +-- dimos/utils/cli/dui/sub_apps/agentspy.py | 14 +- dimos/utils/cli/dui/sub_apps/config.py | 109 ++++---- dimos/utils/cli/dui/sub_apps/dtop.py | 24 +- dimos/utils/cli/dui/sub_apps/humancli.py | 50 ++-- dimos/utils/cli/dui/sub_apps/launcher.py | 84 ++++--- dimos/utils/cli/dui/sub_apps/lcmspy.py | 22 +- dimos/utils/cli/dui/sub_apps/runner.py | 129 ++++++---- dimos/utils/cli/theme.py | 306 +++++++++++++++++++++-- 10 files changed, 582 insertions(+), 216 deletions(-) diff --git a/dimos/utils/cli/dui/app.py b/dimos/utils/cli/dui/app.py index c9850e6087..185558de32 100644 --- a/dimos/utils/cli/dui/app.py +++ b/dimos/utils/cli/dui/app.py @@ -27,6 +27,7 @@ from textual.containers import Container, Horizontal from textual.widgets import RichLog, Static +from dimos.utils.cli import theme from dimos.utils.cli.dui.sub_apps import get_sub_apps if TYPE_CHECKING: @@ -60,6 +61,13 @@ class DUIApp(App[None]): def __init__(self, *, debug: bool = False) -> None: super().__init__() + # Register all DimOS themes + for t in theme.get_textual_themes(): + self.register_theme(t) + # Load saved theme from config + saved_theme = self._load_saved_theme() + theme.set_theme(saved_theme) + self.theme = f"dimos-{saved_theme}" self._debug = debug self._sub_app_classes = get_sub_apps() n = len(self._sub_app_classes) @@ -85,6 +93,22 @@ def __init__(self, *, debug: bool = False) -> None: self._debug_log_path = str(log_path) self._debug_log_file = f + @staticmethod + def _load_saved_theme() -> str: + """Read the saved theme name from dio-config.json, falling back to default.""" + import json + from pathlib import Path + + try: + config_path = Path(sys.prefix) / "dio-config.json" + data = json.loads(config_path.read_text()) + name = data.get("theme", theme.DEFAULT_THEME) + if name in theme.THEME_NAMES: + return name + except Exception: + pass + return theme.DEFAULT_THEME + # ------------------------------------------------------------------ # Debug log # ------------------------------------------------------------------ diff --git a/dimos/utils/cli/dui/dui.tcss b/dimos/utils/cli/dui/dui.tcss index 68ba50c06b..1c8a2c5d9e 100644 --- a/dimos/utils/cli/dui/dui.tcss +++ b/dimos/utils/cli/dui/dui.tcss @@ -2,13 +2,13 @@ Screen { layout: horizontal; - background: #0b0f0f; + background: $dui-bg; } #sidebar { width: 22; height: 1fr; - background: #0b0f0f; + background: $dui-bg; padding: 1 0; } @@ -17,23 +17,23 @@ Screen { height: 3; padding: 1 2; content-align: left middle; - background: #0b0f0f; - color: #b5e4f4; + background: $dui-bg; + color: $dui-text; } .tab-item.--selected-1 { - background: #1a2a2a; - color: #00eeee; + background: $dui-tab1-bg; + color: $dui-tab1; } .tab-item.--selected-2 { - background: #1a1a2a; - color: #5c9ff0; + background: $dui-tab2-bg; + color: $dui-tab2; } .tab-item.--selected-3 { - background: #2a1a2a; - color: #c07ff0; + background: $dui-tab3-bg; + color: $dui-tab3; } #displays { @@ -45,12 +45,12 @@ Screen { .display-pane { width: 1fr; height: 1fr; - border: solid #404040; - background: #0b0f0f; + border: solid $dui-dim; + background: $dui-bg; } .display-pane.--focused { - border: solid #00eeee; + border: solid $dui-accent; } #display-2 { @@ -65,9 +65,9 @@ Screen { width: 50; height: 1fr; dock: right; - border-left: solid #404040; - background: #0b0f0f; - color: #b5e4f4; + border-left: solid $dui-dim; + background: $dui-bg; + color: $dui-text; padding: 0 1; scrollbar-size: 1 1; } @@ -76,8 +76,8 @@ Screen { dock: top; height: 1; width: 100%; - background: #1a2020; - color: #404040; + background: $dui-hint-bg; + color: $dui-dim; padding: 0 1; content-align: left middle; } diff --git a/dimos/utils/cli/dui/sub_apps/agentspy.py b/dimos/utils/cli/dui/sub_apps/agentspy.py index 16251b83ce..28d240ab30 100644 --- a/dimos/utils/cli/dui/sub_apps/agentspy.py +++ b/dimos/utils/cli/dui/sub_apps/agentspy.py @@ -14,17 +14,17 @@ class AgentSpySubApp(SubApp): TITLE = "agentspy" - DEFAULT_CSS = f""" - AgentSpySubApp {{ + DEFAULT_CSS = """ + AgentSpySubApp { layout: vertical; - background: {theme.BACKGROUND}; - }} - AgentSpySubApp RichLog {{ + background: $dui-bg; + } + AgentSpySubApp RichLog { height: 1fr; border: none; - background: {theme.BACKGROUND}; + background: $dui-bg; padding: 0 1; - }} + } """ def __init__(self) -> None: diff --git a/dimos/utils/cli/dui/sub_apps/config.py b/dimos/utils/cli/dui/sub_apps/config.py index 104cd055ed..257e4247fa 100644 --- a/dimos/utils/cli/dui/sub_apps/config.py +++ b/dimos/utils/cli/dui/sub_apps/config.py @@ -35,12 +35,13 @@ from textual.app import ComposeResult _VIEWER_OPTIONS = ["rerun", "rerun-web", "rerun-connect", "foxglove", "none"] +_THEME_OPTIONS = theme.THEME_NAMES class _FormNavigationMixin: """Mixin that intercepts Up/Down to move focus between config fields.""" - _FIELD_ORDER = ("cfg-viewer", "cfg-n-workers", "cfg-robot-ip", "cfg-dtop") + _FIELD_ORDER = ("cfg-theme", "cfg-viewer", "cfg-n-workers", "cfg-robot-ip", "cfg-dtop") def _navigate_field(self, delta: int) -> None: my_id = getattr(self, "id", None) @@ -69,9 +70,10 @@ class CycleSelect(_FormNavigationMixin, Widget, can_focus=True): current_value: reactive[str] = reactive("") class Changed(Message): - def __init__(self, value: str) -> None: + def __init__(self, value: str, widget_id: str = "") -> None: super().__init__() self.value = value + self.widget_id = widget_id def __init__(self, options: list[str], value: str, **kwargs: Any) -> None: super().__init__(**kwargs) @@ -89,7 +91,7 @@ def render(self) -> Text: def action_cycle(self, delta: int) -> None: self._index = (self._index + delta) % len(self._options) self.current_value = self._options[self._index] - self.post_message(self.Changed(self.current_value)) + self.post_message(self.Changed(self.current_value, widget_id=self.id or "")) def action_nav(self, delta: int) -> None: self._navigate_field(delta) @@ -108,6 +110,7 @@ def action_nav(self, delta: int) -> None: _DEFAULTS: dict[str, object] = { + "theme": theme.DEFAULT_THEME, "viewer": "rerun", "n_workers": 2, "robot_ip": "", @@ -146,62 +149,62 @@ def _save_config(values: dict[str, object]) -> None: class ConfigSubApp(SubApp): TITLE = "config" - DEFAULT_CSS = f""" - ConfigSubApp {{ + DEFAULT_CSS = """ + ConfigSubApp { layout: vertical; padding: 1 2; - background: {theme.BACKGROUND}; + background: $dui-bg; overflow-y: auto; - }} - ConfigSubApp .subapp-header {{ - color: #ff8800; + } + ConfigSubApp .subapp-header { + color: $dui-header; padding: 0; text-style: bold; - }} - ConfigSubApp Label {{ + } + ConfigSubApp Label { margin-top: 1; - color: {theme.ACCENT}; - }} - ConfigSubApp .field-label {{ - color: {theme.CYAN}; + color: $dui-text; + } + ConfigSubApp .field-label { + color: $dui-accent; margin-bottom: 0; - }} - ConfigSubApp Input, ConfigSubApp ConfigInput {{ + } + ConfigSubApp Input, ConfigSubApp ConfigInput { width: 40; - }} - ConfigSubApp CycleSelect {{ + } + ConfigSubApp CycleSelect { width: 40; height: 3; - background: {theme.BACKGROUND}; - color: {theme.ACCENT}; - border: solid {theme.DIM}; + background: $dui-bg; + color: $dui-text; + border: solid $dui-dim; content-align: left middle; - }} - ConfigSubApp CycleSelect:focus {{ - border: solid {theme.CYAN}; - color: {theme.CYAN}; - }} - ConfigSubApp .switch-row {{ + } + ConfigSubApp CycleSelect:focus { + border: solid $dui-accent; + color: $dui-accent; + } + ConfigSubApp .switch-row { height: 3; margin-top: 1; - }} - ConfigSubApp .switch-row Label {{ + } + ConfigSubApp .switch-row Label { margin-top: 0; padding: 1 0; - }} - ConfigSubApp .switch-state {{ - color: {theme.DIM}; + } + ConfigSubApp .switch-state { + color: $dui-dim; padding: 1 1; width: 6; - }} - ConfigSubApp .switch-state.--on {{ - color: {theme.CYAN}; - }} - ConfigSubApp #cfg-dirty-notice {{ + } + ConfigSubApp .switch-state.--on { + color: $dui-accent; + } + ConfigSubApp #cfg-dirty-notice { margin-top: 1; - color: {theme.YELLOW}; + color: $dui-yellow; display: none; - }} + } """ def __init__(self) -> None: @@ -212,6 +215,13 @@ def compose(self) -> ComposeResult: v = self.config_values yield Static("GlobalConfig Editor", classes="subapp-header") + yield Label("theme", classes="field-label") + yield CycleSelect( + _THEME_OPTIONS, + value=str(v.get("theme", theme.DEFAULT_THEME)), + id="cfg-theme", + ) + yield Label("viewer", classes="field-label") yield CycleSelect( _VIEWER_OPTIONS, @@ -242,9 +252,22 @@ def _mark_dirty(self) -> None: self.query_one("#cfg-dirty-notice").styles.display = "block" def on_cycle_select_changed(self, event: CycleSelect.Changed) -> None: - self.config_values["viewer"] = event.value - _save_config(self.config_values) - self._mark_dirty() + if event.widget_id == "cfg-theme": + self.config_values["theme"] = event.value + _save_config(self.config_values) + self._apply_theme(event.value) + elif event.widget_id == "cfg-viewer": + self.config_values["viewer"] = event.value + _save_config(self.config_values) + self._mark_dirty() + + def _apply_theme(self, name: str) -> None: + """Switch theme live.""" + theme.set_theme(name) + try: + self.app.theme = f"dimos-{name}" + except Exception: + pass def on_input_changed(self, event: Input.Changed) -> None: if event.input.id == "cfg-n-workers": diff --git a/dimos/utils/cli/dui/sub_apps/dtop.py b/dimos/utils/cli/dui/sub_apps/dtop.py index 6bdbda459a..361023e972 100644 --- a/dimos/utils/cli/dui/sub_apps/dtop.py +++ b/dimos/utils/cli/dui/sub_apps/dtop.py @@ -44,25 +44,25 @@ class DtopSubApp(SubApp): TITLE = "dtop" - DEFAULT_CSS = f""" - DtopSubApp {{ + DEFAULT_CSS = """ + DtopSubApp { layout: vertical; height: 1fr; - background: {theme.BACKGROUND}; - }} - DtopSubApp VerticalScroll {{ + background: $dui-bg; + } + DtopSubApp VerticalScroll { height: 1fr; scrollbar-size: 0 0; - }} - DtopSubApp VerticalScroll.waiting {{ + } + DtopSubApp VerticalScroll.waiting { align: center middle; - }} - DtopSubApp .waiting #dtop-panels {{ + } + DtopSubApp .waiting #dtop-panels { width: auto; - }} - DtopSubApp #dtop-panels {{ + } + DtopSubApp #dtop-panels { background: transparent; - }} + } """ def __init__(self) -> None: diff --git a/dimos/utils/cli/dui/sub_apps/humancli.py b/dimos/utils/cli/dui/sub_apps/humancli.py index 7c55d77107..4f98939759 100644 --- a/dimos/utils/cli/dui/sub_apps/humancli.py +++ b/dimos/utils/cli/dui/sub_apps/humancli.py @@ -44,14 +44,16 @@ class _ConnState(Enum): ERROR = auto() -_STATUS_STYLE: dict[_ConnState, tuple[str, str]] = { - _ConnState.DISCONNECTED: ("disconnected", theme.DIM), - _ConnState.CONNECTING: ("connecting…", theme.YELLOW), - _ConnState.WAITING_FOR_AGENT: ("waiting for agent…", theme.YELLOW), - _ConnState.CONNECTED: ("connected", theme.GREEN), - _ConnState.NO_AGENT: ("no agent — blueprint may not include one", theme.DIM), - _ConnState.ERROR: ("error", theme.RED), -} +def _status_style(state: _ConnState) -> tuple[str, str]: + """Return (label, color) for a connection state, reading theme at call time.""" + return { + _ConnState.DISCONNECTED: ("disconnected", theme.DIM), + _ConnState.CONNECTING: ("connecting…", theme.YELLOW), + _ConnState.WAITING_FOR_AGENT: ("waiting for agent…", theme.YELLOW), + _ConnState.CONNECTED: ("connected", theme.GREEN), + _ConnState.NO_AGENT: ("no agent — blueprint may not include one", theme.DIM), + _ConnState.ERROR: ("error", theme.RED), + }[state] # Seconds to wait for an agent response before showing "no agent" _AGENT_DETECT_TIMEOUT = 8.0 @@ -123,29 +125,29 @@ def _toggle(self) -> None: class HumanCLISubApp(SubApp): TITLE = "chat" - DEFAULT_CSS = f""" - HumanCLISubApp {{ + DEFAULT_CSS = """ + HumanCLISubApp { layout: vertical; - background: {theme.BACKGROUND}; - }} - HumanCLISubApp #hcli-status-bar {{ + background: $dui-bg; + } + HumanCLISubApp #hcli-status-bar { height: 1; dock: top; padding: 0 1; - background: {theme.BG}; - color: {theme.DIM}; - }} - HumanCLISubApp #hcli-chat {{ + background: $dui-bg; + color: $dui-dim; + } + HumanCLISubApp #hcli-chat { height: 1fr; - }} - HumanCLISubApp RichLog {{ + } + HumanCLISubApp RichLog { height: 1fr; scrollbar-size: 0 0; - border: solid {theme.DIM}; - }} - HumanCLISubApp Input {{ + border: solid $dui-dim; + } + HumanCLISubApp Input { dock: bottom; - }} + } """ def __init__(self) -> None: @@ -191,7 +193,7 @@ def on_unmount_subapp(self) -> None: def _set_conn_state(self, state: _ConnState, error: str = "") -> None: self._conn_state = state self._conn_error = error - label, color = _STATUS_STYLE[state] + label, color = _status_style(state) if state == _ConnState.ERROR and error: label = f"error: {error}" try: diff --git a/dimos/utils/cli/dui/sub_apps/launcher.py b/dimos/utils/cli/dui/sub_apps/launcher.py index 4557777fee..3f1dee95a3 100644 --- a/dimos/utils/cli/dui/sub_apps/launcher.py +++ b/dimos/utils/cli/dui/sub_apps/launcher.py @@ -50,6 +50,21 @@ def _launch_log_plain_path() -> Path: return _launch_log_dir() / "launch.plain.log" +def _copy_plain_log_to_run_dir(plain_file: Path) -> None: + """Copy the plain launch log into the most recent run's log directory.""" + import shutil + + try: + from dimos.core.run_registry import get_most_recent + + entry = get_most_recent(alive_only=False) + if entry and entry.log_dir: + dest = Path(entry.log_dir) / "launch.log" + shutil.copy2(plain_file, dest) + except Exception: + pass + + def _is_blueprint_running() -> bool: """Return True if a blueprint is currently running.""" try: @@ -63,53 +78,53 @@ def _is_blueprint_running() -> bool: class LauncherSubApp(SubApp): TITLE = "launch" - DEFAULT_CSS = f""" - LauncherSubApp {{ + DEFAULT_CSS = """ + LauncherSubApp { layout: vertical; height: 1fr; - background: {theme.BACKGROUND}; - }} - LauncherSubApp .subapp-header {{ + background: $dui-bg; + } + LauncherSubApp .subapp-header { width: 100%; height: auto; - color: #ff8800; + color: $dui-header; padding: 1 2; text-style: bold; - }} - LauncherSubApp #launch-filter {{ + } + LauncherSubApp #launch-filter { width: 100%; - background: {theme.BACKGROUND}; - border: solid {theme.DIM}; - color: {theme.ACCENT}; - }} - LauncherSubApp #launch-filter:focus {{ - border: solid {theme.CYAN}; - }} - LauncherSubApp ListView {{ + background: $dui-bg; + border: solid $dui-dim; + color: $dui-text; + } + LauncherSubApp #launch-filter:focus { + border: solid $dui-accent; + } + LauncherSubApp ListView { height: 1fr; - background: {theme.BACKGROUND}; - }} - LauncherSubApp ListView > ListItem {{ - background: {theme.BACKGROUND}; - color: {theme.ACCENT}; + background: $dui-bg; + } + LauncherSubApp ListView > ListItem { + background: $dui-bg; + color: $dui-text; padding: 1 2; - }} - LauncherSubApp ListView > ListItem.--highlight {{ - background: #1a2a2a; - }} - LauncherSubApp.--locked ListView {{ + } + LauncherSubApp ListView > ListItem.--highlight { + background: $dui-panel-bg; + } + LauncherSubApp.--locked ListView { opacity: 0.35; - }} - LauncherSubApp.--locked #launch-filter {{ + } + LauncherSubApp.--locked #launch-filter { opacity: 0.35; - }} - LauncherSubApp .status-bar {{ + } + LauncherSubApp .status-bar { height: 1; dock: bottom; - background: #1a2020; - color: {theme.DIM}; + background: $dui-hint-bg; + color: $dui-dim; padding: 0 1; - }} + } """ def __init__(self) -> None: @@ -300,6 +315,9 @@ def _do_launch() -> None: proc.wait() rc = proc.returncode + # Copy plain log into the run's log directory for archival + _copy_plain_log_to_run_dir(plain_file) + def _after() -> None: self._launching = False if rc != 0: diff --git a/dimos/utils/cli/dui/sub_apps/lcmspy.py b/dimos/utils/cli/dui/sub_apps/lcmspy.py index fb24fc823d..8502886857 100644 --- a/dimos/utils/cli/dui/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dui/sub_apps/lcmspy.py @@ -31,22 +31,22 @@ class LCMSpySubApp(SubApp): TITLE = "lcmspy" - DEFAULT_CSS = f""" - LCMSpySubApp {{ + DEFAULT_CSS = """ + LCMSpySubApp { layout: vertical; - background: {theme.BACKGROUND}; - }} - LCMSpySubApp DataTable {{ + background: $dui-bg; + } + LCMSpySubApp DataTable { height: 1fr; width: 1fr; - border: solid {theme.DIM}; - background: {theme.BG}; + border: solid $dui-dim; + background: $dui-bg; scrollbar-size: 0 0; - }} - LCMSpySubApp DataTable > .datatable--header {{ - color: {theme.ACCENT}; + } + LCMSpySubApp DataTable > .datatable--header { + color: $dui-text; background: transparent; - }} + } """ def __init__(self) -> None: diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py index 23f5992aa3..958a9734dd 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -55,100 +55,101 @@ def _launch_log_plain_path() -> Path: class StatusSubApp(SubApp): TITLE = "status" - DEFAULT_CSS = f""" - StatusSubApp {{ + DEFAULT_CSS = """ + StatusSubApp { layout: vertical; height: 1fr; - background: {theme.BACKGROUND}; - }} - StatusSubApp .subapp-header {{ + background: $dui-bg; + } + StatusSubApp .subapp-header { width: 100%; height: auto; color: #ff8800; padding: 1 2; text-style: bold; - }} - StatusSubApp RichLog {{ + } + StatusSubApp RichLog { height: 1fr; - background: {theme.BACKGROUND}; - border: solid {theme.DIM}; - scrollbar-size: 0 0; - }} - StatusSubApp #idle-container {{ + background: $dui-bg; + border: solid $dui-dim; + scrollbar-size-vertical: 0; + scrollbar-size-horizontal: 1; + } + StatusSubApp #idle-container { height: 1fr; align: center middle; - }} - StatusSubApp #idle-panel {{ + } + StatusSubApp #idle-panel { width: auto; background: transparent; - }} - StatusSubApp #run-controls {{ + } + StatusSubApp #run-controls { height: auto; padding: 0 1; - background: {theme.BACKGROUND}; - }} - StatusSubApp #run-controls Button {{ + background: $dui-bg; + } + StatusSubApp #run-controls Button { margin: 0 1 0 0; min-width: 12; background: transparent; - border: solid {theme.DIM}; - color: {theme.ACCENT}; - }} - StatusSubApp .status-bar {{ + border: solid $dui-dim; + color: $dui-text; + } + StatusSubApp .status-bar { height: 1; dock: bottom; - background: #1a2020; - color: {theme.DIM}; + background: $dui-hint-bg; + color: $dui-dim; padding: 0 1; - }} - StatusSubApp #btn-stop {{ + } + StatusSubApp #btn-stop { border: solid #882222; color: #cc4444; - }} - StatusSubApp #btn-stop:hover {{ + } + StatusSubApp #btn-stop:hover { border: solid #cc4444; - }} - StatusSubApp #btn-stop:focus {{ + } + StatusSubApp #btn-stop:focus { background: #882222; color: #ffffff; border: solid #cc4444; - }} - StatusSubApp #btn-sudo-kill {{ + } + StatusSubApp #btn-sudo-kill { border: solid #882222; color: #ff4444; - }} - StatusSubApp #btn-sudo-kill:hover {{ + } + StatusSubApp #btn-sudo-kill:hover { border: solid #ff4444; - }} - StatusSubApp #btn-sudo-kill:focus {{ + } + StatusSubApp #btn-sudo-kill:focus { background: #882222; color: #ffffff; border: solid #ff4444; - }} - StatusSubApp #btn-restart {{ + } + StatusSubApp #btn-restart { border: solid #886600; color: #ccaa00; - }} - StatusSubApp #btn-restart:hover {{ + } + StatusSubApp #btn-restart:hover { border: solid #ccaa00; - }} - StatusSubApp #btn-restart:focus {{ + } + StatusSubApp #btn-restart:focus { background: #886600; color: #ffffff; border: solid #ccaa00; - }} - StatusSubApp #btn-open-log {{ + } + StatusSubApp #btn-open-log { border: solid #445566; color: #8899aa; - }} - StatusSubApp #btn-open-log:hover {{ + } + StatusSubApp #btn-open-log:hover { border: solid #8899aa; - }} - StatusSubApp #btn-open-log:focus {{ + } + StatusSubApp #btn-open-log:focus { background: #445566; color: #ffffff; border: solid #8899aa; - }} + } """ def __init__(self) -> None: @@ -175,7 +176,7 @@ def compose(self) -> ComposeResult: yield Static("Blueprint Status", classes="subapp-header") with VerticalScroll(id="idle-container"): yield Static(self._idle_panel(), id="idle-panel") - yield RichLog(id="runner-log", markup=True, wrap=True, auto_scroll=True) + yield RichLog(id="runner-log", markup=True, wrap=False, auto_scroll=True, min_width=600) with Horizontal(id="run-controls"): yield Button("Stop", id="btn-stop", variant="error") yield Button("Force Kill (sudo)", id="btn-sudo-kill") @@ -568,16 +569,30 @@ def _restore_status(self) -> None: except Exception: pass + def _get_clicked_line_number(self, event: Any) -> int: + """Map a click event to a 1-based line number in the log file.""" + try: + log_widget = self.query_one("#runner-log", RichLog) + # Convert screen_y to position relative to the RichLog + local_y = event.screen_y - log_widget.region.y + # Add scroll offset to get the visual line index + line_idx = int(log_widget.scroll_y) + local_y + # 1-based for editors + return max(1, line_idx + 1) + except Exception: + return 1 + def _handle_double_click(self, event: Any) -> None: """Open the plain (no ANSI) launch log in the user's editor.""" + lineno = self._get_clicked_line_number(event) log_path = _launch_log_plain_path() if log_path.exists(): - self._open_source_file(str(log_path), 0) + self._open_source_file(str(log_path), lineno) else: # Fall back to colored version log_path = _launch_log_path() if log_path.exists(): - self._open_source_file(str(log_path), 0) + self._open_source_file(str(log_path), lineno) else: self.app.notify("No launch log found", severity="warning") @@ -841,6 +856,14 @@ def _do_restart() -> None: f_plain.write(_ANSI_RE.sub("", line)) f_plain.flush() proc.wait() + + # Copy plain log into the run's log directory for archival + try: + from dimos.utils.cli.dui.sub_apps.launcher import _copy_plain_log_to_run_dir + + _copy_plain_log_to_run_dir(plain_file) + except Exception: + pass except Exception as e: self.app.call_from_thread(log_widget.write, f"[red]Restart error: {e}[/red]") diff --git a/dimos/utils/cli/theme.py b/dimos/utils/cli/theme.py index b6b6b9ccae..76b4a3252b 100644 --- a/dimos/utils/cli/theme.py +++ b/dimos/utils/cli/theme.py @@ -12,7 +12,23 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Parse DimOS theme from tcss file.""" +"""DimOS theme system. + +Provides named themes for the DUI TUI. Each theme defines: + - Textual Theme fields (primary, background, etc.) for built-in widgets + - Custom CSS variables (prefixed ``dui-``) for DimOS-specific styling + - Python-level constants (ACCENT, DIM, AGENT, …) for Rich markup + +Usage in CSS:: + + background: $dui-bg; + border: solid $dui-dim; + color: $dui-text; + +Usage in Python (Rich markup):: + + f"[{theme.AGENT}]agent response[/{theme.AGENT}]" +""" from __future__ import annotations @@ -21,38 +37,298 @@ def parse_tcss_colors(tcss_path: str | Path) -> dict[str, str]: - """Parse color variables from a tcss file. - - Args: - tcss_path: Path to the tcss file - - Returns: - Dictionary mapping variable names to color values - """ + """Parse color variables from a tcss file.""" tcss_path = Path(tcss_path) content = tcss_path.read_text() - - # Match $variable: value; patterns pattern = r"\$([a-zA-Z0-9_-]+)\s*:\s*(#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3});" matches = re.findall(pattern, content) - return {name: value for name, value in matches} -# Load DimOS theme colors +# Load DimOS theme colors (used by standalone apps via CSS_PATH) _THEME_PATH = Path(__file__).parent / "dimos.tcss" COLORS = parse_tcss_colors(_THEME_PATH) -# Export CSS path for Textual apps +# Export CSS path for standalone Textual apps (not DUI) CSS_PATH = str(_THEME_PATH) -# Convenience accessors for common colors +# Convenience accessor def get(name: str, default: str = "#ffffff") -> str: """Get a color by variable name.""" return COLORS.get(name, default) +# --------------------------------------------------------------------------- +# Theme definitions +# --------------------------------------------------------------------------- + +# Each entry maps custom CSS variable names (without ``$``) to hex values. +# These are injected into Textual's CSS variable system via Theme.variables. +# The keys here become ``$dui-bg``, ``$dui-dim``, etc. in CSS. + +_THEME_VARIABLES: dict[str, dict[str, str]] = { + "dark": { + # Core + "dui-bg": "#0b0f0f", + "dui-fg": "#b5e4f4", + "dui-text": "#b5e4f4", + "dui-dim": "#404040", + "dui-accent": "#00eeee", + "dui-border": "#00eeee", + # Base palette + "dui-yellow": "#ffcc00", + "dui-red": "#ff0000", + "dui-green": "#00eeee", + "dui-blue": "#5c9ff0", + "dui-purple": "#c07ff0", + # Chat message colors + "dui-agent": "#88ff88", + "dui-tool": "#00eeee", + "dui-tool-result": "#ffff00", + "dui-human": "#ffffff", + "dui-timestamp": "#ffffff", + # UI chrome + "dui-header": "#ff8800", + "dui-panel-bg": "#1a2a2a", + "dui-hint-bg": "#1a2020", + "dui-tab1": "#00eeee", + "dui-tab1-bg": "#1a2a2a", + "dui-tab2": "#5c9ff0", + "dui-tab2-bg": "#1a1a2a", + "dui-tab3": "#c07ff0", + "dui-tab3-bg": "#2a1a2a", + }, + "midnight": { + "dui-bg": "#0a0e1a", + "dui-fg": "#a0b8d0", + "dui-text": "#a0b8d0", + "dui-dim": "#303850", + "dui-accent": "#4488cc", + "dui-border": "#4488cc", + "dui-yellow": "#ccaa44", + "dui-red": "#cc4444", + "dui-green": "#44aa88", + "dui-blue": "#5588dd", + "dui-purple": "#8866cc", + "dui-agent": "#66cc88", + "dui-tool": "#4488cc", + "dui-tool-result": "#ccaa44", + "dui-human": "#d0d8e0", + "dui-timestamp": "#8899bb", + "dui-header": "#dd8833", + "dui-panel-bg": "#151c2e", + "dui-hint-bg": "#101828", + "dui-tab1": "#4488cc", + "dui-tab1-bg": "#151c2e", + "dui-tab2": "#5588dd", + "dui-tab2-bg": "#14183a", + "dui-tab3": "#8866cc", + "dui-tab3-bg": "#1c1430", + }, + "ember": { + "dui-bg": "#120c0a", + "dui-fg": "#e0c8b0", + "dui-text": "#e0c8b0", + "dui-dim": "#4a3028", + "dui-accent": "#ee8844", + "dui-border": "#ee8844", + "dui-yellow": "#ddaa33", + "dui-red": "#dd4433", + "dui-green": "#88aa44", + "dui-blue": "#cc8844", + "dui-purple": "#cc6688", + "dui-agent": "#aacc66", + "dui-tool": "#ee8844", + "dui-tool-result": "#ddaa33", + "dui-human": "#e8d8c8", + "dui-timestamp": "#aa9080", + "dui-header": "#ff8844", + "dui-panel-bg": "#2a1810", + "dui-hint-bg": "#1a1210", + "dui-tab1": "#ee8844", + "dui-tab1-bg": "#2a1810", + "dui-tab2": "#cc8844", + "dui-tab2-bg": "#2a2010", + "dui-tab3": "#cc6688", + "dui-tab3-bg": "#2a1420", + }, + "forest": { + "dui-bg": "#0a100c", + "dui-fg": "#b0d0b8", + "dui-text": "#b0d0b8", + "dui-dim": "#2a3a2e", + "dui-accent": "#44cc88", + "dui-border": "#44cc88", + "dui-yellow": "#aacc44", + "dui-red": "#cc4444", + "dui-green": "#44cc88", + "dui-blue": "#44aa99", + "dui-purple": "#88aa66", + "dui-agent": "#66dd88", + "dui-tool": "#44cc88", + "dui-tool-result": "#aacc44", + "dui-human": "#d0e0d0", + "dui-timestamp": "#80aa88", + "dui-header": "#88cc44", + "dui-panel-bg": "#142a1a", + "dui-hint-bg": "#101a14", + "dui-tab1": "#44cc88", + "dui-tab1-bg": "#142a1a", + "dui-tab2": "#44aa99", + "dui-tab2-bg": "#142a26", + "dui-tab3": "#88aa66", + "dui-tab3-bg": "#1e2a14", + }, +} + +# Textual Theme constructor args for each theme +_THEME_BASES: dict[str, dict[str, object]] = { + "dark": { + "primary": "#00eeee", + "secondary": "#5c9ff0", + "warning": "#ffcc00", + "error": "#ff0000", + "success": "#88ff88", + "accent": "#00eeee", + "foreground": "#b5e4f4", + "background": "#0b0f0f", + "surface": "#0b0f0f", + "panel": "#1a2a2a", + "dark": True, + }, + "midnight": { + "primary": "#4488cc", + "secondary": "#5588dd", + "warning": "#ccaa44", + "error": "#cc4444", + "success": "#44aa88", + "accent": "#4488cc", + "foreground": "#a0b8d0", + "background": "#0a0e1a", + "surface": "#0a0e1a", + "panel": "#151c2e", + "dark": True, + }, + "ember": { + "primary": "#ee8844", + "secondary": "#cc8844", + "warning": "#ddaa33", + "error": "#dd4433", + "success": "#88aa44", + "accent": "#ee8844", + "foreground": "#e0c8b0", + "background": "#120c0a", + "surface": "#120c0a", + "panel": "#2a1810", + "dark": True, + }, + "forest": { + "primary": "#44cc88", + "secondary": "#44aa99", + "warning": "#aacc44", + "error": "#cc4444", + "success": "#44cc88", + "accent": "#44cc88", + "foreground": "#b0d0b8", + "background": "#0a100c", + "surface": "#0a100c", + "panel": "#142a1a", + "dark": True, + }, +} + +THEME_NAMES: list[str] = list(_THEME_VARIABLES) +DEFAULT_THEME = "dark" + + +def get_textual_themes() -> list[object]: + """Return a list of Textual ``Theme`` objects for all DimOS themes.""" + from textual.theme import Theme as TextualTheme + + themes = [] + for name in THEME_NAMES: + base = _THEME_BASES[name] + variables = _THEME_VARIABLES[name] + themes.append( + TextualTheme( + name=f"dimos-{name}", + variables=variables, + **base, # type: ignore[arg-type] + ) + ) + return themes + + +def _vars_for(name: str) -> dict[str, str]: + """Get the CSS variable dict for a theme by short name.""" + return _THEME_VARIABLES.get(name, _THEME_VARIABLES[DEFAULT_THEME]) + + +# --------------------------------------------------------------------------- +# Active theme tracking + Python-level constants +# --------------------------------------------------------------------------- + +active_theme: str = DEFAULT_THEME + + +def set_theme(name: str) -> None: + """Switch the active theme and update all module-level color constants. + + This updates the Python constants used in Rich markup (e.g. ``theme.AGENT``). + For Textual CSS variables, also call ``app.theme = f"dimos-{name}"``. + """ + global active_theme + if name not in _THEME_VARIABLES: + return + active_theme = name + v = _THEME_VARIABLES[name] + _apply_vars(v) + + +def _apply_vars(v: dict[str, str]) -> None: + """Update module-level constants from a CSS-variable dict.""" + import dimos.utils.cli.theme as _self + + _self.BACKGROUND = v["dui-bg"] + _self.BG = v["dui-bg"] + _self.FOREGROUND = v["dui-fg"] + _self.ACCENT = v["dui-text"] + _self.DIM = v["dui-dim"] + _self.CYAN = v["dui-accent"] + _self.BORDER = v["dui-border"] + _self.YELLOW = v["dui-yellow"] + _self.RED = v["dui-red"] + _self.GREEN = v["dui-green"] + _self.BLUE = v["dui-blue"] + _self.PURPLE = v.get("dui-purple", v["dui-accent"]) + _self.AGENT = v["dui-agent"] + _self.TOOL = v["dui-tool"] + _self.TOOL_RESULT = v["dui-tool-result"] + _self.HUMAN = v["dui-human"] + _self.TIMESTAMP = v["dui-timestamp"] + _self.SYSTEM = v["dui-red"] + _self.SUCCESS = v["dui-green"] + _self.ERROR = v["dui-red"] + _self.WARNING = v["dui-yellow"] + _self.INFO = v["dui-accent"] + _self.BLACK = v["dui-bg"] + _self.WHITE = v["dui-fg"] + _self.BRIGHT_BLACK = v["dui-dim"] + _self.BRIGHT_WHITE = v["dui-timestamp"] + _self.CURSOR = v["dui-accent"] + _self.BRIGHT_RED = v["dui-red"] + _self.BRIGHT_GREEN = v["dui-green"] + _self.BRIGHT_YELLOW = v.get("dui-yellow", "#f2ea8c") + _self.BRIGHT_BLUE = v.get("dui-blue", "#8cbdf2") + _self.BRIGHT_PURPLE = v.get("dui-purple", v["dui-accent"]) + _self.BRIGHT_CYAN = v["dui-accent"] + + +# --------------------------------------------------------------------------- +# Initial module-level constants (from dimos.tcss defaults) +# --------------------------------------------------------------------------- + # Base color palette BLACK = COLORS.get("black", "#0b0f0f") RED = COLORS.get("red", "#ff0000") From d7d69a00dfc2d8c126a6297c2e6488f0efcaf4a5 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 10:26:20 -0700 Subject: [PATCH 11/23] - --- dimos/utils/cli/dui/sub_apps/runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dui/sub_apps/runner.py index 958a9734dd..ed4be024cb 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dui/sub_apps/runner.py @@ -64,7 +64,7 @@ class StatusSubApp(SubApp): StatusSubApp .subapp-header { width: 100%; height: auto; - color: #ff8800; + color: $dui-header; padding: 1 2; text-style: bold; } From c66bae828c29e690ed9739549e0efb923a571a7b Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 10:44:15 -0700 Subject: [PATCH 12/23] remove all references to dui --- dimos/core/module_coordinator.py | 6 + dimos/core/resource_monitor/monitor.py | 15 +- dimos/robot/cli/dimos.py | 8 +- dimos/utils/cli/{dui => dio}/__init__.py | 0 dimos/utils/cli/{dui => dio}/app.py | 34 +- .../utils/cli/{dui => dio}/confirm_screen.py | 28 +- .../utils/cli/{dui/dui.tcss => dio/dio.tcss} | 38 +- dimos/utils/cli/{dui => dio}/sub_app.py | 8 +- dimos/utils/cli/dio/sub_apps/__init__.py | 29 ++ .../cli/{dui => dio}/sub_apps/agentspy.py | 6 +- .../utils/cli/{dui => dio}/sub_apps/config.py | 26 +- dimos/utils/cli/{dui => dio}/sub_apps/dtop.py | 14 +- .../cli/{dui => dio}/sub_apps/humancli.py | 10 +- .../cli/{dui => dio}/sub_apps/launcher.py | 28 +- .../utils/cli/{dui => dio}/sub_apps/lcmspy.py | 10 +- .../utils/cli/{dui => dio}/sub_apps/runner.py | 78 +-- dimos/utils/cli/dtop.py | 2 +- dimos/utils/cli/dui/sub_apps/__init__.py | 29 -- dimos/utils/cli/lcmspy/run_lcmspy.py | 10 +- dimos/utils/cli/theme.py | 453 +++++++++++------- dimos/utils/prompt.py | 6 +- pyproject.toml | 2 +- 22 files changed, 484 insertions(+), 356 deletions(-) rename dimos/utils/cli/{dui => dio}/__init__.py (100%) rename dimos/utils/cli/{dui => dio}/app.py (93%) rename dimos/utils/cli/{dui => dio}/confirm_screen.py (92%) rename dimos/utils/cli/{dui/dui.tcss => dio/dio.tcss} (59%) rename dimos/utils/cli/{dui => dio}/sub_app.py (93%) create mode 100644 dimos/utils/cli/dio/sub_apps/__init__.py rename dimos/utils/cli/{dui => dio}/sub_apps/agentspy.py (96%) rename dimos/utils/cli/{dui => dio}/sub_apps/config.py (95%) rename dimos/utils/cli/{dui => dio}/sub_apps/dtop.py (97%) rename dimos/utils/cli/{dui => dio}/sub_apps/humancli.py (98%) rename dimos/utils/cli/{dui => dio}/sub_apps/launcher.py (95%) rename dimos/utils/cli/{dui => dio}/sub_apps/lcmspy.py (95%) rename dimos/utils/cli/{dui => dio}/sub_apps/runner.py (95%) delete mode 100644 dimos/utils/cli/dui/sub_apps/__init__.py diff --git a/dimos/core/module_coordinator.py b/dimos/core/module_coordinator.py index 3a7961fcea..c9e9595cc2 100644 --- a/dimos/core/module_coordinator.py +++ b/dimos/core/module_coordinator.py @@ -100,6 +100,12 @@ def start(self) -> None: self._stats_monitor = StatsMonitor(self._client) self._stats_monitor.start() + def restart_daemon_threads(self) -> None: + """Re-start threads that were killed by os.fork() during daemonize.""" + if self._stats_monitor is not None: + logger.info("Restarting StatsMonitor after daemonize") + self._stats_monitor.start() + def stop(self) -> None: if self._stats_monitor is not None: self._stats_monitor.stop() diff --git a/dimos/core/resource_monitor/monitor.py b/dimos/core/resource_monitor/monitor.py index 49079ed98e..1fb3f7c101 100644 --- a/dimos/core/resource_monitor/monitor.py +++ b/dimos/core/resource_monitor/monitor.py @@ -80,10 +80,23 @@ def __init__( self._logger = LCMResourceLogger() def start(self) -> None: - """Start the monitoring daemon thread.""" + """Start the monitoring daemon thread. + + Safe to call again after ``os.fork()`` — re-creates the LCM + transport so the new process gets a fresh multicast socket. + """ # Prime cpu_percent so the first real reading isn't 0.0. collect_process_stats(self._coordinator_pid) + # Re-create the LCM logger so we get a fresh lcm.LCM instance. + # After os.fork() the inherited C LCM object and its handle-loop + # thread are dead; we must build a new one in this process. + from dimos.core.resource_monitor.logger import LCMResourceLogger + + if isinstance(self._logger, LCMResourceLogger): + logger.info("StatsMonitor.start: re-creating LCMResourceLogger (post-fork safe)") + self._logger = LCMResourceLogger() + self._stop.clear() self._thread = threading.Thread(target=self._loop, daemon=True) self._thread.start() diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index d6b498d6c9..75423d07e9 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -191,6 +191,10 @@ def run( daemonize(log_dir) + # os.fork() only copies the calling thread — restart any daemon + # threads (e.g. StatsMonitor) that were killed by the double-fork. + coordinator.restart_daemon_threads() + entry = RunEntry( run_id=run_id, pid=os.getpid(), @@ -492,11 +496,11 @@ def dio( debug: bool = typer.Option(False, "--debug", help="Show debug panel with key event log"), ) -> None: """Launch the DimOS Unified TUI.""" - from dimos.utils.cli.dui.app import main as dui_main + from dimos.utils.cli.dio.app import main as dio_main if debug: sys.argv.append("--debug") - dui_main() + dio_main() @main.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True}) diff --git a/dimos/utils/cli/dui/__init__.py b/dimos/utils/cli/dio/__init__.py similarity index 100% rename from dimos/utils/cli/dui/__init__.py rename to dimos/utils/cli/dio/__init__.py diff --git a/dimos/utils/cli/dui/app.py b/dimos/utils/cli/dio/app.py similarity index 93% rename from dimos/utils/cli/dui/app.py rename to dimos/utils/cli/dio/app.py index 185558de32..f6f65a044f 100644 --- a/dimos/utils/cli/dui/app.py +++ b/dimos/utils/cli/dio/app.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""DUI — DimOS Unified TUI.""" +"""DIO — DimOS Unified TUI.""" from __future__ import annotations @@ -28,13 +28,13 @@ from textual.widgets import RichLog, Static from dimos.utils.cli import theme -from dimos.utils.cli.dui.sub_apps import get_sub_apps +from dimos.utils.cli.dio.sub_apps import get_sub_apps if TYPE_CHECKING: from textual.events import Click, Key, Resize from textual.widget import Widget - from dimos.utils.cli.dui.sub_app import SubApp + from dimos.utils.cli.dio.sub_app import SubApp _DUAL_WIDTH = 240 # >= this width: 2 panels _TRIPLE_WIDTH = 320 # >= this width: 3 panels @@ -42,8 +42,8 @@ _QUIT_WINDOW = 1.5 # seconds to press again to confirm quit -class DUIApp(App[None]): - CSS_PATH = "dui.tcss" +class DIOApp(App[None]): + CSS_PATH = "dio.tcss" BINDINGS = [ Binding("alt+up", "tab_prev", "Tab prev", priority=True), @@ -223,9 +223,9 @@ def on_key(self, event: Key) -> None: focused_id = getattr(focused, "id", None) or "" panel = self._panel_for_widget(focused) self._log( - f"[#b5e4f4]KEY[/#b5e4f4] [bold #00eeee]{event.key!r}[/bold #00eeee]" + f"[{theme.DEBUG_KEY}]KEY[/{theme.DEBUG_KEY}] [bold {theme.CYAN}]{event.key!r}[/bold {theme.CYAN}]" f" char={event.character!r}" - f" focused=[#5c9ff0]{focused_name}#{focused_id}[/#5c9ff0]" + f" focused=[{theme.DEBUG_FOCUS}]{focused_name}#{focused_id}[/{theme.DEBUG_FOCUS}]" f" _focused_panel={self._focused_panel} actual_panel={panel}" ) @@ -314,26 +314,26 @@ def _sync_hint(self) -> None: async def action_tab_prev(self) -> None: self._log( - f"[#ffcc00]ACTION[/#ffcc00] tab_prev panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" + f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] tab_prev panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" ) self._clear_quit_pending() await self._move_tab(-1) async def action_tab_next(self) -> None: self._log( - f"[#ffcc00]ACTION[/#ffcc00] tab_next panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" + f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] tab_next panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" ) self._clear_quit_pending() await self._move_tab(1) def action_focus_prev_panel(self) -> None: - self._log(f"[#ffcc00]ACTION[/#ffcc00] focus_prev_panel (was panel={self._focused_panel})") + self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_prev_panel (was panel={self._focused_panel})") self._clear_quit_pending() new = max(0, self._focused_panel - 1) self._focus_panel(new) def action_focus_next_panel(self) -> None: - self._log(f"[#ffcc00]ACTION[/#ffcc00] focus_next_panel (was panel={self._focused_panel})") + self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_next_panel (was panel={self._focused_panel})") self._clear_quit_pending() new = min(self._num_panels - 1, self._focused_panel + 1) self._focus_panel(new) @@ -344,13 +344,13 @@ def action_copy_text(self) -> None: if selected: self.copy_to_clipboard(selected) self.screen.clear_selection() - self._log("[#ffcc00]ACTION[/#ffcc00] copy_text (copied to clipboard)") + self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text (copied to clipboard)") else: - self._log("[#ffcc00]ACTION[/#ffcc00] copy_text -> no selection, treating as quit") + self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text -> no selection, treating as quit") self._handle_quit_press() def action_quit_or_esc(self) -> None: - self._log("[#ffcc00]ACTION[/#ffcc00] quit_or_esc") + self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] quit_or_esc") self._handle_quit_press() # ------------------------------------------------------------------ @@ -444,7 +444,7 @@ def _clear_quit_pending(self) -> None: _pending_confirms_lock = threading.Lock() def _handle_confirm(self, message: str, default: bool) -> bool | None: - from dimos.utils.cli.dui.confirm_screen import ConfirmScreen + from dimos.utils.cli.dio.confirm_screen import ConfirmScreen with self._pending_confirms_lock: if message in self._pending_confirms: @@ -476,7 +476,7 @@ def _on_result(value: bool) -> None: _pending_sudos_lock = threading.Lock() def _handle_sudo(self, message: str) -> bool | None: - from dimos.utils.cli.dui.confirm_screen import SudoScreen + from dimos.utils.cli.dio.confirm_screen import SudoScreen with self._pending_sudos_lock: if message in self._pending_sudos: @@ -510,7 +510,7 @@ def main() -> None: if debug: sys.argv.remove("--debug") - app = DUIApp(debug=debug) + app = DIOApp(debug=debug) set_dio_hook(app._handle_confirm) set_dio_sudo_hook(app._handle_sudo) diff --git a/dimos/utils/cli/dui/confirm_screen.py b/dimos/utils/cli/dio/confirm_screen.py similarity index 92% rename from dimos/utils/cli/dui/confirm_screen.py rename to dimos/utils/cli/dio/confirm_screen.py index be0636e5f5..af2c8d306d 100644 --- a/dimos/utils/cli/dui/confirm_screen.py +++ b/dimos/utils/cli/dio/confirm_screen.py @@ -24,15 +24,15 @@ class ConfirmScreen(ModalScreen[bool]): width: 60; height: auto; max-height: 20; - border: solid #00eeee; - background: #0b0f0f; + border: solid $dio-accent; + background: $dio-bg; padding: 1 2; } ConfirmScreen > Vertical > Label { width: 100%; content-align: center middle; - color: #b5e4f4; + color: $dio-text; margin-bottom: 1; } @@ -45,21 +45,21 @@ class ConfirmScreen(ModalScreen[bool]): margin: 0 2; min-width: 14; background: transparent; - color: #404040; + color: $dio-dim; border: none; } ConfirmScreen Button:focus { - background: #00eeee; - color: #0b0f0f; - border: solid #00eeee; + background: $dio-accent; + color: $dio-bg; + border: solid $dio-accent; text-style: bold; } ConfirmScreen Button:hover { - background: #00cccc; - color: #0b0f0f; - border: solid #00eeee; + background: $dio-accent; + color: $dio-bg; + border: solid $dio-accent; } """ @@ -128,20 +128,20 @@ class SudoScreen(ModalScreen[bool]): width: 50; height: auto; max-height: 14; - border: solid #ffcc00; - background: #0b0f0f; + border: solid $dio-yellow; + background: $dio-bg; padding: 1 2; } SudoScreen > Vertical > Label { width: 100%; content-align: center middle; - color: #b5e4f4; + color: $dio-text; margin-bottom: 1; } SudoScreen > Vertical > #sudo-error { - color: #ff0000; + color: $dio-red; width: 100%; content-align: center middle; margin-top: 1; diff --git a/dimos/utils/cli/dui/dui.tcss b/dimos/utils/cli/dio/dio.tcss similarity index 59% rename from dimos/utils/cli/dui/dui.tcss rename to dimos/utils/cli/dio/dio.tcss index 1c8a2c5d9e..3cddd633a7 100644 --- a/dimos/utils/cli/dui/dui.tcss +++ b/dimos/utils/cli/dio/dio.tcss @@ -1,14 +1,14 @@ -/* DUI — DimOS Unified TUI */ +/* DIO — DimOS Unified TUI */ Screen { layout: horizontal; - background: $dui-bg; + background: $dio-bg; } #sidebar { width: 22; height: 1fr; - background: $dui-bg; + background: $dio-bg; padding: 1 0; } @@ -17,23 +17,23 @@ Screen { height: 3; padding: 1 2; content-align: left middle; - background: $dui-bg; - color: $dui-text; + background: $dio-bg; + color: $dio-text; } .tab-item.--selected-1 { - background: $dui-tab1-bg; - color: $dui-tab1; + background: $dio-tab1-bg; + color: $dio-tab1; } .tab-item.--selected-2 { - background: $dui-tab2-bg; - color: $dui-tab2; + background: $dio-tab2-bg; + color: $dio-tab2; } .tab-item.--selected-3 { - background: $dui-tab3-bg; - color: $dui-tab3; + background: $dio-tab3-bg; + color: $dio-tab3; } #displays { @@ -45,12 +45,12 @@ Screen { .display-pane { width: 1fr; height: 1fr; - border: solid $dui-dim; - background: $dui-bg; + border: solid $dio-dim; + background: $dio-bg; } .display-pane.--focused { - border: solid $dui-accent; + border: solid $dio-accent; } #display-2 { @@ -65,9 +65,9 @@ Screen { width: 50; height: 1fr; dock: right; - border-left: solid $dui-dim; - background: $dui-bg; - color: $dui-text; + border-left: solid $dio-dim; + background: $dio-bg; + color: $dio-text; padding: 0 1; scrollbar-size: 1 1; } @@ -76,8 +76,8 @@ Screen { dock: top; height: 1; width: 100%; - background: $dui-hint-bg; - color: $dui-dim; + background: $dio-hint-bg; + color: $dio-dim; padding: 0 1; content-align: left middle; } diff --git a/dimos/utils/cli/dui/sub_app.py b/dimos/utils/cli/dio/sub_app.py similarity index 93% rename from dimos/utils/cli/dui/sub_app.py rename to dimos/utils/cli/dio/sub_app.py index c638105ad4..b065f55a7b 100644 --- a/dimos/utils/cli/dui/sub_app.py +++ b/dimos/utils/cli/dio/sub_app.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""SubApp base class for DUI sub-applications.""" +"""SubApp base class for DIO sub-applications.""" from __future__ import annotations @@ -20,7 +20,7 @@ class SubApp(Widget): - """Base class for DUI sub-applications. + """Base class for DIO sub-applications. Each sub-app is a Widget that renders inside a display pane. Subclasses must set TITLE and implement compose(). @@ -32,7 +32,7 @@ class SubApp(Widget): - on_resume_subapp() is called on every subsequent remount (e.g. when the widget is moved between display panels). Use this to restart timers killed by remove(). - - on_unmount_subapp() is called when the DUI app is shutting down, + - on_unmount_subapp() is called when the DIO app is shutting down, NOT on every tab switch. """ @@ -97,7 +97,7 @@ def on_resume_subapp(self) -> None: """ def on_unmount_subapp(self) -> None: - """Called when the DUI app tears down this sub-app. + """Called when the DIO app tears down this sub-app. Override to stop LCM subscriptions, timers, etc. """ diff --git a/dimos/utils/cli/dio/sub_apps/__init__.py b/dimos/utils/cli/dio/sub_apps/__init__.py new file mode 100644 index 0000000000..5f9f836671 --- /dev/null +++ b/dimos/utils/cli/dio/sub_apps/__init__.py @@ -0,0 +1,29 @@ +"""Registry of available DIO sub-apps.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from dimos.utils.cli.dio.sub_app import SubApp + + +def get_sub_apps() -> list[type[SubApp]]: + """Return all available sub-app classes in display order.""" + # from dimos.utils.cli.dio.sub_apps.agentspy import AgentSpySubApp + from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp + from dimos.utils.cli.dio.sub_apps.dtop import DtopSubApp + from dimos.utils.cli.dio.sub_apps.humancli import HumanCLISubApp + from dimos.utils.cli.dio.sub_apps.launcher import LauncherSubApp + from dimos.utils.cli.dio.sub_apps.lcmspy import LCMSpySubApp + from dimos.utils.cli.dio.sub_apps.runner import StatusSubApp + + return [ + LauncherSubApp, + StatusSubApp, + ConfigSubApp, + DtopSubApp, + LCMSpySubApp, + HumanCLISubApp, + AgentSpySubApp, + ] diff --git a/dimos/utils/cli/dui/sub_apps/agentspy.py b/dimos/utils/cli/dio/sub_apps/agentspy.py similarity index 96% rename from dimos/utils/cli/dui/sub_apps/agentspy.py rename to dimos/utils/cli/dio/sub_apps/agentspy.py index 28d240ab30..49bb76f27e 100644 --- a/dimos/utils/cli/dui/sub_apps/agentspy.py +++ b/dimos/utils/cli/dio/sub_apps/agentspy.py @@ -8,7 +8,7 @@ from textual.widgets import RichLog from dimos.utils.cli import theme -from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dio.sub_app import SubApp class AgentSpySubApp(SubApp): @@ -17,12 +17,12 @@ class AgentSpySubApp(SubApp): DEFAULT_CSS = """ AgentSpySubApp { layout: vertical; - background: $dui-bg; + background: $dio-bg; } AgentSpySubApp RichLog { height: 1fr; border: none; - background: $dui-bg; + background: $dio-bg; padding: 0 1; } """ diff --git a/dimos/utils/cli/dui/sub_apps/config.py b/dimos/utils/cli/dio/sub_apps/config.py similarity index 95% rename from dimos/utils/cli/dui/sub_apps/config.py rename to dimos/utils/cli/dio/sub_apps/config.py index 257e4247fa..7e0443266c 100644 --- a/dimos/utils/cli/dui/sub_apps/config.py +++ b/dimos/utils/cli/dio/sub_apps/config.py @@ -29,7 +29,7 @@ from textual.widgets import Input, Label, Static, Switch from dimos.utils.cli import theme -from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: from textual.app import ComposeResult @@ -153,20 +153,20 @@ class ConfigSubApp(SubApp): ConfigSubApp { layout: vertical; padding: 1 2; - background: $dui-bg; + background: $dio-bg; overflow-y: auto; } ConfigSubApp .subapp-header { - color: $dui-header; + color: $dio-accent2; padding: 0; text-style: bold; } ConfigSubApp Label { margin-top: 1; - color: $dui-text; + color: $dio-text; } ConfigSubApp .field-label { - color: $dui-accent; + color: $dio-accent; margin-bottom: 0; } ConfigSubApp Input, ConfigSubApp ConfigInput { @@ -175,14 +175,14 @@ class ConfigSubApp(SubApp): ConfigSubApp CycleSelect { width: 40; height: 3; - background: $dui-bg; - color: $dui-text; - border: solid $dui-dim; + background: $dio-bg; + color: $dio-text; + border: solid $dio-dim; content-align: left middle; } ConfigSubApp CycleSelect:focus { - border: solid $dui-accent; - color: $dui-accent; + border: solid $dio-accent; + color: $dio-accent; } ConfigSubApp .switch-row { height: 3; @@ -193,16 +193,16 @@ class ConfigSubApp(SubApp): padding: 1 0; } ConfigSubApp .switch-state { - color: $dui-dim; + color: $dio-dim; padding: 1 1; width: 6; } ConfigSubApp .switch-state.--on { - color: $dui-accent; + color: $dio-accent; } ConfigSubApp #cfg-dirty-notice { margin-top: 1; - color: $dui-yellow; + color: $dio-yellow; display: none; } """ diff --git a/dimos/utils/cli/dui/sub_apps/dtop.py b/dimos/utils/cli/dio/sub_apps/dtop.py similarity index 97% rename from dimos/utils/cli/dui/sub_apps/dtop.py rename to dimos/utils/cli/dio/sub_apps/dtop.py index 361023e972..efebbffc79 100644 --- a/dimos/utils/cli/dui/sub_apps/dtop.py +++ b/dimos/utils/cli/dio/sub_apps/dtop.py @@ -35,7 +35,7 @@ ResourceSpyApp, _compute_ranges, ) -from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: from textual.app import ComposeResult @@ -48,7 +48,7 @@ class DtopSubApp(SubApp): DtopSubApp { layout: vertical; height: 1fr; - background: $dui-bg; + background: $dio-bg; } DtopSubApp VerticalScroll { height: 1fr; @@ -78,7 +78,7 @@ def __init__(self) -> None: def _debug(self, msg: str) -> None: try: - self.app._log(f"[#8899aa]DTOP:[/#8899aa] {msg}") # type: ignore[attr-defined] + self.app._log(f"[{theme.BTN_MUTED}]DTOP:[/{theme.BTN_MUTED}] {msg}") # type: ignore[attr-defined] except Exception: pass @@ -278,8 +278,8 @@ def _refresh(self) -> None: self._reconnecting = True self._debug(f"No data for {now - last_msg:.0f}s, reconnecting LCM...") self.run_worker(self._reconnect_lcm, exclusive=True, thread=True) - dim = "#606060" - border_style = dim if stale else "#777777" + dim = theme.STALE_COLOR + border_style = dim if stale else theme.PID_COLOR entries: list[tuple[str, str, dict[str, Any], str, str]] = [] coord = data.get("coordinator", {}) @@ -307,7 +307,7 @@ def _refresh(self) -> None: title.append(": ", style=dim if stale else _LABEL_COLOR) title.append(mods, style=dim if stale else rs) if pid: - title.append(f" [{pid}]", style=dim if stale else "#777777") + title.append(f" [{pid}]", style=dim if stale else theme.PID_COLOR) title.append(" ") parts.append(Rule(title=title, style=border_style)) parts.extend(ResourceSpyApp._make_lines(d, stale, ranges, self._cpu_history[role])) @@ -319,7 +319,7 @@ def _refresh(self) -> None: panel_title.append(": ", style=dim if stale else _LABEL_COLOR) panel_title.append(first_mods, style=dim if stale else first_rs) if first_pid: - panel_title.append(f" [{first_pid}]", style=dim if stale else "#777777") + panel_title.append(f" [{first_pid}]", style=dim if stale else theme.PID_COLOR) panel_title.append(" ") panel = Panel(Group(*parts), title=panel_title, border_style=border_style) diff --git a/dimos/utils/cli/dui/sub_apps/humancli.py b/dimos/utils/cli/dio/sub_apps/humancli.py similarity index 98% rename from dimos/utils/cli/dui/sub_apps/humancli.py rename to dimos/utils/cli/dio/sub_apps/humancli.py index 4f98939759..f7ae4f68ff 100644 --- a/dimos/utils/cli/dui/sub_apps/humancli.py +++ b/dimos/utils/cli/dio/sub_apps/humancli.py @@ -29,7 +29,7 @@ from textual.widgets import Input, RichLog, Static from dimos.utils.cli import theme -from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: from textual.app import ComposeResult @@ -128,14 +128,14 @@ class HumanCLISubApp(SubApp): DEFAULT_CSS = """ HumanCLISubApp { layout: vertical; - background: $dui-bg; + background: $dio-bg; } HumanCLISubApp #hcli-status-bar { height: 1; dock: top; padding: 0 1; - background: $dui-bg; - color: $dui-dim; + background: $dio-bg; + color: $dio-dim; } HumanCLISubApp #hcli-chat { height: 1fr; @@ -143,7 +143,7 @@ class HumanCLISubApp(SubApp): HumanCLISubApp RichLog { height: 1fr; scrollbar-size: 0 0; - border: solid $dui-dim; + border: solid $dio-dim; } HumanCLISubApp Input { dock: bottom; diff --git a/dimos/utils/cli/dui/sub_apps/launcher.py b/dimos/utils/cli/dio/sub_apps/launcher.py similarity index 95% rename from dimos/utils/cli/dui/sub_apps/launcher.py rename to dimos/utils/cli/dio/sub_apps/launcher.py index 3f1dee95a3..6dfbae2e94 100644 --- a/dimos/utils/cli/dui/sub_apps/launcher.py +++ b/dimos/utils/cli/dio/sub_apps/launcher.py @@ -26,7 +26,7 @@ from textual.widgets import Input, Label, ListItem, ListView, Static from dimos.utils.cli import theme -from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: from textual.app import ComposeResult @@ -82,35 +82,35 @@ class LauncherSubApp(SubApp): LauncherSubApp { layout: vertical; height: 1fr; - background: $dui-bg; + background: $dio-bg; } LauncherSubApp .subapp-header { width: 100%; height: auto; - color: $dui-header; + color: $dio-accent2; padding: 1 2; text-style: bold; } LauncherSubApp #launch-filter { width: 100%; - background: $dui-bg; - border: solid $dui-dim; - color: $dui-text; + background: $dio-bg; + border: solid $dio-dim; + color: $dio-text; } LauncherSubApp #launch-filter:focus { - border: solid $dui-accent; + border: solid $dio-accent; } LauncherSubApp ListView { height: 1fr; - background: $dui-bg; + background: $dio-bg; } LauncherSubApp ListView > ListItem { - background: $dui-bg; - color: $dui-text; + background: $dio-bg; + color: $dio-text; padding: 1 2; } LauncherSubApp ListView > ListItem.--highlight { - background: $dui-panel-bg; + background: $dio-panel-bg; } LauncherSubApp.--locked ListView { opacity: 0.35; @@ -121,8 +121,8 @@ class LauncherSubApp(SubApp): LauncherSubApp .status-bar { height: 1; dock: bottom; - background: $dui-hint-bg; - color: $dui-dim; + background: $dio-hint-bg; + color: $dio-dim; padding: 0 1; } """ @@ -266,7 +266,7 @@ def _launch(self, name: str) -> None: # Gather config overrides config_args: list[str] = [] try: - from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp + from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp for inst in self.app._instances: # type: ignore[attr-defined] if isinstance(inst, ConfigSubApp): diff --git a/dimos/utils/cli/dui/sub_apps/lcmspy.py b/dimos/utils/cli/dio/sub_apps/lcmspy.py similarity index 95% rename from dimos/utils/cli/dui/sub_apps/lcmspy.py rename to dimos/utils/cli/dio/sub_apps/lcmspy.py index 8502886857..873e2eb2a6 100644 --- a/dimos/utils/cli/dui/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dio/sub_apps/lcmspy.py @@ -22,7 +22,7 @@ from textual.widgets import DataTable from dimos.utils.cli import theme -from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: from textual.app import ComposeResult @@ -34,17 +34,17 @@ class LCMSpySubApp(SubApp): DEFAULT_CSS = """ LCMSpySubApp { layout: vertical; - background: $dui-bg; + background: $dio-bg; } LCMSpySubApp DataTable { height: 1fr; width: 1fr; - border: solid $dui-dim; - background: $dui-bg; + border: solid $dio-dim; + background: $dio-bg; scrollbar-size: 0 0; } LCMSpySubApp DataTable > .datatable--header { - color: $dui-text; + color: $dio-text; background: transparent; } """ diff --git a/dimos/utils/cli/dui/sub_apps/runner.py b/dimos/utils/cli/dio/sub_apps/runner.py similarity index 95% rename from dimos/utils/cli/dui/sub_apps/runner.py rename to dimos/utils/cli/dio/sub_apps/runner.py index ed4be024cb..d797bfdc9e 100644 --- a/dimos/utils/cli/dui/sub_apps/runner.py +++ b/dimos/utils/cli/dio/sub_apps/runner.py @@ -30,7 +30,7 @@ from textual.widgets import Button, RichLog, Static from dimos.utils.cli import theme -from dimos.utils.cli.dui.sub_app import SubApp +from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: from textual.app import ComposeResult @@ -59,19 +59,19 @@ class StatusSubApp(SubApp): StatusSubApp { layout: vertical; height: 1fr; - background: $dui-bg; + background: $dio-bg; } StatusSubApp .subapp-header { width: 100%; height: auto; - color: $dui-header; + color: $dio-accent2; padding: 1 2; text-style: bold; } StatusSubApp RichLog { height: 1fr; - background: $dui-bg; - border: solid $dui-dim; + background: $dio-bg; + border: solid $dio-dim; scrollbar-size-vertical: 0; scrollbar-size-horizontal: 1; } @@ -86,69 +86,69 @@ class StatusSubApp(SubApp): StatusSubApp #run-controls { height: auto; padding: 0 1; - background: $dui-bg; + background: $dio-bg; } StatusSubApp #run-controls Button { margin: 0 1 0 0; min-width: 12; background: transparent; - border: solid $dui-dim; - color: $dui-text; + border: solid $dio-dim; + color: $dio-text; } StatusSubApp .status-bar { height: 1; dock: bottom; - background: $dui-hint-bg; - color: $dui-dim; + background: $dio-hint-bg; + color: $dio-dim; padding: 0 1; } StatusSubApp #btn-stop { - border: solid #882222; - color: #cc4444; + border: solid $dio-btn-danger-bg; + color: $dio-btn-danger; } StatusSubApp #btn-stop:hover { - border: solid #cc4444; + border: solid $dio-btn-danger; } StatusSubApp #btn-stop:focus { - background: #882222; - color: #ffffff; - border: solid #cc4444; + background: $dio-btn-danger-bg; + color: $dio-white; + border: solid $dio-btn-danger; } StatusSubApp #btn-sudo-kill { - border: solid #882222; - color: #ff4444; + border: solid $dio-btn-kill-bg; + color: $dio-btn-kill; } StatusSubApp #btn-sudo-kill:hover { - border: solid #ff4444; + border: solid $dio-btn-kill; } StatusSubApp #btn-sudo-kill:focus { - background: #882222; - color: #ffffff; - border: solid #ff4444; + background: $dio-btn-kill-bg; + color: $dio-white; + border: solid $dio-btn-kill; } StatusSubApp #btn-restart { - border: solid #886600; - color: #ccaa00; + border: solid $dio-btn-warn-bg; + color: $dio-btn-warn; } StatusSubApp #btn-restart:hover { - border: solid #ccaa00; + border: solid $dio-btn-warn; } StatusSubApp #btn-restart:focus { - background: #886600; - color: #ffffff; - border: solid #ccaa00; + background: $dio-btn-warn-bg; + color: $dio-white; + border: solid $dio-btn-warn; } StatusSubApp #btn-open-log { - border: solid #445566; - color: #8899aa; + border: solid $dio-btn-muted-bg; + color: $dio-btn-muted; } StatusSubApp #btn-open-log:hover { - border: solid #8899aa; + border: solid $dio-btn-muted; } StatusSubApp #btn-open-log:focus { - background: #445566; - color: #ffffff; - border: solid #8899aa; + background: $dio-btn-muted-bg; + color: $dio-white; + border: solid $dio-btn-muted; } """ @@ -166,9 +166,9 @@ def __init__(self) -> None: self._saved_status: str = "" def _debug(self, msg: str) -> None: - """Log to the DUI debug panel if available.""" + """Log to the DIO debug panel if available.""" try: - self.app._log(f"[#8899aa]STATUS:[/#8899aa] {msg}") # type: ignore[attr-defined] + self.app._log(f"[{theme.BTN_MUTED}]STATUS:[/{theme.BTN_MUTED}] {msg}") # type: ignore[attr-defined] except Exception: pass @@ -186,7 +186,7 @@ def compose(self) -> ComposeResult: def _idle_panel(self) -> Panel: msg = Text(justify="center") - msg.append("No Blueprint Running\n\n", style="bold #cc4444") + msg.append("No Blueprint Running\n\n", style=f"bold {theme.BTN_DANGER}") msg.append("Use the ", style=theme.DIM) msg.append("launch", style=f"bold {theme.CYAN}") msg.append(" tab to start a blueprint", style=theme.DIM) @@ -804,7 +804,7 @@ def _do_restart() -> None: config_args: list[str] = [] try: - from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp + from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp for inst in self.app._instances: # type: ignore[attr-defined] if isinstance(inst, ConfigSubApp): @@ -859,7 +859,7 @@ def _do_restart() -> None: # Copy plain log into the run's log directory for archival try: - from dimos.utils.cli.dui.sub_apps.launcher import _copy_plain_log_to_run_dir + from dimos.utils.cli.dio.sub_apps.launcher import _copy_plain_log_to_run_dir _copy_plain_log_to_run_dir(plain_file) except Exception: diff --git a/dimos/utils/cli/dtop.py b/dimos/utils/cli/dtop.py index fa463c15d6..ddc3e71909 100644 --- a/dimos/utils/cli/dtop.py +++ b/dimos/utils/cli/dtop.py @@ -67,7 +67,7 @@ def _bar(value: float, max_val: float, width: int = 12) -> Text: _LDOTS = (0x00, 0x40, 0x44, 0x46, 0x47) # left col: 0‥4 filled rows _RDOTS = (0x00, 0x80, 0xA0, 0xB0, 0xB8) # right col: 0‥4 filled rows _SPARK_WIDTH = 12 # characters (×2 = 24 samples of history) -_LABEL_COLOR = "#cccccc" # metric label color (CPU, PSS, Thr, etc.) +_LABEL_COLOR = theme.LABEL_COLOR # metric label color (CPU, PSS, Thr, etc.) def _spark(history: deque[float], width: int = _SPARK_WIDTH) -> Text: diff --git a/dimos/utils/cli/dui/sub_apps/__init__.py b/dimos/utils/cli/dui/sub_apps/__init__.py deleted file mode 100644 index d12c17cf30..0000000000 --- a/dimos/utils/cli/dui/sub_apps/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Registry of available DUI sub-apps.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from dimos.utils.cli.dui.sub_app import SubApp - - -def get_sub_apps() -> list[type[SubApp]]: - """Return all available sub-app classes in display order.""" - # from dimos.utils.cli.dui.sub_apps.agentspy import AgentSpySubApp - from dimos.utils.cli.dui.sub_apps.config import ConfigSubApp - from dimos.utils.cli.dui.sub_apps.dtop import DtopSubApp - from dimos.utils.cli.dui.sub_apps.humancli import HumanCLISubApp - from dimos.utils.cli.dui.sub_apps.launcher import LauncherSubApp - from dimos.utils.cli.dui.sub_apps.lcmspy import LCMSpySubApp - from dimos.utils.cli.dui.sub_apps.runner import StatusSubApp - - return [ - LauncherSubApp, - StatusSubApp, - ConfigSubApp, - DtopSubApp, - LCMSpySubApp, - HumanCLISubApp, - # AgentSpySubApp, - ] diff --git a/dimos/utils/cli/lcmspy/run_lcmspy.py b/dimos/utils/cli/lcmspy/run_lcmspy.py index 438d93fa1f..42e6644733 100644 --- a/dimos/utils/cli/lcmspy/run_lcmspy.py +++ b/dimos/utils/cli/lcmspy/run_lcmspy.py @@ -21,15 +21,15 @@ from dimos.utils.cli import theme from dimos.utils.cli.lcmspy.lcmspy import GraphLCMSpy, GraphTopic as SpyTopic +from dimos.utils.cli.theme import _vars_for def gradient(max_value: float, value: float) -> str: - """Gradient from cyan (low) to yellow (high) using DimOS theme colors""" + """Gradient from low (cool) to high (warm) using DimOS theme bandwidth colors.""" ratio = min(value / max_value, 1.0) - # Parse hex colors from theme - cyan = Color.parse(theme.CYAN) - yellow = Color.parse(theme.YELLOW) - color = cyan.blend(yellow, ratio) + low = Color.parse(_vars_for(theme.active_theme).get("dio-bw-low", theme.CYAN)) + high = Color.parse(_vars_for(theme.active_theme).get("dio-bw-high", theme.YELLOW)) + color = low.blend(high, ratio) return color.hex diff --git a/dimos/utils/cli/theme.py b/dimos/utils/cli/theme.py index 76b4a3252b..452739a7e7 100644 --- a/dimos/utils/cli/theme.py +++ b/dimos/utils/cli/theme.py @@ -14,16 +14,16 @@ """DimOS theme system. -Provides named themes for the DUI TUI. Each theme defines: +Provides named themes for the DIO TUI. Each theme defines: - Textual Theme fields (primary, background, etc.) for built-in widgets - - Custom CSS variables (prefixed ``dui-``) for DimOS-specific styling + - Custom CSS variables (prefixed ``dio-``) for DimOS-specific styling - Python-level constants (ACCENT, DIM, AGENT, …) for Rich markup Usage in CSS:: - background: $dui-bg; - border: solid $dui-dim; - color: $dui-text; + background: $dio-bg; + border: solid $dio-dim; + color: $dio-text; Usage in Python (Rich markup):: @@ -49,7 +49,7 @@ def parse_tcss_colors(tcss_path: str | Path) -> dict[str, str]: _THEME_PATH = Path(__file__).parent / "dimos.tcss" COLORS = parse_tcss_colors(_THEME_PATH) -# Export CSS path for standalone Textual apps (not DUI) +# Export CSS path for standalone Textual apps (not DIO) CSS_PATH = str(_THEME_PATH) @@ -65,126 +65,191 @@ def get(name: str, default: str = "#ffffff") -> str: # Each entry maps custom CSS variable names (without ``$``) to hex values. # These are injected into Textual's CSS variable system via Theme.variables. -# The keys here become ``$dui-bg``, ``$dui-dim``, etc. in CSS. +# The keys here become ``$dio-bg``, ``$dio-dim``, etc. in CSS. +# +# Variable naming: +# Core: dio-bg, dio-fg, dio-text, dio-dim, dio-accent, dio-accent2 +# Palette: dio-red, dio-orange, dio-yellow, dio-green, dio-blue, dio-purple, dio-cyan, dio-white, dio-grey +# Chat: dio-agent, dio-tool, dio-tool-result, dio-human, dio-timestamp +# Buttons: dio-btn-danger, dio-btn-danger-bg, dio-btn-warn, dio-btn-warn-bg, +# dio-btn-muted, dio-btn-muted-bg, dio-btn-kill, dio-btn-kill-bg +# Chrome: dio-panel-bg, dio-hint-bg +# Tabs: dio-tab1, dio-tab1-bg, dio-tab2, dio-tab2-bg, dio-tab3, dio-tab3-bg +# LCMSpy: dio-bw-low, dio-bw-high +# Dtop: dio-label, dio-stale, dio-pid +# Debug: dio-debug-key, dio-debug-action, dio-debug-focus _THEME_VARIABLES: dict[str, dict[str, str]] = { - "dark": { + "dark-one": { # Core - "dui-bg": "#0b0f0f", - "dui-fg": "#b5e4f4", - "dui-text": "#b5e4f4", - "dui-dim": "#404040", - "dui-accent": "#00eeee", - "dui-border": "#00eeee", - # Base palette - "dui-yellow": "#ffcc00", - "dui-red": "#ff0000", - "dui-green": "#00eeee", - "dui-blue": "#5c9ff0", - "dui-purple": "#c07ff0", + "dio-bg": "#0b0f0f", + "dio-fg": "#b5e4f4", + "dio-text": "#b5e4f4", + "dio-dim": "#404040", + "dio-accent": "#00eeee", + "dio-accent2": "#ff8800", + # Named palette + "dio-red": "#ff0000", + "dio-orange": "#ff8800", + "dio-yellow": "#ffcc00", + "dio-green": "#00ee88", + "dio-blue": "#5c9ff0", + "dio-purple": "#c07ff0", + "dio-cyan": "#00eeee", + "dio-white": "#ffffff", + "dio-grey": "#777777", # Chat message colors - "dui-agent": "#88ff88", - "dui-tool": "#00eeee", - "dui-tool-result": "#ffff00", - "dui-human": "#ffffff", - "dui-timestamp": "#ffffff", + "dio-agent": "#88ff88", + "dio-tool": "#00eeee", + "dio-tool-result": "#ffff00", + "dio-human": "#ffffff", + "dio-timestamp": "#ffffff", + # Buttons + "dio-btn-danger": "#cc4444", + "dio-btn-danger-bg": "#882222", + "dio-btn-warn": "#ccaa00", + "dio-btn-warn-bg": "#886600", + "dio-btn-muted": "#8899aa", + "dio-btn-muted-bg": "#445566", + "dio-btn-kill": "#ff4444", + "dio-btn-kill-bg": "#882222", # UI chrome - "dui-header": "#ff8800", - "dui-panel-bg": "#1a2a2a", - "dui-hint-bg": "#1a2020", - "dui-tab1": "#00eeee", - "dui-tab1-bg": "#1a2a2a", - "dui-tab2": "#5c9ff0", - "dui-tab2-bg": "#1a1a2a", - "dui-tab3": "#c07ff0", - "dui-tab3-bg": "#2a1a2a", - }, - "midnight": { - "dui-bg": "#0a0e1a", - "dui-fg": "#a0b8d0", - "dui-text": "#a0b8d0", - "dui-dim": "#303850", - "dui-accent": "#4488cc", - "dui-border": "#4488cc", - "dui-yellow": "#ccaa44", - "dui-red": "#cc4444", - "dui-green": "#44aa88", - "dui-blue": "#5588dd", - "dui-purple": "#8866cc", - "dui-agent": "#66cc88", - "dui-tool": "#4488cc", - "dui-tool-result": "#ccaa44", - "dui-human": "#d0d8e0", - "dui-timestamp": "#8899bb", - "dui-header": "#dd8833", - "dui-panel-bg": "#151c2e", - "dui-hint-bg": "#101828", - "dui-tab1": "#4488cc", - "dui-tab1-bg": "#151c2e", - "dui-tab2": "#5588dd", - "dui-tab2-bg": "#14183a", - "dui-tab3": "#8866cc", - "dui-tab3-bg": "#1c1430", + "dio-panel-bg": "#1a2a2a", + "dio-hint-bg": "#1a2020", + # Tabs + "dio-tab1": "#00eeee", + "dio-tab1-bg": "#1a2a2a", + "dio-tab2": "#5c9ff0", + "dio-tab2-bg": "#1a1a2a", + "dio-tab3": "#c07ff0", + "dio-tab3-bg": "#2a1a2a", + # LCMSpy bandwidth gradient + "dio-bw-low": "#00eeee", + "dio-bw-high": "#ffcc00", + # Dtop + "dio-label": "#cccccc", + "dio-stale": "#606060", + "dio-pid": "#777777", + # Debug + "dio-debug-key": "#b5e4f4", + "dio-debug-action": "#ffcc00", + "dio-debug-focus": "#5c9ff0", }, - "ember": { - "dui-bg": "#120c0a", - "dui-fg": "#e0c8b0", - "dui-text": "#e0c8b0", - "dui-dim": "#4a3028", - "dui-accent": "#ee8844", - "dui-border": "#ee8844", - "dui-yellow": "#ddaa33", - "dui-red": "#dd4433", - "dui-green": "#88aa44", - "dui-blue": "#cc8844", - "dui-purple": "#cc6688", - "dui-agent": "#aacc66", - "dui-tool": "#ee8844", - "dui-tool-result": "#ddaa33", - "dui-human": "#e8d8c8", - "dui-timestamp": "#aa9080", - "dui-header": "#ff8844", - "dui-panel-bg": "#2a1810", - "dui-hint-bg": "#1a1210", - "dui-tab1": "#ee8844", - "dui-tab1-bg": "#2a1810", - "dui-tab2": "#cc8844", - "dui-tab2-bg": "#2a2010", - "dui-tab3": "#cc6688", - "dui-tab3-bg": "#2a1420", + "dark-two": { + # Core + "dio-bg": "#0a0e1a", + "dio-fg": "#a0b8d0", + "dio-text": "#a0b8d0", + "dio-dim": "#303850", + "dio-accent": "#4488cc", + "dio-accent2": "#dd8833", + # Named palette + "dio-red": "#cc4444", + "dio-orange": "#dd8833", + "dio-yellow": "#ccaa44", + "dio-green": "#44aa88", + "dio-blue": "#5588dd", + "dio-purple": "#8866cc", + "dio-cyan": "#4488cc", + "dio-white": "#d0d8e0", + "dio-grey": "#667788", + # Chat message colors + "dio-agent": "#66cc88", + "dio-tool": "#4488cc", + "dio-tool-result": "#ccaa44", + "dio-human": "#d0d8e0", + "dio-timestamp": "#8899bb", + # Buttons + "dio-btn-danger": "#bb5555", + "dio-btn-danger-bg": "#662233", + "dio-btn-warn": "#bbaa44", + "dio-btn-warn-bg": "#665522", + "dio-btn-muted": "#7788aa", + "dio-btn-muted-bg": "#334455", + "dio-btn-kill": "#dd5555", + "dio-btn-kill-bg": "#662233", + # UI chrome + "dio-panel-bg": "#151c2e", + "dio-hint-bg": "#101828", + # Tabs + "dio-tab1": "#4488cc", + "dio-tab1-bg": "#151c2e", + "dio-tab2": "#5588dd", + "dio-tab2-bg": "#14183a", + "dio-tab3": "#8866cc", + "dio-tab3-bg": "#1c1430", + # LCMSpy bandwidth gradient + "dio-bw-low": "#4488cc", + "dio-bw-high": "#ccaa44", + # Dtop + "dio-label": "#aabbcc", + "dio-stale": "#404860", + "dio-pid": "#667788", + # Debug + "dio-debug-key": "#a0b8d0", + "dio-debug-action": "#ccaa44", + "dio-debug-focus": "#5588dd", }, - "forest": { - "dui-bg": "#0a100c", - "dui-fg": "#b0d0b8", - "dui-text": "#b0d0b8", - "dui-dim": "#2a3a2e", - "dui-accent": "#44cc88", - "dui-border": "#44cc88", - "dui-yellow": "#aacc44", - "dui-red": "#cc4444", - "dui-green": "#44cc88", - "dui-blue": "#44aa99", - "dui-purple": "#88aa66", - "dui-agent": "#66dd88", - "dui-tool": "#44cc88", - "dui-tool-result": "#aacc44", - "dui-human": "#d0e0d0", - "dui-timestamp": "#80aa88", - "dui-header": "#88cc44", - "dui-panel-bg": "#142a1a", - "dui-hint-bg": "#101a14", - "dui-tab1": "#44cc88", - "dui-tab1-bg": "#142a1a", - "dui-tab2": "#44aa99", - "dui-tab2-bg": "#142a26", - "dui-tab3": "#88aa66", - "dui-tab3-bg": "#1e2a14", + "light": { + # Core + "dio-bg": "#f0f2f5", + "dio-fg": "#1a1a2e", + "dio-text": "#1a1a2e", + "dio-dim": "#999999", + "dio-accent": "#0077aa", + "dio-accent2": "#cc6600", + # Named palette + "dio-red": "#cc2222", + "dio-orange": "#cc6600", + "dio-yellow": "#aa8800", + "dio-green": "#228844", + "dio-blue": "#2266cc", + "dio-purple": "#7744aa", + "dio-cyan": "#0077aa", + "dio-white": "#1a1a2e", + "dio-grey": "#666666", + # Chat message colors + "dio-agent": "#227744", + "dio-tool": "#0077aa", + "dio-tool-result": "#886600", + "dio-human": "#1a1a2e", + "dio-timestamp": "#555555", + # Buttons + "dio-btn-danger": "#cc2222", + "dio-btn-danger-bg": "#ffdddd", + "dio-btn-warn": "#aa7700", + "dio-btn-warn-bg": "#fff3dd", + "dio-btn-muted": "#555555", + "dio-btn-muted-bg": "#dddddd", + "dio-btn-kill": "#ee2222", + "dio-btn-kill-bg": "#ffdddd", + # UI chrome + "dio-panel-bg": "#e4e8ee", + "dio-hint-bg": "#dde0e6", + # Tabs + "dio-tab1": "#0077aa", + "dio-tab1-bg": "#ddeef8", + "dio-tab2": "#2266cc", + "dio-tab2-bg": "#dde0f8", + "dio-tab3": "#7744aa", + "dio-tab3-bg": "#eeddf8", + # LCMSpy bandwidth gradient + "dio-bw-low": "#0077aa", + "dio-bw-high": "#aa8800", + # Dtop + "dio-label": "#333333", + "dio-stale": "#aaaaaa", + "dio-pid": "#666666", + # Debug + "dio-debug-key": "#1a1a2e", + "dio-debug-action": "#aa8800", + "dio-debug-focus": "#2266cc", }, } # Textual Theme constructor args for each theme _THEME_BASES: dict[str, dict[str, object]] = { - "dark": { + "dark-one": { "primary": "#00eeee", "secondary": "#5c9ff0", "warning": "#ffcc00", @@ -197,7 +262,7 @@ def get(name: str, default: str = "#ffffff") -> str: "panel": "#1a2a2a", "dark": True, }, - "midnight": { + "dark-two": { "primary": "#4488cc", "secondary": "#5588dd", "warning": "#ccaa44", @@ -210,36 +275,23 @@ def get(name: str, default: str = "#ffffff") -> str: "panel": "#151c2e", "dark": True, }, - "ember": { - "primary": "#ee8844", - "secondary": "#cc8844", - "warning": "#ddaa33", - "error": "#dd4433", - "success": "#88aa44", - "accent": "#ee8844", - "foreground": "#e0c8b0", - "background": "#120c0a", - "surface": "#120c0a", - "panel": "#2a1810", - "dark": True, - }, - "forest": { - "primary": "#44cc88", - "secondary": "#44aa99", - "warning": "#aacc44", - "error": "#cc4444", - "success": "#44cc88", - "accent": "#44cc88", - "foreground": "#b0d0b8", - "background": "#0a100c", - "surface": "#0a100c", - "panel": "#142a1a", - "dark": True, + "light": { + "primary": "#0077aa", + "secondary": "#2266cc", + "warning": "#aa8800", + "error": "#cc2222", + "success": "#228844", + "accent": "#0077aa", + "foreground": "#1a1a2e", + "background": "#f0f2f5", + "surface": "#f0f2f5", + "panel": "#e4e8ee", + "dark": False, }, } THEME_NAMES: list[str] = list(_THEME_VARIABLES) -DEFAULT_THEME = "dark" +DEFAULT_THEME = "dark-one" def get_textual_themes() -> list[object]: @@ -290,39 +342,70 @@ def _apply_vars(v: dict[str, str]) -> None: """Update module-level constants from a CSS-variable dict.""" import dimos.utils.cli.theme as _self - _self.BACKGROUND = v["dui-bg"] - _self.BG = v["dui-bg"] - _self.FOREGROUND = v["dui-fg"] - _self.ACCENT = v["dui-text"] - _self.DIM = v["dui-dim"] - _self.CYAN = v["dui-accent"] - _self.BORDER = v["dui-border"] - _self.YELLOW = v["dui-yellow"] - _self.RED = v["dui-red"] - _self.GREEN = v["dui-green"] - _self.BLUE = v["dui-blue"] - _self.PURPLE = v.get("dui-purple", v["dui-accent"]) - _self.AGENT = v["dui-agent"] - _self.TOOL = v["dui-tool"] - _self.TOOL_RESULT = v["dui-tool-result"] - _self.HUMAN = v["dui-human"] - _self.TIMESTAMP = v["dui-timestamp"] - _self.SYSTEM = v["dui-red"] - _self.SUCCESS = v["dui-green"] - _self.ERROR = v["dui-red"] - _self.WARNING = v["dui-yellow"] - _self.INFO = v["dui-accent"] - _self.BLACK = v["dui-bg"] - _self.WHITE = v["dui-fg"] - _self.BRIGHT_BLACK = v["dui-dim"] - _self.BRIGHT_WHITE = v["dui-timestamp"] - _self.CURSOR = v["dui-accent"] - _self.BRIGHT_RED = v["dui-red"] - _self.BRIGHT_GREEN = v["dui-green"] - _self.BRIGHT_YELLOW = v.get("dui-yellow", "#f2ea8c") - _self.BRIGHT_BLUE = v.get("dui-blue", "#8cbdf2") - _self.BRIGHT_PURPLE = v.get("dui-purple", v["dui-accent"]) - _self.BRIGHT_CYAN = v["dui-accent"] + # Core + _self.BACKGROUND = v["dio-bg"] + _self.BG = v["dio-bg"] + _self.FOREGROUND = v["dio-fg"] + _self.ACCENT = v["dio-text"] + _self.DIM = v["dio-dim"] + _self.CYAN = v["dio-accent"] + _self.BORDER = v["dio-accent"] + + # Named palette + _self.RED = v["dio-red"] + _self.ORANGE = v["dio-orange"] + _self.YELLOW = v["dio-yellow"] + _self.GREEN = v["dio-green"] + _self.BLUE = v["dio-blue"] + _self.PURPLE = v["dio-purple"] + _self.WHITE = v["dio-white"] + _self.GREY = v["dio-grey"] + + # Chat + _self.AGENT = v["dio-agent"] + _self.TOOL = v["dio-tool"] + _self.TOOL_RESULT = v["dio-tool-result"] + _self.HUMAN = v["dio-human"] + _self.TIMESTAMP = v["dio-timestamp"] + + # Semantic aliases + _self.SYSTEM = v["dio-red"] + _self.SUCCESS = v["dio-green"] + _self.ERROR = v["dio-red"] + _self.WARNING = v["dio-yellow"] + _self.INFO = v["dio-accent"] + + # Legacy compat + _self.BLACK = v["dio-bg"] + _self.BRIGHT_BLACK = v["dio-dim"] + _self.BRIGHT_WHITE = v["dio-white"] + _self.CURSOR = v["dio-accent"] + _self.BRIGHT_RED = v["dio-red"] + _self.BRIGHT_GREEN = v["dio-green"] + _self.BRIGHT_YELLOW = v["dio-yellow"] + _self.BRIGHT_BLUE = v["dio-blue"] + _self.BRIGHT_PURPLE = v["dio-purple"] + _self.BRIGHT_CYAN = v["dio-accent"] + + # Button colors (available as Python constants for inline Rich markup) + _self.BTN_DANGER = v["dio-btn-danger"] + _self.BTN_DANGER_BG = v["dio-btn-danger-bg"] + _self.BTN_WARN = v["dio-btn-warn"] + _self.BTN_WARN_BG = v["dio-btn-warn-bg"] + _self.BTN_MUTED = v["dio-btn-muted"] + _self.BTN_MUTED_BG = v["dio-btn-muted-bg"] + _self.BTN_KILL = v["dio-btn-kill"] + _self.BTN_KILL_BG = v["dio-btn-kill-bg"] + + # Dtop + _self.LABEL_COLOR = v["dio-label"] + _self.STALE_COLOR = v["dio-stale"] + _self.PID_COLOR = v["dio-pid"] + + # Debug + _self.DEBUG_KEY = v["dio-debug-key"] + _self.DEBUG_ACTION = v["dio-debug-action"] + _self.DEBUG_FOCUS = v["dio-debug-focus"] # --------------------------------------------------------------------------- @@ -332,12 +415,14 @@ def _apply_vars(v: dict[str, str]) -> None: # Base color palette BLACK = COLORS.get("black", "#0b0f0f") RED = COLORS.get("red", "#ff0000") +ORANGE = "#ff8800" GREEN = COLORS.get("green", "#00eeee") YELLOW = COLORS.get("yellow", "#ffcc00") BLUE = COLORS.get("blue", "#5c9ff0") PURPLE = COLORS.get("purple", "#00eeee") CYAN = COLORS.get("cyan", "#00eeee") WHITE = COLORS.get("white", "#b5e4f4") +GREY = "#777777" # Bright colors BRIGHT_BLACK = COLORS.get("bright-black", "#404040") @@ -374,6 +459,26 @@ def _apply_vars(v: dict[str, str]) -> None: WARNING = COLORS.get("warning", "#ffcc00") INFO = COLORS.get("info", "#00eeee") +# Button colors +BTN_DANGER = "#cc4444" +BTN_DANGER_BG = "#882222" +BTN_WARN = "#ccaa00" +BTN_WARN_BG = "#886600" +BTN_MUTED = "#8899aa" +BTN_MUTED_BG = "#445566" +BTN_KILL = "#ff4444" +BTN_KILL_BG = "#882222" + +# Dtop colors +LABEL_COLOR = "#cccccc" +STALE_COLOR = "#606060" +PID_COLOR = "#777777" + +# Debug colors +DEBUG_KEY = "#b5e4f4" +DEBUG_ACTION = "#ffcc00" +DEBUG_FOCUS = "#5c9ff0" + ascii_logo = """ ▇▇▇▇▇▇╗ ▇▇╗▇▇▇╗ ▇▇▇╗▇▇▇▇▇▇▇╗▇▇▇╗ ▇▇╗▇▇▇▇▇▇▇╗▇▇╗ ▇▇▇▇▇▇╗ ▇▇▇╗ ▇▇╗ ▇▇▇▇▇╗ ▇▇╗ ▇▇╔══▇▇╗▇▇║▇▇▇▇╗ ▇▇▇▇║▇▇╔════╝▇▇▇▇╗ ▇▇║▇▇╔════╝▇▇║▇▇╔═══▇▇╗▇▇▇▇╗ ▇▇║▇▇╔══▇▇╗▇▇║ diff --git a/dimos/utils/prompt.py b/dimos/utils/prompt.py index 68a5417769..6b4451cec1 100644 --- a/dimos/utils/prompt.py +++ b/dimos/utils/prompt.py @@ -37,21 +37,21 @@ def set_dio_hook(hook: Any) -> None: - """Register the dio TUI confirm handler (called by DUIApp on startup).""" + """Register the dio TUI confirm handler (called by DIOApp on startup).""" global _dio_confirm_hook with _lock: _dio_confirm_hook = hook def set_dio_sudo_hook(hook: Any) -> None: - """Register the dio TUI sudo handler (called by DUIApp on startup).""" + """Register the dio TUI sudo handler (called by DIOApp on startup).""" global _dio_sudo_hook with _lock: _dio_sudo_hook = hook def clear_dio_hook() -> None: - """Unregister all dio TUI handlers (called by DUIApp on shutdown).""" + """Unregister all dio TUI handlers (called by DIOApp on shutdown).""" global _dio_confirm_hook, _dio_sudo_hook with _lock: _dio_confirm_hook = None diff --git a/pyproject.toml b/pyproject.toml index 1618061aa3..f9a76b128b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,7 +95,7 @@ foxglove-bridge = "dimos.utils.cli.foxglove_bridge.run_foxglove_bridge:main" agentspy = "dimos.utils.cli.agentspy.agentspy:main" humancli = "dimos.utils.cli.human.humanclianim:main" dimos = "dimos.robot.cli.dimos:main" -dio = "dimos.utils.cli.dui.app:main" +dio = "dimos.utils.cli.dio.app:main" rerun-bridge = "dimos.visualization.rerun.bridge:app" doclinks = "dimos.utils.docs.doclinks:main" dtop = "dimos.utils.cli.dtop:main" From ec8d528bf3233984cf82e429023bf090f748ad89 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 10:46:08 -0700 Subject: [PATCH 13/23] finish adding themes --- dimos/utils/cli/dio/app.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/dimos/utils/cli/dio/app.py b/dimos/utils/cli/dio/app.py index f6f65a044f..1dbbc54d52 100644 --- a/dimos/utils/cli/dio/app.py +++ b/dimos/utils/cli/dio/app.py @@ -103,6 +103,9 @@ def _load_saved_theme() -> str: config_path = Path(sys.prefix) / "dio-config.json" data = json.loads(config_path.read_text()) name = data.get("theme", theme.DEFAULT_THEME) + # Migrate old theme names + _MIGRATION = {"dark": "dark-one", "midnight": "dark-two"} + name = _MIGRATION.get(name, name) if name in theme.THEME_NAMES: return name except Exception: @@ -327,13 +330,17 @@ async def action_tab_next(self) -> None: await self._move_tab(1) def action_focus_prev_panel(self) -> None: - self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_prev_panel (was panel={self._focused_panel})") + self._log( + f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_prev_panel (was panel={self._focused_panel})" + ) self._clear_quit_pending() new = max(0, self._focused_panel - 1) self._focus_panel(new) def action_focus_next_panel(self) -> None: - self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_next_panel (was panel={self._focused_panel})") + self._log( + f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_next_panel (was panel={self._focused_panel})" + ) self._clear_quit_pending() new = min(self._num_panels - 1, self._focused_panel + 1) self._focus_panel(new) @@ -344,9 +351,13 @@ def action_copy_text(self) -> None: if selected: self.copy_to_clipboard(selected) self.screen.clear_selection() - self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text (copied to clipboard)") + self._log( + f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text (copied to clipboard)" + ) else: - self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text -> no selection, treating as quit") + self._log( + f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text -> no selection, treating as quit" + ) self._handle_quit_press() def action_quit_or_esc(self) -> None: From 9728d4c04714b7890c40387a7f7c5f41184d0694 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 10:49:27 -0700 Subject: [PATCH 14/23] allow auto selection of n_workers --- dimos/core/blueprints.py | 6 ++++++ dimos/core/global_config.py | 2 +- dimos/core/module_coordinator.py | 6 +++++- dimos/core/worker_manager.py | 15 +++++++++++++++ dimos/utils/cli/dio/sub_apps/__init__.py | 2 +- dimos/utils/cli/dio/sub_apps/dtop.py | 22 ++++++++++++---------- 6 files changed, 40 insertions(+), 13 deletions(-) diff --git a/dimos/core/blueprints.py b/dimos/core/blueprints.py index 287697f6c0..f0ff38d7f4 100644 --- a/dimos/core/blueprints.py +++ b/dimos/core/blueprints.py @@ -485,6 +485,12 @@ def build( self._verify_no_name_conflicts() logger.info("Starting the modules") + # Auto-compute worker count if not explicitly set + if global_config.n_workers is None: + import math + + n_modules = len(self._active_blueprints) + global_config.n_workers = max(2, math.ceil(n_modules * 0.7)) module_coordinator = ModuleCoordinator(cfg=global_config) module_coordinator.start() diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index fa9757db68..201a963a28 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -33,7 +33,7 @@ class GlobalConfig(BaseSettings): simulation: bool = False replay: bool = False viewer: ViewerBackend = "rerun" - n_workers: int = 2 + n_workers: int | None = None memory_limit: str = "auto" mujoco_camera_position: str | None = None mujoco_room: str | None = None diff --git a/dimos/core/module_coordinator.py b/dimos/core/module_coordinator.py index c9e9595cc2..af469d2ec8 100644 --- a/dimos/core/module_coordinator.py +++ b/dimos/core/module_coordinator.py @@ -90,7 +90,7 @@ def suppress_console(self) -> None: self._client.suppress_console() def start(self) -> None: - n = self._n if self._n is not None else 2 + n: int = self._n if self._n is not None else 2 # type: ignore[assignment] self._client = WorkerManager(n_workers=n) self._client.start() @@ -135,6 +135,10 @@ def deploy_parallel( if not self._client: raise ValueError("Not started") + # Grow the worker pool if there are more modules than workers + if isinstance(self._client, WorkerManager): + self._client.ensure_capacity(len(module_specs)) + modules = self._client.deploy_parallel(module_specs) for (module_class, _, _), module in zip(module_specs, modules, strict=True): self._deployed_modules[module_class] = module # type: ignore[assignment] diff --git a/dimos/core/worker_manager.py b/dimos/core/worker_manager.py index 2b41f634e8..b9ea473208 100644 --- a/dimos/core/worker_manager.py +++ b/dimos/core/worker_manager.py @@ -44,6 +44,21 @@ def start(self) -> None: self._workers.append(worker) logger.info("Worker pool started.", n_workers=self._n_workers) + def add_worker(self) -> Worker: + """Spawn one additional worker process and add it to the pool.""" + worker = Worker() + worker.start_process() + self._workers.append(worker) + self._n_workers = len(self._workers) + return worker + + def ensure_capacity(self, n: int) -> None: + """Grow the pool to at least *n* workers if needed.""" + while len(self._workers) < n: + self.add_worker() + if len(self._workers) > self._n_workers: + logger.info("Worker pool grew.", n_workers=len(self._workers)) + def _select_worker(self) -> Worker: return min(self._workers, key=lambda w: w.module_count) diff --git a/dimos/utils/cli/dio/sub_apps/__init__.py b/dimos/utils/cli/dio/sub_apps/__init__.py index 5f9f836671..57b4afc0b7 100644 --- a/dimos/utils/cli/dio/sub_apps/__init__.py +++ b/dimos/utils/cli/dio/sub_apps/__init__.py @@ -10,7 +10,7 @@ def get_sub_apps() -> list[type[SubApp]]: """Return all available sub-app classes in display order.""" - # from dimos.utils.cli.dio.sub_apps.agentspy import AgentSpySubApp + from dimos.utils.cli.dio.sub_apps.agentspy import AgentSpySubApp from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp from dimos.utils.cli.dio.sub_apps.dtop import DtopSubApp from dimos.utils.cli.dio.sub_apps.humancli import HumanCLISubApp diff --git a/dimos/utils/cli/dio/sub_apps/dtop.py b/dimos/utils/cli/dio/sub_apps/dtop.py index efebbffc79..e6acf9a842 100644 --- a/dimos/utils/cli/dio/sub_apps/dtop.py +++ b/dimos/utils/cli/dio/sub_apps/dtop.py @@ -29,13 +29,12 @@ from textual.widgets import Static from dimos.utils.cli import theme +from dimos.utils.cli.dio.sub_app import SubApp from dimos.utils.cli.dtop import ( - _LABEL_COLOR, _SPARK_WIDTH, ResourceSpyApp, _compute_ranges, ) -from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: from textual.app import ComposeResult @@ -188,10 +187,13 @@ def _on_test(msg: Any, _topic: Any) -> None: # Also try publishing on the real topic to see if our main # subscription picks it up self._debug("_init_lcm: testing subscription on /dimos/resource_stats...") - plcm.publish("/dimos/resource_stats", { - "coordinator": {"pid": 0, "cpu_percent": 0, "alive": True}, - "workers": [], - }) + plcm.publish( + "/dimos/resource_stats", + { + "coordinator": {"pid": 0, "cpu_percent": 0, "alive": True}, + "workers": [], + }, + ) _time.sleep(0.3) if self._latest is not None: self._debug("_init_lcm: real-topic self-test PASSED") @@ -302,9 +304,9 @@ def _refresh(self) -> None: self._cpu_history[role].append(d.get("cpu_percent", 0)) if i > 0: title = Text(" ") - title.append(role, style=dim if stale else _LABEL_COLOR) + title.append(role, style=dim if stale else theme.LABEL_COLOR) if mods: - title.append(": ", style=dim if stale else _LABEL_COLOR) + title.append(": ", style=dim if stale else theme.LABEL_COLOR) title.append(mods, style=dim if stale else rs) if pid: title.append(f" [{pid}]", style=dim if stale else theme.PID_COLOR) @@ -314,9 +316,9 @@ def _refresh(self) -> None: first_role, first_rs, _, first_mods, first_pid = entries[0] panel_title = Text(" ") - panel_title.append(first_role, style=dim if stale else _LABEL_COLOR) + panel_title.append(first_role, style=dim if stale else theme.LABEL_COLOR) if first_mods: - panel_title.append(": ", style=dim if stale else _LABEL_COLOR) + panel_title.append(": ", style=dim if stale else theme.LABEL_COLOR) panel_title.append(first_mods, style=dim if stale else first_rs) if first_pid: panel_title.append(f" [{first_pid}]", style=dim if stale else theme.PID_COLOR) From d73d6351210587a947ac08f54022ea118d70463d Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 10:51:39 -0700 Subject: [PATCH 15/23] more themes --- dimos/utils/cli/dio/app.py | 2 +- dimos/utils/cli/dio/sub_apps/config.py | 30 +++- dimos/utils/cli/theme.py | 204 +++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 9 deletions(-) diff --git a/dimos/utils/cli/dio/app.py b/dimos/utils/cli/dio/app.py index 1dbbc54d52..0abcf39716 100644 --- a/dimos/utils/cli/dio/app.py +++ b/dimos/utils/cli/dio/app.py @@ -104,7 +104,7 @@ def _load_saved_theme() -> str: data = json.loads(config_path.read_text()) name = data.get("theme", theme.DEFAULT_THEME) # Migrate old theme names - _MIGRATION = {"dark": "dark-one", "midnight": "dark-two"} + _MIGRATION = {"dark": "dark-one"} name = _MIGRATION.get(name, name) if name in theme.THEME_NAMES: return name diff --git a/dimos/utils/cli/dio/sub_apps/config.py b/dimos/utils/cli/dio/sub_apps/config.py index 7e0443266c..2f2054336a 100644 --- a/dimos/utils/cli/dio/sub_apps/config.py +++ b/dimos/utils/cli/dio/sub_apps/config.py @@ -41,7 +41,7 @@ class _FormNavigationMixin: """Mixin that intercepts Up/Down to move focus between config fields.""" - _FIELD_ORDER = ("cfg-theme", "cfg-viewer", "cfg-n-workers", "cfg-robot-ip", "cfg-dtop") + _FIELD_ORDER = ("cfg-viewer", "cfg-n-workers", "cfg-robot-ip", "cfg-dtop", "cfg-theme") def _navigate_field(self, delta: int) -> None: my_id = getattr(self, "id", None) @@ -205,6 +205,17 @@ class ConfigSubApp(SubApp): color: $dio-yellow; display: none; } + ConfigSubApp .section-header { + color: $dio-accent2; + margin-top: 2; + padding: 0; + text-style: bold; + } + ConfigSubApp .section-rule { + color: $dio-dim; + margin-top: 1; + margin-bottom: 0; + } """ def __init__(self) -> None: @@ -215,13 +226,6 @@ def compose(self) -> ComposeResult: v = self.config_values yield Static("GlobalConfig Editor", classes="subapp-header") - yield Label("theme", classes="field-label") - yield CycleSelect( - _THEME_OPTIONS, - value=str(v.get("theme", theme.DEFAULT_THEME)), - id="cfg-theme", - ) - yield Label("viewer", classes="field-label") yield CycleSelect( _VIEWER_OPTIONS, @@ -248,6 +252,16 @@ def compose(self) -> ComposeResult: yield Static("edits only take effect on new blueprint launch", id="cfg-dirty-notice") + # ── Dio Settings ────────────────────────────────────────── + yield Static("Dio Settings", classes="section-header") + + yield Label("theme", classes="field-label") + yield CycleSelect( + _THEME_OPTIONS, + value=str(v.get("theme", theme.DEFAULT_THEME)), + id="cfg-theme", + ) + def _mark_dirty(self) -> None: self.query_one("#cfg-dirty-notice").styles.display = "block" diff --git a/dimos/utils/cli/theme.py b/dimos/utils/cli/theme.py index 452739a7e7..1bc5010e19 100644 --- a/dimos/utils/cli/theme.py +++ b/dimos/utils/cli/theme.py @@ -245,6 +245,171 @@ def get(name: str, default: str = "#ffffff") -> str: "dio-debug-action": "#aa8800", "dio-debug-focus": "#2266cc", }, + "midnight": { + # Core — deep navy with cool steel accents + "dio-bg": "#0a0e1a", + "dio-fg": "#a8bcd0", + "dio-text": "#a8bcd0", + "dio-dim": "#2e3a50", + "dio-accent": "#5599dd", + "dio-accent2": "#cc8844", + # Named palette + "dio-red": "#cc5555", + "dio-orange": "#cc8844", + "dio-yellow": "#ccaa55", + "dio-green": "#55aa88", + "dio-blue": "#5599dd", + "dio-purple": "#9977cc", + "dio-cyan": "#55aacc", + "dio-white": "#d0d8e8", + "dio-grey": "#667788", + # Chat message colors + "dio-agent": "#66cc88", + "dio-tool": "#55aacc", + "dio-tool-result": "#ccaa55", + "dio-human": "#d0d8e8", + "dio-timestamp": "#8899bb", + # Buttons + "dio-btn-danger": "#cc5555", + "dio-btn-danger-bg": "#552233", + "dio-btn-warn": "#bbaa55", + "dio-btn-warn-bg": "#554422", + "dio-btn-muted": "#7788aa", + "dio-btn-muted-bg": "#334455", + "dio-btn-kill": "#dd5555", + "dio-btn-kill-bg": "#552233", + # UI chrome + "dio-panel-bg": "#121830", + "dio-hint-bg": "#0e1428", + # Tabs + "dio-tab1": "#55aacc", + "dio-tab1-bg": "#121830", + "dio-tab2": "#5599dd", + "dio-tab2-bg": "#14183a", + "dio-tab3": "#9977cc", + "dio-tab3-bg": "#1c1430", + # LCMSpy bandwidth gradient + "dio-bw-low": "#55aacc", + "dio-bw-high": "#ccaa55", + # Dtop + "dio-label": "#aabbcc", + "dio-stale": "#3a4860", + "dio-pid": "#667788", + # Debug + "dio-debug-key": "#a8bcd0", + "dio-debug-action": "#ccaa55", + "dio-debug-focus": "#5599dd", + }, + "ember": { + # Core — warm dark with orange/red accent + "dio-bg": "#120c0a", + "dio-fg": "#e0c8b0", + "dio-text": "#e0c8b0", + "dio-dim": "#4a3028", + "dio-accent": "#ee8844", + "dio-accent2": "#cc6644", + # Named palette + "dio-red": "#dd4433", + "dio-orange": "#ee8844", + "dio-yellow": "#ddaa33", + "dio-green": "#88aa44", + "dio-blue": "#cc8844", + "dio-purple": "#cc6688", + "dio-cyan": "#ccaa66", + "dio-white": "#e8d8c8", + "dio-grey": "#887766", + # Chat message colors + "dio-agent": "#aacc66", + "dio-tool": "#ee8844", + "dio-tool-result": "#ddaa33", + "dio-human": "#e8d8c8", + "dio-timestamp": "#aa9080", + # Buttons + "dio-btn-danger": "#dd4433", + "dio-btn-danger-bg": "#661a14", + "dio-btn-warn": "#ccaa33", + "dio-btn-warn-bg": "#665518", + "dio-btn-muted": "#998877", + "dio-btn-muted-bg": "#443830", + "dio-btn-kill": "#ee4433", + "dio-btn-kill-bg": "#661a14", + # UI chrome + "dio-panel-bg": "#2a1810", + "dio-hint-bg": "#1a1210", + # Tabs + "dio-tab1": "#ee8844", + "dio-tab1-bg": "#2a1810", + "dio-tab2": "#cc8844", + "dio-tab2-bg": "#2a2010", + "dio-tab3": "#cc6688", + "dio-tab3-bg": "#2a1420", + # LCMSpy bandwidth gradient + "dio-bw-low": "#ccaa66", + "dio-bw-high": "#dd4433", + # Dtop + "dio-label": "#ccbbaa", + "dio-stale": "#5a4838", + "dio-pid": "#887766", + # Debug + "dio-debug-key": "#e0c8b0", + "dio-debug-action": "#ddaa33", + "dio-debug-focus": "#cc8844", + }, + "forest": { + # Core — deep green with natural earth tones + "dio-bg": "#0a100c", + "dio-fg": "#b0d0b8", + "dio-text": "#b0d0b8", + "dio-dim": "#2a3a2e", + "dio-accent": "#44cc88", + "dio-accent2": "#88cc44", + # Named palette + "dio-red": "#cc5544", + "dio-orange": "#cc8844", + "dio-yellow": "#aacc44", + "dio-green": "#44cc88", + "dio-blue": "#44aa99", + "dio-purple": "#88aa66", + "dio-cyan": "#55ccaa", + "dio-white": "#d0e0d0", + "dio-grey": "#668866", + # Chat message colors + "dio-agent": "#66dd88", + "dio-tool": "#55ccaa", + "dio-tool-result": "#aacc44", + "dio-human": "#d0e0d0", + "dio-timestamp": "#80aa88", + # Buttons + "dio-btn-danger": "#cc5544", + "dio-btn-danger-bg": "#552218", + "dio-btn-warn": "#aaaa44", + "dio-btn-warn-bg": "#555522", + "dio-btn-muted": "#778877", + "dio-btn-muted-bg": "#334433", + "dio-btn-kill": "#dd5544", + "dio-btn-kill-bg": "#552218", + # UI chrome + "dio-panel-bg": "#142a1a", + "dio-hint-bg": "#101a14", + # Tabs + "dio-tab1": "#44cc88", + "dio-tab1-bg": "#142a1a", + "dio-tab2": "#44aa99", + "dio-tab2-bg": "#142a26", + "dio-tab3": "#88aa66", + "dio-tab3-bg": "#1e2a14", + # LCMSpy bandwidth gradient + "dio-bw-low": "#55ccaa", + "dio-bw-high": "#aacc44", + # Dtop + "dio-label": "#aaccaa", + "dio-stale": "#3a4a3e", + "dio-pid": "#668866", + # Debug + "dio-debug-key": "#b0d0b8", + "dio-debug-action": "#aacc44", + "dio-debug-focus": "#44aa99", + }, } # Textual Theme constructor args for each theme @@ -288,6 +453,45 @@ def get(name: str, default: str = "#ffffff") -> str: "panel": "#e4e8ee", "dark": False, }, + "midnight": { + "primary": "#5599dd", + "secondary": "#55aacc", + "warning": "#ccaa55", + "error": "#cc5555", + "success": "#55aa88", + "accent": "#5599dd", + "foreground": "#a8bcd0", + "background": "#0a0e1a", + "surface": "#0a0e1a", + "panel": "#121830", + "dark": True, + }, + "ember": { + "primary": "#ee8844", + "secondary": "#cc8844", + "warning": "#ddaa33", + "error": "#dd4433", + "success": "#88aa44", + "accent": "#ee8844", + "foreground": "#e0c8b0", + "background": "#120c0a", + "surface": "#120c0a", + "panel": "#2a1810", + "dark": True, + }, + "forest": { + "primary": "#44cc88", + "secondary": "#44aa99", + "warning": "#aacc44", + "error": "#cc5544", + "success": "#44cc88", + "accent": "#44cc88", + "foreground": "#b0d0b8", + "background": "#0a100c", + "surface": "#0a100c", + "panel": "#142a1a", + "dark": True, + }, } THEME_NAMES: list[str] = list(_THEME_VARIABLES) From 5cf42e88cc7633480254ea0cc9555af442ff4098 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 11:01:42 -0700 Subject: [PATCH 16/23] fix --- dimos/utils/cli/dio/sub_apps/config.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dimos/utils/cli/dio/sub_apps/config.py b/dimos/utils/cli/dio/sub_apps/config.py index 2f2054336a..c7f180dac1 100644 --- a/dimos/utils/cli/dio/sub_apps/config.py +++ b/dimos/utils/cli/dio/sub_apps/config.py @@ -309,10 +309,15 @@ def on_switch_changed(self, event: Switch.Changed) -> None: _save_config(self.config_values) self._mark_dirty() + # Keys that are dio-only settings and should NOT be passed as CLI args + _DIO_ONLY_KEYS = {"theme"} + def get_overrides(self) -> dict[str, object]: - """Return config overrides for use by the runner.""" + """Return config overrides for use by the runner (excludes dio-only settings).""" overrides: dict[str, object] = {} for k, v in self.config_values.items(): + if k in self._DIO_ONLY_KEYS: + continue if k == "robot_ip" and not v: continue overrides[k] = v From 1f032c4dfe61260c0d4fd60678679dcfbd8bb0d6 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 11 Mar 2026 21:43:21 -0700 Subject: [PATCH 17/23] misc fixup --- dimos/core/module_coordinator.py | 1 - dimos/core/resource_monitor/monitor.py | 1 - dimos/utils/cli/dio/app.py | 4 +- dimos/utils/cli/dio/dio.tcss | 4 +- dimos/utils/cli/dio/sub_apps/__init__.py | 2 +- dimos/utils/cli/dio/sub_apps/dtop.py | 103 ++--------------------- dimos/utils/cli/dio/sub_apps/humancli.py | 13 ++- dimos/utils/cli/dio/sub_apps/lcmspy.py | 7 +- 8 files changed, 25 insertions(+), 110 deletions(-) diff --git a/dimos/core/module_coordinator.py b/dimos/core/module_coordinator.py index af469d2ec8..259c8be7de 100644 --- a/dimos/core/module_coordinator.py +++ b/dimos/core/module_coordinator.py @@ -103,7 +103,6 @@ def start(self) -> None: def restart_daemon_threads(self) -> None: """Re-start threads that were killed by os.fork() during daemonize.""" if self._stats_monitor is not None: - logger.info("Restarting StatsMonitor after daemonize") self._stats_monitor.start() def stop(self) -> None: diff --git a/dimos/core/resource_monitor/monitor.py b/dimos/core/resource_monitor/monitor.py index 1fb3f7c101..80e2887402 100644 --- a/dimos/core/resource_monitor/monitor.py +++ b/dimos/core/resource_monitor/monitor.py @@ -94,7 +94,6 @@ def start(self) -> None: from dimos.core.resource_monitor.logger import LCMResourceLogger if isinstance(self._logger, LCMResourceLogger): - logger.info("StatsMonitor.start: re-creating LCMResourceLogger (post-fork safe)") self._logger = LCMResourceLogger() self._stop.clear() diff --git a/dimos/utils/cli/dio/app.py b/dimos/utils/cli/dio/app.py index 0abcf39716..0c7a3814d6 100644 --- a/dimos/utils/cli/dio/app.py +++ b/dimos/utils/cli/dio/app.py @@ -36,8 +36,8 @@ from dimos.utils.cli.dio.sub_app import SubApp -_DUAL_WIDTH = 240 # >= this width: 2 panels -_TRIPLE_WIDTH = 320 # >= this width: 3 panels +_DUAL_WIDTH = 140 # >= this width: 2 panels +_TRIPLE_WIDTH = 220 # >= this width: 3 panels _MAX_PANELS = 3 _QUIT_WINDOW = 1.5 # seconds to press again to confirm quit diff --git a/dimos/utils/cli/dio/dio.tcss b/dimos/utils/cli/dio/dio.tcss index 3cddd633a7..c4df83ecba 100644 --- a/dimos/utils/cli/dio/dio.tcss +++ b/dimos/utils/cli/dio/dio.tcss @@ -6,14 +6,14 @@ Screen { } #sidebar { - width: 22; + width: auto; height: 1fr; background: $dio-bg; padding: 1 0; } .tab-item { - width: 100%; + width: auto; height: 3; padding: 1 2; content-align: left middle; diff --git a/dimos/utils/cli/dio/sub_apps/__init__.py b/dimos/utils/cli/dio/sub_apps/__init__.py index 57b4afc0b7..4ed116499a 100644 --- a/dimos/utils/cli/dio/sub_apps/__init__.py +++ b/dimos/utils/cli/dio/sub_apps/__init__.py @@ -25,5 +25,5 @@ def get_sub_apps() -> list[type[SubApp]]: DtopSubApp, LCMSpySubApp, HumanCLISubApp, - AgentSpySubApp, + # AgentSpySubApp, ] diff --git a/dimos/utils/cli/dio/sub_apps/dtop.py b/dimos/utils/cli/dio/sub_apps/dtop.py index e6acf9a842..84abb28559 100644 --- a/dimos/utils/cli/dio/sub_apps/dtop.py +++ b/dimos/utils/cli/dio/sub_apps/dtop.py @@ -72,8 +72,6 @@ def __init__(self) -> None: self._last_msg_time: float = 0.0 self._cpu_history: dict[str, deque[float]] = {} self._reconnecting = False - self._refresh_count = 0 - self._msg_count = 0 def _debug(self, msg: str) -> None: try: @@ -123,92 +121,23 @@ def _start_refresh_timer(self) -> None: def _init_lcm(self) -> None: """Blocking LCM init — runs in a worker thread.""" - self._debug("_init_lcm: starting...") - # Stop any existing LCM instance first if self._lcm is not None: - self._debug("_init_lcm: stopping existing LCM instance") try: self._lcm.stop() - except Exception as e: - self._debug(f"_init_lcm: stop failed: {e}") + except Exception: + pass self._lcm = None try: from dimos.protocol.pubsub.impl.lcmpubsub import PickleLCM, Topic - self._debug("_init_lcm: creating PickleLCM...") plcm = PickleLCM() - self._debug( - f"_init_lcm: PickleLCM created, l={plcm.l is not None}, url={plcm.config.url}" - ) - - self._debug("_init_lcm: subscribing to /dimos/resource_stats...") plcm.subscribe(Topic("/dimos/resource_stats"), self._on_msg) - - self._debug("_init_lcm: calling start()...") plcm.start() - self._debug( - f"_init_lcm: started, thread={plcm._thread is not None}, " - f"thread.alive={plcm._thread.is_alive() if plcm._thread else 'N/A'}" - ) - self._lcm = plcm - self._debug("_init_lcm: DONE — listening for messages") - - # Self-test: publish a test message and see if we receive it - self._debug("_init_lcm: running self-test...") - self._self_test_received = False - test_topic = "/dimos/_dtop_self_test" - test_plcm = PickleLCM() - - def _on_test(msg: Any, _topic: Any) -> None: - self._self_test_received = True - - test_plcm.subscribe(Topic(test_topic), _on_test) - test_plcm.start() - import time as _time - - _time.sleep(0.2) - test_plcm.publish(test_topic, {"test": True}) - _time.sleep(0.5) - if self._self_test_received: - self._debug("_init_lcm: self-test PASSED — LCM multicast is working") - else: - self._debug( - "_init_lcm: self-test FAILED — LCM multicast NOT working! " - "Check multicast routes and firewall." - ) - try: - test_plcm.stop() - except Exception: - pass - - # Also try publishing on the real topic to see if our main - # subscription picks it up - self._debug("_init_lcm: testing subscription on /dimos/resource_stats...") - plcm.publish( - "/dimos/resource_stats", - { - "coordinator": {"pid": 0, "cpu_percent": 0, "alive": True}, - "workers": [], - }, - ) - _time.sleep(0.3) - if self._latest is not None: - self._debug("_init_lcm: real-topic self-test PASSED") - else: - self._debug( - "_init_lcm: real-topic self-test FAILED — subscription to " - "/dimos/resource_stats may not be working" - ) - # Clear the test data so the real data takes over - with self._lock: - self._latest = None except Exception as e: - import traceback - - self._debug(f"_init_lcm FAILED: {e}\n{traceback.format_exc()}") + self._debug(f"_init_lcm FAILED: {e}") self._lcm = None def on_unmount_subapp(self) -> None: @@ -223,27 +152,17 @@ def _reconnect_lcm(self) -> None: """Tear down and re-create the LCM subscription.""" try: self._init_lcm() - self._debug("LCM reconnected") - except Exception as e: - self._debug(f"reconnect failed: {e}") + except Exception: + pass finally: self._reconnecting = False def _on_msg(self, msg: dict[str, Any], _topic: str) -> None: - self._msg_count += 1 - first = False with self._lock: - if self._latest is None: - first = True self._latest = msg self._last_msg_time = time.monotonic() - if first: - self._debug(f"_on_msg: FIRST message received! keys={list(msg.keys())}") - elif self._msg_count % 10 == 0: - self._debug(f"_on_msg: {self._msg_count} messages received so far") def _refresh(self) -> None: - self._refresh_count += 1 with self._lock: data = self._latest last_msg = self._last_msg_time @@ -255,18 +174,6 @@ def _refresh(self) -> None: now = time.monotonic() - # Log status every 10 seconds (20 refresh cycles at 0.5s) - if self._refresh_count % 20 == 0: - lcm = self._lcm - thread_alive = "N/A" - if lcm is not None and hasattr(lcm, "_thread") and lcm._thread is not None: - thread_alive = str(lcm._thread.is_alive()) - self._debug( - f"_refresh #{self._refresh_count}: data={'yes' if data else 'no'}, " - f"msgs={self._msg_count}, lcm={'yes' if lcm else 'no'}, " - f"thread_alive={thread_alive}" - ) - if data is None: scroll.add_class("waiting") self.query_one("#dtop-panels", Static).update(self._waiting_panel()) diff --git a/dimos/utils/cli/dio/sub_apps/humancli.py b/dimos/utils/cli/dio/sub_apps/humancli.py index f7ae4f68ff..a549598986 100644 --- a/dimos/utils/cli/dio/sub_apps/humancli.py +++ b/dimos/utils/cli/dio/sub_apps/humancli.py @@ -165,7 +165,9 @@ def __init__(self) -> None: self._agent_timeout_timer: Any = None def compose(self) -> ComposeResult: - yield Static("", id="hcli-status-bar") + # Show default disconnected status immediately (before on_mount_subapp) + label, color = _status_style(_ConnState.DISCONNECTED) + yield Static(f"[{color}]● {label}[/{color}]", id="hcli-status-bar") with Container(id="hcli-chat"): yield RichLog(id="hcli-log", highlight=True, markup=True, wrap=False) yield Input(placeholder="Type a message…", id="hcli-input") @@ -414,6 +416,13 @@ def on_input_submitted(self, event: Input.Submitted) -> None: self._add_user_message(log, message) self._human_transport.publish(message) else: - # Transport not ready yet — queue the message + # Transport not ready yet — queue the message and tell the user self._add_user_message(log, message, queued=True) self._enqueue(message) + n = len(self._send_queue) + bar = self.query_one("#hcli-status-bar", Static) + label, color = _status_style(self._conn_state) + bar.update( + f"[{color}]● {label}[/{color}]" + f" [{theme.DIM}]({n} message{'s' if n != 1 else ''} queued — will send when connected)[/{theme.DIM}]" + ) diff --git a/dimos/utils/cli/dio/sub_apps/lcmspy.py b/dimos/utils/cli/dio/sub_apps/lcmspy.py index 873e2eb2a6..6b692dc8c5 100644 --- a/dimos/utils/cli/dio/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dio/sub_apps/lcmspy.py @@ -76,10 +76,11 @@ def _init_lcm(self) -> None: try: from dimos.utils.cli.lcmspy.lcmspy import GraphLCMSpy - self._spy = GraphLCMSpy(autoconf=True, graph_log_window=0.5) + self._spy = GraphLCMSpy(graph_log_window=0.5) self._spy.start() - except Exception: - pass + except Exception as e: + import traceback + self._debug(traceback.format_exc()) def on_unmount_subapp(self) -> None: if self._spy: From 3793a232a7a4dd63f485616a967a6562c510dd4e Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Thu, 12 Mar 2026 14:39:43 -0700 Subject: [PATCH 18/23] error notification system, daemon overhaul, multi-blueprint running working --- dimos/agents/mcp/mcp_adapter.py | 4 +- dimos/agents/mcp/mcp_server.py | 6 +- dimos/core/daemon.py | 392 +++++++++++- dimos/core/instance_registry.py | 198 ++++++ dimos/core/log_viewer.py | 74 ++- dimos/core/module_coordinator.py | 5 - dimos/core/output_tee.py | 109 ++++ dimos/core/run_registry.py | 119 ++-- dimos/core/test_daemon.py | 22 +- dimos/robot/cli/dimos.py | 299 +++++---- dimos/utils/cli/dio/confirm_screen.py | 25 +- dimos/utils/cli/dio/sub_apps/humancli.py | 1 + dimos/utils/cli/dio/sub_apps/launcher.py | 189 +++--- dimos/utils/cli/dio/sub_apps/lcmspy.py | 4 +- dimos/utils/cli/dio/sub_apps/runner.py | 743 ++++++++++++++--------- dimos/utils/logging_config.py | 11 +- docs/development/daemon.md | 214 +++++++ 17 files changed, 1758 insertions(+), 657 deletions(-) create mode 100644 dimos/core/instance_registry.py create mode 100644 dimos/core/output_tee.py create mode 100644 docs/development/daemon.md diff --git a/dimos/agents/mcp/mcp_adapter.py b/dimos/agents/mcp/mcp_adapter.py index 9b8cc5c4b9..234e239275 100644 --- a/dimos/agents/mcp/mcp_adapter.py +++ b/dimos/agents/mcp/mcp_adapter.py @@ -159,9 +159,9 @@ def from_run_entry(cls, entry: Any | None = None, timeout: int = DEFAULT_TIMEOUT Falls back to the default URL if no entry is found. """ if entry is None: - from dimos.core.run_registry import list_runs + from dimos.core.instance_registry import list_running - runs = list_runs(alive_only=True) + runs = list_running() entry = runs[0] if runs else None if entry is not None and hasattr(entry, "mcp_url") and entry.mcp_url: diff --git a/dimos/agents/mcp/mcp_server.py b/dimos/agents/mcp/mcp_server.py index bfd45bc58a..3537f2bc6f 100644 --- a/dimos/agents/mcp/mcp_server.py +++ b/dimos/agents/mcp/mcp_server.py @@ -226,12 +226,12 @@ def on_system_modules(self, modules: list[RPCClient]) -> None: @skill def server_status(self) -> str: """Get MCP server status: main process PID, deployed modules, and skill count.""" - from dimos.core.run_registry import get_most_recent + from dimos.core.instance_registry import list_running skills: list[SkillInfo] = app.state.skills modules = list(dict.fromkeys(s.class_name for s in skills)) - entry = get_most_recent() - pid = entry.pid if entry else os.getpid() + running = list_running() + pid = running[0].pid if running else os.getpid() return json.dumps( { "pid": pid, diff --git a/dimos/core/daemon.py b/dimos/core/daemon.py index f4a19c9403..d1af74c363 100644 --- a/dimos/core/daemon.py +++ b/dimos/core/daemon.py @@ -12,75 +12,387 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Daemonization and health-check support for DimOS processes.""" +"""Daemonization support for DimOS processes. + +Architecture: subprocess + double-fork. + +Caller (DIO or CLI) + |- Compute instance_name, create run_dir + |- Write launch_params.json to run_dir + |- subprocess.Popen([python, -m, dimos.core.daemon, run_dir], + | start_new_session=True, stdin=DEVNULL, stdout=DEVNULL, stderr=DEVNULL) + |- Return LaunchResult(instance_name, run_dir) immediately + | + +- SUBPROCESS (__main__): + |- Read launch_params.json + |- Double-fork to become daemon + |- Redirect stdin -> /dev/null, stdout/stderr -> stdout.log + |- Start OutputTee -> stdout.log + stdout.plain.log + |- Apply config_overrides to global_config + |- Import and build blueprint + |- health_check() + |- register(InstanceInfo) -> writes current.json + |- install_signal_handlers() + +- coordinator.loop() <- blocks forever + +DIO reads stdout.log directly from run_dir. CLI uses attached_tail() which +also reads stdout.log. No pipe between caller and daemon. +""" from __future__ import annotations +import json import os +import select import signal +import subprocess import sys +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path from typing import TYPE_CHECKING from dimos.utils.logging_config import setup_logger if TYPE_CHECKING: - from pathlib import Path - + from dimos.core.instance_registry import InstanceInfo from dimos.core.module_coordinator import ModuleCoordinator - from dimos.core.run_registry import RunEntry + from dimos.core.output_tee import OutputTee logger = setup_logger() + +@dataclass +class LaunchResult: + """Returned by launch_blueprint() with info about the launched instance.""" + + instance_name: str + run_dir: Path + + # --------------------------------------------------------------------------- -# Health check (delegates to ModuleCoordinator.health_check) +# Public API: launch_blueprint # --------------------------------------------------------------------------- -def health_check(coordinator: ModuleCoordinator) -> bool: - """Verify all coordinator workers are alive after build. +def launch_blueprint( + robot_types: list[str], + config_overrides: dict[str, object] | None = None, + instance_name: str | None = None, + force_replace: bool = False, + disable: list[str] | None = None, +) -> LaunchResult: + """Launch a blueprint as a daemon process. - .. deprecated:: 0.1.0 - Use ``coordinator.health_check()`` directly. + Creates run_dir, writes launch_params.json, spawns a fresh Python + process via subprocess that double-forks into a daemon. Returns + immediately. Works identically from CLI and TUI. + + Parameters + ---------- + robot_types: + Blueprint names to autoconnect. + config_overrides: + GlobalConfig overrides as a dict (passed directly, no CLI parsing). + instance_name: + Global instance name (default: "-".join(robot_types)). + force_replace: + If True, stop any existing instance with the same name. + disable: + Module names to disable. + + Returns + ------- + LaunchResult with instance_name and run_dir. """ - return coordinator.health_check() + from dimos.core.instance_registry import ( + get, + is_pid_alive, + make_run_dir, + stop as registry_stop, + ) + from dimos.core.global_config import global_config + + config_overrides = config_overrides or {} + disable = disable or [] + + # Compute instance name + blueprint_name = "-".join(robot_types) + if not instance_name: + instance_name = blueprint_name + + # Handle existing instance + existing = get(instance_name) + if existing is not None: + if force_replace: + msg, _ok = registry_stop(instance_name) + for _ in range(20): + if not is_pid_alive(existing.pid): + break + time.sleep(0.1) + else: + raise RuntimeError( + f"Instance '{instance_name}' already running (PID {existing.pid}). " + f"Use force_replace=True to auto-stop." + ) + + # Create run directory + run_dir = make_run_dir(instance_name) + + # Dump full config snapshot + global_config.update(**config_overrides) + (run_dir / "config.json").write_text( + json.dumps(global_config.model_dump(mode="json"), indent=2) + ) + + # Write launch parameters for the subprocess to read + launch_params = { + "robot_types": robot_types, + "config_overrides": config_overrides, + "instance_name": instance_name, + "blueprint_name": blueprint_name, + "disable": disable, + } + (run_dir / "launch_params.json").write_text(json.dumps(launch_params)) + + # Spawn a completely fresh Python process. + # start_new_session=True detaches it from the caller's terminal/process group. + # All stdio goes to DEVNULL — the daemon writes its own stdout.log. + logger.info( + "launch_blueprint: spawning daemon", + instance_name=instance_name, + run_dir=str(run_dir), + ) + subprocess.Popen( + [sys.executable, "-m", "dimos.core.daemon", str(run_dir)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + return LaunchResult(instance_name=instance_name, run_dir=run_dir) # --------------------------------------------------------------------------- -# Daemonize (double-fork) +# Daemon entry point (runs in the SUBPROCESS, then double-forks) # --------------------------------------------------------------------------- -def daemonize(log_dir: Path) -> None: - """Double-fork daemonize the current process. +def _daemon_main(run_dir: Path) -> None: + """Read launch_params.json, double-fork, build blueprint, loop forever.""" + params = json.loads((run_dir / "launch_params.json").read_text()) + robot_types: list[str] = params["robot_types"] + config_overrides: dict[str, object] = params["config_overrides"] + instance_name: str = params["instance_name"] + blueprint_name: str = params["blueprint_name"] + disable: list[str] = params["disable"] - After this call the *caller* is the daemon grandchild. - stdin/stdout/stderr are redirected to ``/dev/null`` — all real - logging goes through structlog's FileHandler to ``main.jsonl``. - The two intermediate parents call ``os._exit(0)``. - """ - log_dir.mkdir(parents=True, exist_ok=True) - - # First fork — detach from terminal + # Double-fork to become a proper daemon pid = os.fork() if pid > 0: os._exit(0) os.setsid() - # Second fork — can never reacquire a controlling terminal pid = os.fork() if pid > 0: os._exit(0) - # Redirect all stdio to /dev/null — structlog FileHandler is the log path + # --- DAEMON GRANDCHILD --- + # Redirect stdin to /dev/null sys.stdout.flush() sys.stderr.flush() + devnull_fd = os.open(os.devnull, os.O_RDWR) + os.dup2(devnull_fd, 0) # stdin + + # Redirect stdout/stderr to stdout.log immediately (crash safety). + # OutputTee will take over these fds later with its pipe. + run_dir.mkdir(parents=True, exist_ok=True) + log_fd = os.open( + str(run_dir / "stdout.log"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644 + ) + os.dup2(log_fd, 1) # stdout + os.dup2(log_fd, 2) # stderr + os.close(devnull_fd) + os.close(log_fd) + + # Rebuild Python's sys.stdout/stderr on the new fds + sys.stdout = os.fdopen(1, "w", buffering=1) + sys.stderr = os.fdopen(2, "w", buffering=1) + + try: + from dimos.core.global_config import global_config + + global_config.update(**config_overrides) + + from dimos.utils.logging_config import set_run_log_dir, setup_exception_handler + + setup_exception_handler() + set_run_log_dir(run_dir) + + # Start OutputTee — takes over fd 1+2 with its pipe, reader thread + # fans out to stdout.log (reopened) + stdout.plain.log + from dimos.core.output_tee import OutputTee + + tee = OutputTee(run_dir) + tee.start() + + # Import and build blueprint (heavy imports happen here) + from dimos.core.blueprints import autoconnect + from dimos.robot.get_all_blueprints import get_by_name, get_module_by_name + + blueprint = autoconnect(*map(get_by_name, robot_types)) + + if disable: + disabled_classes = tuple( + get_module_by_name(d).blueprints[0].module for d in disable + ) + blueprint = blueprint.disabled_modules(*disabled_classes) + + coordinator = blueprint.build(cli_config_overrides=config_overrides) + + # Health check + if not coordinator.health_check(): + sys.stderr.write("Error: health check failed — a worker process died.\n") + coordinator.stop() + os._exit(1) + + n_workers = coordinator.n_workers + n_modules = coordinator.n_modules + print(f"All modules started ({n_modules} modules, {n_workers} workers)") + print("Health check passed") + print("DimOS running in background\n") + print(f" Instance: {instance_name}") + print(f" Run dir: {run_dir}") + print(" Stop: dimos stop") + print(" Status: dimos status") - devnull = open(os.devnull) - os.dup2(devnull.fileno(), sys.stdin.fileno()) - os.dup2(devnull.fileno(), sys.stdout.fileno()) - os.dup2(devnull.fileno(), sys.stderr.fileno()) - devnull.close() + coordinator.suppress_console() + + # Register with current.json (signals build completion) + from dimos.core.instance_registry import InstanceInfo, register + + info = InstanceInfo( + name=instance_name, + pid=os.getpid(), + blueprint=blueprint_name, + started_at=datetime.now(timezone.utc).isoformat(), + run_dir=str(run_dir), + grpc_port=global_config.grpc_port + if hasattr(global_config, "grpc_port") + else 9877, + original_argv=sys.argv, + config_overrides=config_overrides, + ) + register(info) + install_signal_handlers(info, coordinator, tee) + + # Block forever + coordinator.loop() + + except Exception: + import traceback + + traceback.print_exc() + sys.stdout.flush() + sys.stderr.flush() + os._exit(1) + + +# --------------------------------------------------------------------------- +# Health check (delegates to ModuleCoordinator.health_check) +# --------------------------------------------------------------------------- + + +def health_check(coordinator: ModuleCoordinator) -> bool: + """Verify all coordinator workers are alive after build.""" + return coordinator.health_check() + + +# --------------------------------------------------------------------------- +# Attached tail (for CLI --daemon without --detach) +# --------------------------------------------------------------------------- + + +def attached_tail(stdout_log: Path, instance_name: str) -> int: + """Tail stdout.log until the daemon dies. Forward SIGINT as SIGTERM. + + Returns exit code: 0 on success, 1 on error. + """ + from dimos.core.instance_registry import get, is_pid_alive + + got_signal = False + + # Wait for log file to appear + for _ in range(150): # ~30s + if stdout_log.exists(): + break + time.sleep(0.2) + else: + sys.stderr.write(f"Error: {stdout_log} never appeared\n") + return 1 + + daemon_pid: int | None = None + + def _check_pid() -> int | None: + info = get(instance_name) + if info is not None: + return info.pid + return None + + def _forward_sigint(signum: int, frame: object) -> None: + nonlocal got_signal + got_signal = True + pid = daemon_pid + if pid is not None: + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + pass + + signal.signal(signal.SIGINT, _forward_sigint) + + try: + with open(stdout_log) as f: + while True: + line = f.readline() + if line: + sys.stdout.write(line) + sys.stdout.flush() + else: + if daemon_pid is None: + daemon_pid = _check_pid() + + if daemon_pid is not None and not is_pid_alive(daemon_pid): + for remaining in f: + sys.stdout.write(remaining) + sys.stdout.flush() + break + + if got_signal: + for _ in range(50): + if daemon_pid is not None and not is_pid_alive(daemon_pid): + break + time.sleep(0.1) + for remaining in f: + sys.stdout.write(remaining) + sys.stdout.flush() + break + + try: + select.select([], [], [], 0.1) + except (OSError, ValueError): + time.sleep(0.1) + except KeyboardInterrupt: + if daemon_pid is not None: + try: + os.kill(daemon_pid, signal.SIGTERM) + except ProcessLookupError: + pass + + return 0 # --------------------------------------------------------------------------- @@ -88,8 +400,13 @@ def daemonize(log_dir: Path) -> None: # --------------------------------------------------------------------------- -def install_signal_handlers(entry: RunEntry, coordinator: ModuleCoordinator) -> None: +def install_signal_handlers( + info: InstanceInfo, + coordinator: ModuleCoordinator, + tee: OutputTee | None = None, +) -> None: """Install SIGTERM/SIGINT handlers that stop the coordinator and clean the registry.""" + from dimos.core import instance_registry def _shutdown(signum: int, frame: object) -> None: logger.info("Received signal, shutting down", signal=signum) @@ -97,8 +414,23 @@ def _shutdown(signum: int, frame: object) -> None: coordinator.stop() except Exception: logger.error("Error during coordinator stop", exc_info=True) - entry.remove() + sys.stdout.flush() + sys.stderr.flush() + instance_registry.unregister(info.name) + if tee is not None: + tee.close() sys.exit(0) signal.signal(signal.SIGTERM, _shutdown) signal.signal(signal.SIGINT, _shutdown) + + +# --------------------------------------------------------------------------- +# __main__ — entry point for: python -m dimos.core.daemon +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + if len(sys.argv) != 2: + sys.stderr.write("Usage: python -m dimos.core.daemon \n") + sys.exit(1) + _daemon_main(Path(sys.argv[1])) diff --git a/dimos/core/instance_registry.py b/dimos/core/instance_registry.py new file mode 100644 index 0000000000..0a57911e4e --- /dev/null +++ b/dimos/core/instance_registry.py @@ -0,0 +1,198 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Instance registry for tracking named DimOS daemon processes. + +Every running DimOS instance is identified by a global name (default: +the blueprint name). Metadata lives under ``~/.dimos/instances//``. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import json +import os +from pathlib import Path +import signal +import time + +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + + +def dimos_home() -> Path: + """Return DIMOS_HOME (``~/.dimos`` by default, overridable via env var).""" + env = os.environ.get("DIMOS_HOME") + if env: + return Path(env) + return Path.home() / ".dimos" + + +def _instances_dir() -> Path: + return dimos_home() / "instances" + + +@dataclass +class InstanceInfo: + """Metadata for a running DimOS instance.""" + + name: str + pid: int + blueprint: str + started_at: str # ISO 8601 + run_dir: str + grpc_port: int = 9877 + original_argv: list[str] = field(default_factory=list) + config_overrides: dict[str, object] = field(default_factory=dict) + + +def _current_json_path(name: str) -> Path: + return _instances_dir() / name / "current.json" + + +def is_pid_alive(pid: int) -> bool: + """Check whether a process with the given PID is still running.""" + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def register(info: InstanceInfo) -> None: + """Write ``current.json`` for the given instance.""" + path = _current_json_path(info.name) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(asdict(info), indent=2)) + + +def unregister(name: str) -> None: + """Delete ``current.json`` for the given instance.""" + _current_json_path(name).unlink(missing_ok=True) + + +def get(name: str) -> InstanceInfo | None: + """Read ``current.json``, verify the pid is alive, return info or None. + + Auto-cleans stale entries (pid dead). + """ + path = _current_json_path(name) + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + info = InstanceInfo(**data) + except Exception: + path.unlink(missing_ok=True) + return None + if not is_pid_alive(info.pid): + path.unlink(missing_ok=True) + return None + return info + + +def list_running() -> list[InstanceInfo]: + """Scan all ``instances/*/current.json`` and return live instances.""" + base = _instances_dir() + if not base.exists(): + return [] + results: list[InstanceInfo] = [] + for child in sorted(base.iterdir()): + cj = child / "current.json" + if not cj.exists(): + continue + try: + data = json.loads(cj.read_text()) + info = InstanceInfo(**data) + except Exception: + cj.unlink(missing_ok=True) + continue + if is_pid_alive(info.pid): + results.append(info) + else: + cj.unlink(missing_ok=True) + return results + + +def get_sole_running() -> InstanceInfo | None: + """Return the single running instance, or None if 0. + + Raises ``SystemExit`` with a helpful message if 2+ are running. + """ + running = list_running() + if len(running) == 0: + return None + if len(running) == 1: + return running[0] + names = ", ".join(r.name for r in running) + raise SystemExit( + f"Multiple instances running ({names}). Specify a name explicitly." + ) + + +def stop(name: str, force: bool = False) -> tuple[str, bool]: + """Stop a named instance. Returns (message, success).""" + info = get(name) + if info is None: + return ("No running instance with that name", False) + + sig = signal.SIGKILL if force else signal.SIGTERM + sig_name = "SIGKILL" if force else "SIGTERM" + + try: + os.kill(info.pid, sig) + except ProcessLookupError: + unregister(name) + return ("Process already dead, cleaned registry", True) + + if not force: + for _ in range(50): # 5 seconds + if not is_pid_alive(info.pid): + break + time.sleep(0.1) + else: + try: + os.kill(info.pid, signal.SIGKILL) + except ProcessLookupError: + pass + else: + for _ in range(20): + if not is_pid_alive(info.pid): + break + time.sleep(0.1) + unregister(name) + return (f"Escalated to SIGKILL after {sig_name} timeout", True) + + unregister(name) + return (f"Stopped with {sig_name}", True) + + +def make_run_dir(name: str) -> Path: + """Create ``instances//runs//`` and return its path.""" + ts = time.strftime("%Y%m%d-%H%M%S") + run_dir = _instances_dir() / name / "runs" / ts + run_dir.mkdir(parents=True, exist_ok=True) + return run_dir + + +def latest_run_dir(name: str) -> Path | None: + """Return the most recent run directory for the given instance, or None.""" + runs_dir = _instances_dir() / name / "runs" + if not runs_dir.exists(): + return None + dirs = sorted(runs_dir.iterdir(), reverse=True) + return dirs[0] if dirs else None diff --git a/dimos/core/log_viewer.py b/dimos/core/log_viewer.py index 563c244a96..ac104aad79 100644 --- a/dimos/core/log_viewer.py +++ b/dimos/core/log_viewer.py @@ -22,7 +22,7 @@ import time from typing import TYPE_CHECKING -from dimos.core.run_registry import get_most_recent, list_runs +from dimos.core.instance_registry import get, get_sole_running, latest_run_dir, list_running if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -32,29 +32,58 @@ _RESET = "\033[0m" -def resolve_log_path(run_id: str = "") -> Path | None: - """Find the log file: specific run → alive run → most recent.""" - if run_id: - for entry in list_runs(alive_only=False): - if entry.run_id == run_id: - return _log_path_if_exists(entry.log_dir) +def resolve_log_path(name: str = "", run_datetime: str = "") -> Path | None: + """Find the log file for a given instance name and optional run datetime. + + Resolution order: + 1. If name + run_datetime given: look in that specific run dir + 2. If name given: use the running instance's run_dir, or latest run dir + 3. If no name: use sole running instance, or scan all for latest + """ + if name and run_datetime: + from dimos.core.instance_registry import _instances_dir + run_dir = _instances_dir() / name / "runs" / run_datetime + return _log_path_if_exists(str(run_dir)) + + if name: + # Check if it's running + info = get(name) + if info is not None: + return _log_path_if_exists(info.run_dir) + # Not running — get latest run dir + rd = latest_run_dir(name) + if rd is not None: + return _log_path_if_exists(str(rd)) return None - # Prefer alive run, fall back to most recent stopped run. - alive = get_most_recent(alive_only=True) - if alive is not None: - return _log_path_if_exists(alive.log_dir) - recent = get_most_recent(alive_only=False) - if recent is not None: - return _log_path_if_exists(recent.log_dir) - return None + # No name specified — try sole running instance + running = list_running() + if len(running) == 1: + return _log_path_if_exists(running[0].run_dir) + # Try to find the most recent run across all instances + from dimos.core.instance_registry import _instances_dir + base = _instances_dir() + if not base.exists(): + return None -def format_line(raw: str, *, json_output: bool = False) -> str: - """Format a JSONL log line for display. + latest: Path | None = None + for child in base.iterdir(): + runs_dir = child / "runs" + if not runs_dir.exists(): + continue + for rd in sorted(runs_dir.iterdir(), reverse=True): + p = rd / "main.jsonl" + if p.exists(): + if latest is None or rd.name > latest.parent.name: + latest = p + break # only check most recent per instance - Default: ``HH:MM:SS [lvl] logger event k=v …`` - """ + return latest + + +def format_line(raw: str, *, json_output: bool = False) -> str: + """Format a JSONL log line for display.""" if json_output: return raw.rstrip() try: @@ -80,7 +109,6 @@ def read_log(path: Path, count: int | None = 50) -> list[str]: """Read last *count* lines from a log file (``None`` = all).""" if count is None: return path.read_text().splitlines(keepends=True) - # Only keep the tail — avoids loading the full file into a list. tail: deque[str] = deque(maxlen=count) with open(path) as f: for line in f: @@ -89,11 +117,7 @@ def read_log(path: Path, count: int | None = 50) -> list[str]: def follow_log(path: Path, stop: Callable[[], bool] | None = None) -> Iterator[str]: - """Yield new lines as they appear (``tail -f`` style). - - *stop* is an optional callable; when it returns ``True`` the - generator exits cleanly. - """ + """Yield new lines as they appear (``tail -f`` style).""" with open(path) as f: f.seek(0, 2) while stop is None or not stop(): diff --git a/dimos/core/module_coordinator.py b/dimos/core/module_coordinator.py index 259c8be7de..5316b9381f 100644 --- a/dimos/core/module_coordinator.py +++ b/dimos/core/module_coordinator.py @@ -100,11 +100,6 @@ def start(self) -> None: self._stats_monitor = StatsMonitor(self._client) self._stats_monitor.start() - def restart_daemon_threads(self) -> None: - """Re-start threads that were killed by os.fork() during daemonize.""" - if self._stats_monitor is not None: - self._stats_monitor.start() - def stop(self) -> None: if self._stats_monitor is not None: self._stats_monitor.stop() diff --git a/dimos/core/output_tee.py b/dimos/core/output_tee.py new file mode 100644 index 0000000000..7e07769faf --- /dev/null +++ b/dimos/core/output_tee.py @@ -0,0 +1,109 @@ +# Copyright 2025-2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""OutputTee — fd-level tee for stdout/stderr to log files. + +Operates at the file-descriptor level so it captures output from native +extensions as well as Python ``print()`` / ``sys.stdout.write()``. +""" + +from __future__ import annotations + +import os +import re +import threading +from pathlib import Path + +_ANSI_RE = re.compile(rb"\x1b\[[0-9;]*m") + + +class OutputTee: + """Fan out fd 1+2 to: stdout.log (with ANSI) and stdout.plain.log (stripped). + + Parameters + ---------- + run_dir: + Directory where ``stdout.log`` and ``stdout.plain.log`` are created. + """ + + def __init__(self, run_dir: Path) -> None: + self._run_dir = run_dir + self._reader_thread: threading.Thread | None = None + self._stopped = False + + # Internal pipe: we dup2 stdout/stderr to write_fd; the reader + # thread reads from read_fd and fans out. + self._read_fd, self._write_fd = os.pipe() + + # Open log files + run_dir.mkdir(parents=True, exist_ok=True) + self._color_log = open(run_dir / "stdout.log", "ab") + self._plain_log = open(run_dir / "stdout.plain.log", "ab") + + # Save original stdout fd so we can still write to the real terminal + # (only relevant for the attached-mode case). + self._orig_stdout_fd = os.dup(1) + + def start(self) -> None: + """Redirect fd 1 and 2 to the internal pipe and start the reader thread.""" + os.dup2(self._write_fd, 1) + os.dup2(self._write_fd, 2) + + self._reader_thread = threading.Thread( + target=self._reader_loop, daemon=True, name="output-tee" + ) + self._reader_thread.start() + + def _reader_loop(self) -> None: + """Read from the internal pipe and fan out to all destinations.""" + try: + while True: + data = os.read(self._read_fd, 65536) + if not data: + break + # Write to stdout.log (with ANSI colors) + try: + self._color_log.write(data) + self._color_log.flush() + except Exception: + pass + # Write to stdout.plain.log (ANSI stripped) + try: + self._plain_log.write(_ANSI_RE.sub(b"", data)) + self._plain_log.flush() + except Exception: + pass + except OSError: + pass # pipe closed + + def close(self) -> None: + """Shut down the tee (flush, close files, join reader).""" + self._stopped = True + # Close the write end so the reader loop sees EOF + try: + os.close(self._write_fd) + except OSError: + pass + if self._reader_thread is not None: + self._reader_thread.join(timeout=2.0) + try: + os.close(self._read_fd) + except OSError: + pass + self._color_log.close() + self._plain_log.close() + try: + os.close(self._orig_stdout_fd) + except OSError: + pass diff --git a/dimos/core/run_registry.py b/dimos/core/run_registry.py index 617872011c..ea10b02d8e 100644 --- a/dimos/core/run_registry.py +++ b/dimos/core/run_registry.py @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Run registry for tracking DimOS daemon processes.""" +"""Compatibility shim — delegates to instance_registry. + +.. deprecated:: + Use ``dimos.core.instance_registry`` directly. +""" from __future__ import annotations @@ -21,11 +25,30 @@ import os from pathlib import Path import re +import signal import time -from dimos.utils.logging_config import setup_logger - -logger = setup_logger() +from dimos.core.instance_registry import ( + InstanceInfo, + dimos_home, + is_pid_alive, + list_running, + stop as _stop_by_name, +) + +# Re-export +__all__ = [ + "RunEntry", + "is_pid_alive", + "get_most_recent", + "list_runs", + "stop_entry", + "cleanup_stale", + "check_port_conflicts", + "generate_run_id", + "LOG_BASE_DIR", + "REGISTRY_DIR", +] def _get_state_dir() -> Path: @@ -42,7 +65,10 @@ def _get_state_dir() -> Path: @dataclass class RunEntry: - """Metadata for a single DimOS run (daemon or foreground).""" + """Legacy RunEntry — kept for test and migration compatibility. + + New code should use ``InstanceInfo`` from ``instance_registry``. + """ run_id: str pid: int @@ -54,12 +80,21 @@ class RunEntry: grpc_port: int = 9877 original_argv: list[str] = field(default_factory=list) + # Alias for instance_registry compat + @property + def name(self) -> str: + return self.blueprint + + @property + def run_dir(self) -> str: + return self.log_dir + @property def registry_path(self) -> Path: return REGISTRY_DIR / f"{self.run_id}.json" def save(self) -> None: - """Persist this entry to disk.""" + """Persist this entry to disk (legacy format).""" REGISTRY_DIR.mkdir(parents=True, exist_ok=True) self.registry_path.write_text(json.dumps(asdict(self), indent=2)) @@ -75,46 +110,53 @@ def load(cls, path: Path) -> RunEntry: def generate_run_id(blueprint: str) -> str: - """Generate a human-readable, timestamp-prefixed run ID.""" ts = time.strftime("%Y%m%d-%H%M%S") safe_name = re.sub(r"[^a-zA-Z0-9_-]", "-", blueprint) return f"{ts}-{safe_name}" -def is_pid_alive(pid: int) -> bool: - """Check whether a process with the given PID is still running.""" - try: - os.kill(pid, 0) - return True - except ProcessLookupError: - return False - except PermissionError: - # Process exists but we can't signal it — still alive. - return True - - def list_runs(alive_only: bool = True) -> list[RunEntry]: - """List all registered runs, optionally filtering to alive processes.""" + """List runs. Checks both new instance registry and legacy format.""" + # Check new instance registry first + new_entries = list_running() + results: list[RunEntry] = [] + for info in new_entries: + results.append(RunEntry( + run_id=info.name, + pid=info.pid, + blueprint=info.blueprint, + started_at=info.started_at, + log_dir=info.run_dir, + grpc_port=info.grpc_port, + original_argv=info.original_argv, + config_overrides=info.config_overrides, + )) + + # Also check legacy registry dir REGISTRY_DIR.mkdir(parents=True, exist_ok=True) - entries: list[RunEntry] = [] + seen_pids = {r.pid for r in results} for f in sorted(REGISTRY_DIR.glob("*.json")): try: entry = RunEntry.load(f) except Exception: - logger.warning("Corrupt registry entry, removing", path=str(f)) f.unlink() continue - + if entry.pid in seen_pids: + continue if alive_only and not is_pid_alive(entry.pid): - logger.info("Cleaning stale run entry", run_id=entry.run_id, pid=entry.pid) entry.remove() continue - entries.append(entry) - return entries + results.append(entry) + + return results + + +def get_most_recent(alive_only: bool = True) -> RunEntry | None: + runs = list_runs(alive_only=alive_only) + return runs[-1] if runs else None def cleanup_stale() -> int: - """Remove registry entries for dead processes. Returns count removed.""" REGISTRY_DIR.mkdir(parents=True, exist_ok=True) removed = 0 for f in list(REGISTRY_DIR.glob("*.json")): @@ -130,27 +172,22 @@ def cleanup_stale() -> int: def check_port_conflicts(grpc_port: int = 9877) -> RunEntry | None: - """Check if any alive run is using the gRPC port. Returns conflicting entry or None.""" for entry in list_runs(alive_only=True): if entry.grpc_port == grpc_port: return entry return None -def get_most_recent(alive_only: bool = True) -> RunEntry | None: - """Return the most recently created run entry, or None.""" - runs = list_runs(alive_only=alive_only) - return runs[-1] if runs else None - - -import signal - - def stop_entry(entry: RunEntry, force: bool = False) -> tuple[str, bool]: - """Stop a DimOS instance by registry entry. + """Stop a DimOS instance by RunEntry.""" + # Try new registry first + msg, ok = _stop_by_name(entry.name, force=force) + if ok: + # Also clean legacy entry if present + entry.remove() + return msg, ok - Returns (message, success) for the CLI to display. - """ + # Fall back to direct PID kill (legacy) sig = signal.SIGKILL if force else signal.SIGTERM sig_name = "SIGKILL" if force else "SIGTERM" @@ -161,7 +198,7 @@ def stop_entry(entry: RunEntry, force: bool = False) -> tuple[str, bool]: return ("Process already dead, cleaning registry", True) if not force: - for _ in range(50): # 5 seconds + for _ in range(50): if not is_pid_alive(entry.pid): break time.sleep(0.1) diff --git a/dimos/core/test_daemon.py b/dimos/core/test_daemon.py index bd7c6b9ad8..c7b2037de8 100644 --- a/dimos/core/test_daemon.py +++ b/dimos/core/test_daemon.py @@ -216,24 +216,16 @@ def test_partial_death(self): # Daemon tests # --------------------------------------------------------------------------- -from dimos.core.daemon import daemonize, install_signal_handlers +from dimos.core.daemon import install_signal_handlers, launch_blueprint, LaunchResult -class TestDaemonize: - """test_daemonize_creates_log_dir.""" +class TestLaunchResult: + """Test the LaunchResult dataclass.""" - def test_daemonize_creates_log_dir(self, tmp_path: Path): - log_dir = tmp_path / "nested" / "logs" - assert not log_dir.exists() - - # We can't actually double-fork in tests (child would continue running - # pytest), so we mock os.fork to return >0 both times (parent path). - with mock.patch("os.fork", return_value=1), pytest.raises(SystemExit): - # Parent calls os._exit(0) which we let raise - with mock.patch("os._exit", side_effect=SystemExit(0)): - daemonize(log_dir) - - assert log_dir.exists() + def test_launch_result_fields(self, tmp_path: Path): + result = LaunchResult(instance_name="test", run_dir=tmp_path) + assert result.instance_name == "test" + assert result.run_dir == tmp_path class TestSignalHandler: diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 75423d07e9..f451e82c69 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -29,7 +29,17 @@ from dimos.agents.mcp.mcp_adapter import McpAdapter, McpError from dimos.core.global_config import GlobalConfig, global_config -from dimos.core.run_registry import get_most_recent, is_pid_alive, stop_entry +from dimos.core.instance_registry import ( + InstanceInfo, + get, + get_sole_running, + is_pid_alive, + list_running, + make_run_dir, + register, + stop as registry_stop, + unregister, +) from dimos.utils.logging_config import setup_logger logger = setup_logger() @@ -108,134 +118,165 @@ def callback(**kwargs) -> None: # type: ignore[no-untyped-def] main.callback()(create_dynamic_callback()) # type: ignore[no-untyped-call] +def _resolve_name(name: str | None) -> InstanceInfo: + """Resolve an instance name. If None, use sole running instance.""" + if name: + info = get(name) + if info is None: + typer.echo(f"No running instance named '{name}'", err=True) + raise typer.Exit(1) + return info + info = get_sole_running() + if info is None: + typer.echo("No running DimOS instance", err=True) + raise typer.Exit(1) + return info + + @main.command() def run( ctx: typer.Context, robot_types: list[str] = typer.Argument(..., help="Blueprints or modules to run"), - daemon: bool = typer.Option(False, "--daemon", "-d", help="Run in background"), + daemon: bool = typer.Option(False, "--daemon", "-d", help="Run as daemon (always daemonizes; without -d stays attached)"), + detach: bool = typer.Option(False, "--detach", help="Exit CLI after successful build (implies --daemon)"), + name: str = typer.Option("", "--name", help="Global instance name (default: blueprint name)"), + force_replace: bool = typer.Option(False, "--force-replace", help="Auto-stop existing instance with same name"), disable: list[str] = typer.Option([], "--disable", help="Module names to disable"), ) -> None: - """Start a robot blueprint""" + """Start a robot blueprint.""" logger.info("Starting DimOS") - from dimos.core.blueprints import autoconnect - from dimos.core.run_registry import ( - LOG_BASE_DIR, - RunEntry, - check_port_conflicts, - cleanup_stale, - generate_run_id, - ) - from dimos.robot.get_all_blueprints import get_by_name, get_module_by_name - from dimos.utils.logging_config import set_run_log_dir, setup_exception_handler - - setup_exception_handler() - cli_config_overrides: dict[str, Any] = ctx.obj - global_config.update(**cli_config_overrides) - # Clean stale registry entries - stale = cleanup_stale() - if stale: - logger.info(f"Cleaned {stale} stale run entries") - - # Port conflict check - conflict = check_port_conflicts() - if conflict: - typer.echo( - f"Error: Ports in use by {conflict.run_id} (PID {conflict.pid}). " - f"Run 'dimos stop' first.", - err=True, - ) - raise typer.Exit(1) + if daemon or detach: + # --- DAEMON PATH: spawn + double-fork --- + # launch_blueprint handles everything: config, existing instance, + # run_dir creation, config snapshot, and spawning the daemon. + from dimos.core.daemon import attached_tail, launch_blueprint - blueprint_name = "-".join(robot_types) - run_id = generate_run_id(blueprint_name) - log_dir = LOG_BASE_DIR / run_id - - # Route structured logs (main.jsonl) to the per-run directory. - # Workers inherit DIMOS_RUN_LOG_DIR env var via forkserver. - set_run_log_dir(log_dir) - - blueprint = autoconnect(*map(get_by_name, robot_types)) - - if disable: - disabled_classes = tuple(get_module_by_name(name).blueprints[0].module for name in disable) - blueprint = blueprint.disabled_modules(*disabled_classes) - - coordinator = blueprint.build(cli_config_overrides=cli_config_overrides) - - if daemon: - from dimos.core.daemon import ( - daemonize, - install_signal_handlers, + result = launch_blueprint( + robot_types=list(robot_types), + config_overrides=cli_config_overrides, + instance_name=name or None, + force_replace=force_replace, + disable=list(disable) if disable else None, ) - # Health check before daemonizing — catch early crashes - if not coordinator.health_check(): - typer.echo("Error: health check failed — a worker process died.", err=True) - coordinator.stop() - raise typer.Exit(1) + if not detach: + # Attached mode: tail stdout.log, forward ctrl+c as SIGTERM + exit_code = attached_tail(result.run_dir / "stdout.log", result.instance_name) + raise typer.Exit(exit_code) - n_workers = coordinator.n_workers - n_modules = coordinator.n_modules - typer.echo(f"✓ All modules started ({n_modules} modules, {n_workers} workers)") - typer.echo("✓ Health check passed") - typer.echo("✓ DimOS running in background\n") - typer.echo(f" Run ID: {run_id}") - typer.echo(f" Log: {log_dir}") - typer.echo(" Stop: dimos stop") - typer.echo(" Status: dimos status") + typer.echo(f"Launched {result.instance_name}") + typer.echo(f" Run dir: {result.run_dir}") + typer.echo(" Stop: dimos stop") + raise typer.Exit(0) + else: + # --- FOREGROUND PATH --- + from dimos.core.blueprints import autoconnect + from dimos.robot.get_all_blueprints import get_by_name, get_module_by_name + from dimos.utils.logging_config import set_run_log_dir, setup_exception_handler + + setup_exception_handler() + global_config.update(**cli_config_overrides) + + blueprint_name = "-".join(robot_types) + instance_name = name or blueprint_name + + # Check for existing instance + existing = get(instance_name) + if existing is not None: + if force_replace: + typer.echo(f"Stopping existing instance '{instance_name}' (PID {existing.pid})...") + msg, _ok = registry_stop(instance_name) + typer.echo(f" {msg}") + for _ in range(20): + if not is_pid_alive(existing.pid): + break + time.sleep(0.1) + elif sys.stdin.isatty(): + typer.echo( + f"Instance '{instance_name}' is already running (PID {existing.pid}). " + f"Stop it and launch new? [y/N] ", + nl=False, + ) + answer = input().strip().lower() + if answer not in ("y", "yes"): + raise typer.Exit(0) + msg, _ok = registry_stop(instance_name) + typer.echo(f" {msg}") + for _ in range(20): + if not is_pid_alive(existing.pid): + break + time.sleep(0.1) + else: + typer.echo( + f"Error: Instance '{instance_name}' already running (PID {existing.pid}). " + f"Use --force-replace to auto-stop.", + err=True, + ) + raise typer.Exit(1) + + run_dir = make_run_dir(instance_name) + set_run_log_dir(run_dir) + + config_snapshot = run_dir / "config.json" + config_snapshot.write_text( + json.dumps(global_config.model_dump(mode="json"), indent=2) + ) - coordinator.suppress_console() + blueprint = autoconnect(*map(get_by_name, robot_types)) - daemonize(log_dir) + if disable: + disabled_classes = tuple(get_module_by_name(d).blueprints[0].module for d in disable) + blueprint = blueprint.disabled_modules(*disabled_classes) - # os.fork() only copies the calling thread — restart any daemon - # threads (e.g. StatsMonitor) that were killed by the double-fork. - coordinator.restart_daemon_threads() + coordinator = blueprint.build(cli_config_overrides=cli_config_overrides) - entry = RunEntry( - run_id=run_id, + info = InstanceInfo( + name=instance_name, pid=os.getpid(), blueprint=blueprint_name, started_at=datetime.now(timezone.utc).isoformat(), - log_dir=str(log_dir), - cli_args=list(robot_types), - config_overrides=cli_config_overrides, + run_dir=str(run_dir), + grpc_port=global_config.grpc_port if hasattr(global_config, "grpc_port") else 9877, original_argv=sys.argv, - ) - entry.save() - install_signal_handlers(entry, coordinator) - coordinator.loop() - else: - entry = RunEntry( - run_id=run_id, - pid=os.getpid(), - blueprint=blueprint_name, - started_at=datetime.now(timezone.utc).isoformat(), - log_dir=str(log_dir), - cli_args=list(robot_types), config_overrides=cli_config_overrides, - original_argv=sys.argv, ) - entry.save() + register(info) try: coordinator.loop() finally: - entry.remove() + unregister(instance_name) @main.command() -def status() -> None: - """Show the running DimOS instance.""" - entry = get_most_recent(alive_only=True) - if not entry: - typer.echo("No running DimOS instance") +def status( + name: str = typer.Argument("", help="Instance name (optional)"), +) -> None: + """Show running DimOS instance(s).""" + if name: + info = get(name) + if not info: + typer.echo(f"No running instance named '{name}'") + return + _print_instance(info) + return + + running = list_running() + if not running: + typer.echo("No running DimOS instances") return + for info in running: + _print_instance(info) + if len(running) > 1: + typer.echo("") + + +def _print_instance(info: InstanceInfo) -> None: try: - started = datetime.fromisoformat(entry.started_at) + started = datetime.fromisoformat(info.started_at) age = datetime.now(timezone.utc) - started hours, remainder = divmod(int(age.total_seconds()), 3600) minutes, seconds = divmod(remainder, 60) @@ -243,42 +284,55 @@ def status() -> None: except Exception: uptime = "unknown" - typer.echo(f" Run ID: {entry.run_id}") - typer.echo(f" PID: {entry.pid}") - typer.echo(f" Blueprint: {entry.blueprint}") + typer.echo(f" Name: {info.name}") + typer.echo(f" PID: {info.pid}") + typer.echo(f" Blueprint: {info.blueprint}") typer.echo(f" Uptime: {uptime}") - typer.echo(f" Log: {entry.log_dir}") + typer.echo(f" Run dir: {info.run_dir}") @main.command() def stop( + name: str = typer.Argument("", help="Instance name (optional)"), force: bool = typer.Option(False, "--force", "-f", help="Force kill (SIGKILL)"), ) -> None: - """Stop the running DimOS instance.""" - - entry = get_most_recent(alive_only=True) - if not entry: - typer.echo("No running DimOS instance", err=True) - raise typer.Exit(1) + """Stop a running DimOS instance.""" + if name: + info = get(name) + if not info: + typer.echo(f"No running instance named '{name}'", err=True) + raise typer.Exit(1) + else: + running = list_running() + if len(running) == 0: + typer.echo("No running DimOS instances", err=True) + raise typer.Exit(1) + if len(running) > 1: + typer.echo("Multiple instances running. Specify a name:", err=True) + for r in running: + typer.echo(f" {r.name} (PID {r.pid}, blueprint: {r.blueprint})") + raise typer.Exit(1) + info = running[0] sig_name = "SIGKILL" if force else "SIGTERM" - typer.echo(f"Stopping {entry.run_id} (PID {entry.pid}) with {sig_name}...") - msg, _ok = stop_entry(entry, force=force) + typer.echo(f"Stopping {info.name} (PID {info.pid}) with {sig_name}...") + msg, _ok = registry_stop(info.name, force=force) typer.echo(f" {msg}") @main.command("log") def log_cmd( + name: str = typer.Argument("", help="Instance name (optional)"), follow: bool = typer.Option(False, "--follow", "-f", help="Follow log output"), lines: int = typer.Option(50, "--lines", "-n", help="Number of lines to show"), all_lines: bool = typer.Option(False, "--all", "-a", help="Show full log"), json_output: bool = typer.Option(False, "--json", help="Raw JSONL output"), - run_id: str = typer.Option("", "--run", "-r", help="Specific run ID"), + run_datetime: str = typer.Option("", "--run", "-r", help="Specific run datetime"), ) -> None: """View logs from a DimOS run.""" from dimos.core.log_viewer import follow_log, format_line, read_log, resolve_log_path - path = resolve_log_path(run_id) + path = resolve_log_path(name=name, run_datetime=run_datetime) if not path: typer.echo("No log files found", err=True) raise typer.Exit(1) @@ -436,28 +490,25 @@ def agent_send_cmd( @main.command() def restart( + name: str = typer.Argument("", help="Instance name (optional)"), force: bool = typer.Option(False, "--force", "-f", help="Force kill before restarting"), ) -> None: - """Restart the running DimOS instance with the same arguments.""" - entry = get_most_recent(alive_only=True) - if not entry: - typer.echo("No running DimOS instance to restart", err=True) - raise typer.Exit(1) + """Restart a running DimOS instance with the same arguments.""" + info = _resolve_name(name or None) - if not entry.original_argv: - typer.echo("Cannot restart: run entry missing original command", err=True) + if not info.original_argv: + typer.echo("Cannot restart: instance missing original command", err=True) raise typer.Exit(1) - # Save argv and pid before stopping (stop removes the entry) - argv = entry.original_argv - old_pid = entry.pid + argv = info.original_argv + old_pid = info.pid + instance_name = info.name - typer.echo(f"Restarting {entry.run_id} ({entry.blueprint})...") - msg, _ok = stop_entry(entry, force=force) + typer.echo(f"Restarting {instance_name} ({info.blueprint})...") + msg, _ok = registry_stop(instance_name, force=force) typer.echo(f" {msg}") - # Wait for the old process to fully exit so ports are released. - for _ in range(20): # up to 2s + for _ in range(20): if not is_pid_alive(old_pid): break time.sleep(0.1) diff --git a/dimos/utils/cli/dio/confirm_screen.py b/dimos/utils/cli/dio/confirm_screen.py index af2c8d306d..5a67cade4e 100644 --- a/dimos/utils/cli/dio/confirm_screen.py +++ b/dimos/utils/cli/dio/confirm_screen.py @@ -29,6 +29,14 @@ class ConfirmScreen(ModalScreen[bool]): padding: 1 2; } + ConfirmScreen.--warning > Vertical { + border: solid $dio-yellow; + } + + ConfirmScreen.--warning > Vertical > Label { + color: $dio-yellow; + } + ConfirmScreen > Vertical > Label { width: 100%; content-align: center middle; @@ -61,6 +69,18 @@ class ConfirmScreen(ModalScreen[bool]): color: $dio-bg; border: solid $dio-accent; } + + ConfirmScreen.--warning Button:focus { + background: $dio-yellow; + color: $dio-bg; + border: solid $dio-yellow; + } + + ConfirmScreen.--warning Button:hover { + background: $dio-yellow; + color: $dio-bg; + border: solid $dio-yellow; + } """ BINDINGS = [ @@ -76,12 +96,15 @@ class ConfirmScreen(ModalScreen[bool]): Binding("tab", "switch_btn", "Tab", priority=True), ] - def __init__(self, message: str, default: bool = False) -> None: + def __init__(self, message: str, default: bool = False, warning: bool = False) -> None: super().__init__() self._message = message self._default = default + self._warning = warning def compose(self) -> ComposeResult: + if self._warning: + self.add_class("--warning") with Vertical(): yield Label(self._message) with Center(): diff --git a/dimos/utils/cli/dio/sub_apps/humancli.py b/dimos/utils/cli/dio/sub_apps/humancli.py index a549598986..ddd09122bc 100644 --- a/dimos/utils/cli/dio/sub_apps/humancli.py +++ b/dimos/utils/cli/dio/sub_apps/humancli.py @@ -55,6 +55,7 @@ def _status_style(state: _ConnState) -> tuple[str, str]: _ConnState.ERROR: ("error", theme.RED), }[state] + # Seconds to wait for an agent response before showing "no agent" _AGENT_DETECT_TIMEOUT = 8.0 diff --git a/dimos/utils/cli/dio/sub_apps/launcher.py b/dimos/utils/cli/dio/sub_apps/launcher.py index 6dfbae2e94..093c32eb95 100644 --- a/dimos/utils/cli/dio/sub_apps/launcher.py +++ b/dimos/utils/cli/dio/sub_apps/launcher.py @@ -16,10 +16,7 @@ from __future__ import annotations -import os from pathlib import Path -import subprocess -import sys import threading from typing import TYPE_CHECKING, Any @@ -32,47 +29,25 @@ from textual.app import ComposeResult -def _launch_log_dir() -> Path: - """Base directory for launch logs.""" - xdg = os.environ.get("XDG_STATE_HOME") - base = Path(xdg) / "dimos" if xdg else Path.home() / ".local" / "state" / "dimos" - base.mkdir(parents=True, exist_ok=True) - return base - - -def _launch_log_path() -> Path: - """Well-known path for launch stdout/stderr (with ANSI colors).""" - return _launch_log_dir() / "launch.log" - - -def _launch_log_plain_path() -> Path: - """Well-known path for launch stdout/stderr (plain text, no ANSI).""" - return _launch_log_dir() / "launch.plain.log" - - -def _copy_plain_log_to_run_dir(plain_file: Path) -> None: - """Copy the plain launch log into the most recent run's log directory.""" - import shutil - +def _list_running_names() -> list[str]: + """Return names of currently running blueprints.""" try: - from dimos.core.run_registry import get_most_recent + from dimos.core.instance_registry import list_running - entry = get_most_recent(alive_only=False) - if entry and entry.log_dir: - dest = Path(entry.log_dir) / "launch.log" - shutil.copy2(plain_file, dest) + return [info.blueprint for info in list_running()] except Exception: - pass + return [] -def _is_blueprint_running() -> bool: - """Return True if a blueprint is currently running.""" +def _debug_log(msg: str) -> None: + """Append to the DIO debug log file.""" try: - from dimos.core.run_registry import get_most_recent - - return get_most_recent(alive_only=True) is not None + from dimos.core.instance_registry import dimos_home + log_path = dimos_home() / "dio-debug.log" + with open(log_path, "a") as f: + f.write(f"LAUNCHER: {msg}\n") except Exception: - return False + pass class LauncherSubApp(SubApp): @@ -180,35 +155,28 @@ def _rebuild_list(self) -> None: @property def _is_locked(self) -> bool: - """True if launching is blocked (already running or mid-launch).""" - return self._launching or _is_blueprint_running() + """True only while a launch is in progress.""" + return self._launching def _sync_status(self) -> None: status = self.query_one("#launch-status", Static) - locked = self._is_locked filter_input = self.query_one("#launch-filter", Input) lv = self.query_one("#launch-list", ListView) - if locked: + if self._launching: self.add_class("--locked") filter_input.disabled = True lv.disabled = True - else: - self.remove_class("--locked") - filter_input.disabled = False - lv.disabled = False - - if self._launching: return # don't overwrite "Launching..." message - if _is_blueprint_running(): - try: - from dimos.core.run_registry import get_most_recent - entry = get_most_recent(alive_only=True) - name = entry.blueprint if entry else "unknown" - status.update(f"Already running: {name} — stop it first") - except Exception: - status.update("A blueprint is already running") + self.remove_class("--locked") + filter_input.disabled = False + lv.disabled = False + + running = _list_running_names() + if running: + names = ", ".join(running) + status.update(f"Running: {names} | Enter: launch another") else: status.update("Up/Down: navigate | Enter: launch | Type to filter") @@ -222,21 +190,49 @@ def on_input_changed(self, event: Input.Changed) -> None: self._rebuild_list() def on_input_submitted(self, event: Input.Submitted) -> None: + _debug_log(f"on_input_submitted: id={event.input.id} locked={self._is_locked}") if event.input.id == "launch-filter": if self._is_locked: return lv = self.query_one("#launch-list", ListView) idx = lv.index + _debug_log(f"on_input_submitted: idx={idx} filtered_len={len(self._filtered)}") if idx is not None and 0 <= idx < len(self._filtered): - self._launch(self._filtered[idx]) + _debug_log(f"on_input_submitted: confirming {self._filtered[idx]}") + self._confirm_and_launch(self._filtered[idx]) def on_list_view_selected(self, event: ListView.Selected) -> None: + _debug_log(f"on_list_view_selected: locked={self._is_locked}") if self._is_locked: return lv = self.query_one("#launch-list", ListView) idx = lv.index if idx is not None and 0 <= idx < len(self._filtered): - self._launch(self._filtered[idx]) + _debug_log(f"on_list_view_selected: confirming {self._filtered[idx]}") + self._confirm_and_launch(self._filtered[idx]) + + def _confirm_and_launch(self, name: str) -> None: + from dimos.utils.cli.dio.confirm_screen import ConfirmScreen + + running = _list_running_names() + _debug_log(f"_confirm_and_launch: name={name} running={running}") + if running: + names = ", ".join(running) + message = ( + f"The {names} blueprint{'s are' if len(running) > 1 else ' is'} " + f"already running, are you sure you want to start {name}?" + ) + warning = True + else: + message = f"Launch {name}?" + warning = False + + def _on_confirm(result: bool) -> None: + _debug_log(f"_on_confirm: result={result}") + if result: + self._launch(name) + + self.app.push_screen(ConfirmScreen(message, warning=warning), _on_confirm) def on_key(self, event: Any) -> None: key = getattr(event, "key", "") @@ -254,6 +250,7 @@ def on_key(self, event: Any) -> None: event.stop() def _launch(self, name: str) -> None: + _debug_log(f"_launch called: name={name} locked={self._is_locked}") if self._is_locked: self._sync_status() return @@ -263,77 +260,59 @@ def _launch(self, name: str) -> None: status = self.query_one("#launch-status", Static) status.update(f"Launching {name}...") - # Gather config overrides - config_args: list[str] = [] + # Gather config overrides as a Python dict (no CLI arg conversion) + config_overrides: dict[str, object] = {} try: from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp for inst in self.app._instances: # type: ignore[attr-defined] if isinstance(inst, ConfigSubApp): - for k, v in inst.get_overrides().items(): - cli_key = k.replace("_", "-") - if isinstance(v, bool): - config_args.append(f"--{cli_key}" if v else f"--no-{cli_key}") - else: - config_args.extend([f"--{cli_key}", str(v)]) + config_overrides = inst.get_overrides() break except Exception: pass - cmd = [sys.executable, "-m", "dimos.robot.cli.dimos", *config_args, "run", "--daemon", name] + _debug_log(f"_launch: config_overrides={config_overrides}") def _do_launch() -> None: - import re - - _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") - log_file = _launch_log_path() - plain_file = _launch_log_plain_path() - # Preserve ANSI colors in piped output - env = os.environ.copy() - env["FORCE_COLOR"] = "1" - env["PYTHONUNBUFFERED"] = "1" - env["TERM"] = env.get("TERM", "xterm-256color") + _debug_log("_do_launch: thread started") try: - with ( - open(log_file, "w") as f_color, - open(plain_file, "w") as f_plain, - ): - proc = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=env, - start_new_session=True, - ) - for raw_line in proc.stdout: # type: ignore[union-attr] - line = raw_line.decode("utf-8", errors="replace") - f_color.write(line) - f_color.flush() - f_plain.write(_ANSI_RE.sub("", line)) - f_plain.flush() - proc.wait() - rc = proc.returncode - - # Copy plain log into the run's log directory for archival - _copy_plain_log_to_run_dir(plain_file) + from dimos.core.daemon import launch_blueprint + + _debug_log(f"_do_launch: calling launch_blueprint(robot_types=[{name}])") + result = launch_blueprint( + robot_types=[name], + config_overrides=config_overrides, + force_replace=False, + ) + _debug_log(f"_do_launch: success! instance={result.instance_name} run_dir={result.run_dir}") def _after() -> None: self._launching = False - if rc != 0: - s = self.query_one("#launch-status", Static) - s.update(f"Launch failed (exit code {rc})") + # Tell StatusSubApp immediately + self._notify_runner(result.instance_name, result.run_dir) self._sync_status() self.app.call_from_thread(_after) - except Exception: + except Exception as e: + import traceback + _debug_log(f"_do_launch: EXCEPTION: {e}\n{traceback.format_exc()}") def _err() -> None: self._launching = False - s = self.query_one("#launch-status", Static) - s.update(f"Launch error: {e}") + self.query_one("#launch-status", Static).update(f"Launch error: {e}") + self.app.notify(f"Launch failed: {e}", severity="error", timeout=10) self._sync_status() self.app.call_from_thread(_err) threading.Thread(target=_do_launch, daemon=True).start() + + def _notify_runner(self, instance_name: str, run_dir: Path) -> None: + """Tell the StatusSubApp about the just-launched instance.""" + from dimos.utils.cli.dio.sub_apps.runner import StatusSubApp + + for inst in self.app._instances: # type: ignore[attr-defined] + if isinstance(inst, StatusSubApp): + inst.on_launch_started(instance_name, run_dir) + break diff --git a/dimos/utils/cli/dio/sub_apps/lcmspy.py b/dimos/utils/cli/dio/sub_apps/lcmspy.py index 6b692dc8c5..b04b3f7a26 100644 --- a/dimos/utils/cli/dio/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dio/sub_apps/lcmspy.py @@ -21,7 +21,6 @@ from rich.text import Text from textual.widgets import DataTable -from dimos.utils.cli import theme from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: @@ -78,8 +77,9 @@ def _init_lcm(self) -> None: self._spy = GraphLCMSpy(graph_log_window=0.5) self._spy.start() - except Exception as e: + except Exception: import traceback + self._debug(traceback.format_exc()) def on_unmount_subapp(self) -> None: diff --git a/dimos/utils/cli/dio/sub_apps/runner.py b/dimos/utils/cli/dio/sub_apps/runner.py index d797bfdc9e..39caf2da5f 100644 --- a/dimos/utils/cli/dio/sub_apps/runner.py +++ b/dimos/utils/cli/dio/sub_apps/runner.py @@ -12,7 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Status sub-app — log viewer and blueprint lifecycle controls.""" +"""Status sub-app — log viewer and blueprint lifecycle controls. + +Supports multiple concurrent running blueprints via an instance picker. +""" from __future__ import annotations @@ -36,20 +39,23 @@ from textual.app import ComposeResult -def _launch_log_dir() -> Path: - """Base directory for launch logs.""" - xdg = os.environ.get("XDG_STATE_HOME") - return Path(xdg) / "dimos" if xdg else Path.home() / ".local" / "state" / "dimos" - +def _get_all_running() -> list[Any]: + """Return all running InstanceInfo objects.""" + try: + from dimos.core.instance_registry import list_running -def _launch_log_path() -> Path: - """Well-known path for launch stdout/stderr (with ANSI colors).""" - return _launch_log_dir() / "launch.log" + return list_running() + except Exception: + return [] -def _launch_log_plain_path() -> Path: - """Well-known path for launch stdout/stderr (plain text, no ANSI).""" - return _launch_log_dir() / "launch.plain.log" +def _stdout_log_path(info: Any) -> Path | None: + """Return the stdout.log path for a running instance.""" + if info and getattr(info, "run_dir", None): + p = Path(info.run_dir) / "stdout.log" + if p.exists(): + return p + return None class StatusSubApp(SubApp): @@ -83,6 +89,26 @@ class StatusSubApp(SubApp): width: auto; background: transparent; } + StatusSubApp #instance-picker { + height: auto; + padding: 0 1; + background: $dio-bg; + } + StatusSubApp #instance-picker Button { + margin: 0 1 0 0; + min-width: 8; + background: transparent; + border: solid $dio-dim; + color: $dio-dim; + } + StatusSubApp #instance-picker Button.--selected { + border: solid $dio-accent; + color: $dio-accent; + text-style: bold; + } + StatusSubApp #instance-picker Button:focus { + background: $dio-panel-bg; + } StatusSubApp #run-controls { height: auto; padding: 0 1; @@ -154,17 +180,30 @@ class StatusSubApp(SubApp): def __init__(self) -> None: super().__init__() - self._running_entry: Any = None + # Multi-instance tracking + self._running_entries: list[Any] = [] + self._selected_name: str | None = None # name of the selected instance + # Launching state (pre-build) + self._launching_name: str | None = None + self._launching_run_dir: Path | None = None + self._stopping: bool = False self._log_thread: threading.Thread | None = None self._stop_log = False self._failed_stop_pid: int | None = None - self._following_launch_log = False - self._launch_log_mtime: float = 0.0 self._poll_count = 0 self._last_click_time: float = 0.0 self._last_click_y: int = -1 self._saved_status: str = "" + @property + def _selected_entry(self) -> Any: + """Return the currently selected running entry, or None.""" + if self._selected_name: + for e in self._running_entries: + if getattr(e, "name", None) == self._selected_name: + return e + return None + def _debug(self, msg: str) -> None: """Log to the DIO debug panel if available.""" try: @@ -176,6 +215,8 @@ def compose(self) -> ComposeResult: yield Static("Blueprint Status", classes="subapp-header") with VerticalScroll(id="idle-container"): yield Static(self._idle_panel(), id="idle-panel") + with Horizontal(id="instance-picker"): + pass # populated dynamically yield RichLog(id="runner-log", markup=True, wrap=False, auto_scroll=True, min_width=600) with Horizontal(id="run-controls"): yield Button("Stop", id="btn-stop", variant="error") @@ -194,27 +235,21 @@ def _idle_panel(self) -> Panel: def on_mount_subapp(self) -> None: self._debug("on_mount_subapp called") - self._check_running() - entry = self._running_entry - self._debug(f"initial check: entry={getattr(entry, 'run_id', None)}") - if entry is not None: - self._debug("-> _show_running") + self._refresh_entries() + if self._running_entries: + self._selected_name = self._running_entries[0].name + self._debug(f"-> _show_running (initial: {self._selected_name})") self._show_running() else: - self._check_launch_log() - if not self._following_launch_log: - self._debug("-> _show_idle") - self._show_idle() - else: - self._debug("-> following launch log") + self._debug("-> _show_idle") + self._show_idle() self._start_poll_timer() def on_resume_subapp(self) -> None: self._debug("on_resume_subapp: restarting timer after remount") self._start_poll_timer() - # Re-sync UI state with current data - self._check_running() - if self._running_entry is not None: + self._refresh_entries() + if self._running_entries: self._show_running() def _start_poll_timer(self) -> None: @@ -222,112 +257,191 @@ def _start_poll_timer(self) -> None: self._debug("timer started") def get_focus_target(self) -> object | None: - if self._running_entry is not None: + if self._running_entries or self._launching_name is not None: try: return self.query_one("#runner-log", RichLog) except Exception: pass return super().get_focus_target() + # ------------------------------------------------------------------ + # Instance picker + # ------------------------------------------------------------------ + + def _rebuild_picker(self) -> None: + """Rebuild the instance picker buttons from current entries.""" + picker = self.query_one("#instance-picker", Horizontal) + picker.remove_children() + + # Collect names: running entries + launching entry if not yet registered + names: list[str] = [getattr(e, "name", "?") for e in self._running_entries] + if self._launching_name and self._launching_name not in names: + names.append(self._launching_name) + + if len(names) <= 1: + # No need for picker with 0 or 1 instance + picker.styles.display = "none" + return + + picker.styles.display = "block" + for name in names: + btn = Button(name, id=f"pick-{name}") + if name == self._selected_name or name == self._launching_name: + btn.add_class("--selected") + picker.mount(btn) + + def _on_picker_pressed(self, name: str) -> None: + """Handle an instance picker button press.""" + if name == self._selected_name: + return + + self._debug(f"picker: switching to {name}") + self._selected_name = name + self._stop_log = True + + # Update picker button styles + picker = self.query_one("#instance-picker", Horizontal) + for child in picker.children: + if isinstance(child, Button): + if getattr(child, "id", "") == f"pick-{name}": + child.add_class("--selected") + else: + child.remove_class("--selected") + + # Switch log and status to selected entry + entry = self._selected_entry + if entry: + self._show_running_for_entry(entry) + elif name == self._launching_name and self._launching_run_dir: + # Still in launching phase + log_widget = self.query_one("#runner-log", RichLog) + log_widget.clear() + self._start_log_follow_from_path(self._launching_run_dir / "stdout.log") + status = self.query_one("#runner-status", Static) + status.update(f"Launching: {name}...") + + # ------------------------------------------------------------------ + # Launcher notification (immediate feedback) + # ------------------------------------------------------------------ + + def on_launch_started(self, instance_name: str, run_dir: Path) -> None: + """Called by launcher immediately after launch_blueprint() returns. + + Shows UI controls before the daemon has finished building. + """ + self._debug(f"on_launch_started: {instance_name} at {run_dir}") + self._launching_name = instance_name + self._launching_run_dir = run_dir + self._selected_name = instance_name + self._stopping = False + + # Show controls for the launching state + self.query_one("#idle-container").styles.display = "none" + self.query_one("#runner-log").styles.display = "block" + self.query_one("#run-controls").styles.display = "block" + self.query_one("#btn-stop").styles.display = "block" + self.query_one("#btn-sudo-kill").styles.display = "none" + self.query_one("#btn-restart").styles.display = "none" # no restart during build + self.query_one("#btn-open-log").styles.display = "block" + self._failed_stop_pid = None + + status = self.query_one("#runner-status", Static) + status.update(f"Launching: {instance_name}...") + + # Clear log and start tailing the new run_dir's stdout.log + log_widget = self.query_one("#runner-log", RichLog) + self._stop_log = True # stop any existing tail + log_widget.clear() + self._start_log_follow_from_path(run_dir / "stdout.log") + + # Rebuild picker (may now show multiple entries) + self._rebuild_picker() + # ------------------------------------------------------------------ # State management # ------------------------------------------------------------------ - def _check_running(self) -> None: + def _refresh_entries(self) -> None: + """Update the list of running entries from the registry.""" try: - from dimos.core.run_registry import get_most_recent - - self._running_entry = get_most_recent(alive_only=True) + self._running_entries = _get_all_running() except Exception as e: - self._debug(f"_check_running exception: {e}") - self._running_entry = None + self._debug(f"_refresh_entries exception: {e}") + self._running_entries = [] def _poll_running(self) -> None: self._poll_count += 1 - old_entry = self._running_entry - self._check_running() - new_entry = self._running_entry - - old_id = getattr(old_entry, "run_id", None) - new_id = getattr(new_entry, "run_id", None) + old_entries = self._running_entries + old_names = {getattr(e, "name", None) for e in old_entries} + self._refresh_entries() + new_names = {getattr(e, "name", None) for e in self._running_entries} + + # If we're in launching state and the matching instance appeared -> transition + if self._launching_name is not None and self._launching_name in new_names: + self._debug(f"launch completed: {self._launching_name} -> running") + completed_name = self._launching_name + self._launching_name = None + self._launching_run_dir = None + # If the completed instance is selected, show restart button + if self._selected_name == completed_name: + self.query_one("#btn-restart").styles.display = "block" + entry = self._selected_entry + if entry: + status = self.query_one("#runner-status", Static) + status.update(self._format_status_line(entry)) + self._rebuild_picker() + return - # Log every 10th poll or on state change - changed = old_id != new_id + changed = old_names != new_names if changed or self._poll_count % 10 == 1: self._debug( - f"poll #{self._poll_count}: old={old_id} new={new_id} " - f"changed={changed} following_launch={self._following_launch_log}" + f"poll #{self._poll_count}: old={old_names} new={new_names} changed={changed}" ) if changed: - if new_entry is not None: - self._debug(f"-> _show_running (new entry: {new_id})") + self._rebuild_picker() + + # If nothing is running anymore + if not self._running_entries and self._launching_name is None: + self._debug("-> all instances gone") + self._selected_name = None self._stop_log = True - self._following_launch_log = False - self._show_running() + self._show_stopped("All processes ended") return - elif old_entry is not None: - self._debug(f"-> _show_stopped (entry gone: {old_id})") + + # If selected instance disappeared + if self._selected_name and self._selected_name not in new_names: + # The selected instance died — if launching, keep showing that + if self._launching_name == self._selected_name: + return + self._debug(f"selected instance {self._selected_name} gone") self._stop_log = True - self._following_launch_log = False - self._show_stopped("Process ended") + if self._running_entries: + # Switch to another running instance + self._selected_name = self._running_entries[0].name + self._show_running() + else: + self._selected_name = None + self._show_stopped("Process ended") return - # If nothing is running yet, check for a fresh launch log - if new_entry is None and not self._following_launch_log: - self._check_launch_log() - - def _check_launch_log(self) -> None: - """Detect a new/updated launch.log and start tailing it.""" - log_path = _launch_log_path() - try: - mtime = log_path.stat().st_mtime - except FileNotFoundError: - return - age = time.time() - mtime - if mtime <= self._launch_log_mtime: - return - if age > 30: - return - self._debug(f"_check_launch_log: new launch.log detected (age={age:.1f}s)") - self._launch_log_mtime = mtime - self._following_launch_log = True - self._show_launching(log_path) - - def _show_launching(self, log_path: Path) -> None: - """Show the launch log output while the daemon is starting.""" - self._debug(f"_show_launching: {log_path}") - self.query_one("#idle-container").styles.display = "none" - self.query_one("#runner-log").styles.display = "block" - self.query_one("#run-controls").styles.display = "none" - status = self.query_one("#runner-status", Static) - status.update("Launching blueprint... — double-click log to open") - - self._stop_log = False - log_widget = self.query_one("#runner-log", RichLog) - log_widget.clear() - - def _tail() -> None: - try: - with open(log_path) as f: - while not self._stop_log: - line = f.readline() - if line: - rendered = Text.from_ansi(line.rstrip("\n")) - self.app.call_from_thread(self._write_log_line, log_widget, rendered) - else: - time.sleep(0.2) - except Exception as e: - self.app.call_from_thread( - self._write_log_line, log_widget, f"[red]Error reading launch log: {e}[/red]" - ) - - self._log_thread = threading.Thread(target=_tail, daemon=True) - self._log_thread.start() + # New instance appeared (maybe launched externally) + added = new_names - old_names + if added and not self._selected_name: + # Auto-select the new one if nothing selected + self._selected_name = self._running_entries[0].name + self._show_running() def _show_running(self) -> None: - """Show controls for a running blueprint.""" - self._debug("_show_running: setting widget display states") + """Show controls for the selected running blueprint.""" + entry = self._selected_entry + if entry: + self._show_running_for_entry(entry) + self._rebuild_picker() + + def _show_running_for_entry(self, entry: Any) -> None: + """Show controls for a specific running entry.""" + self._debug(f"_show_running_for_entry: {getattr(entry, 'name', '?')}") try: self.query_one("#idle-container").styles.display = "none" self.query_one("#runner-log").styles.display = "block" @@ -337,18 +451,19 @@ def _show_running(self) -> None: self.query_one("#btn-restart").styles.display = "block" self.query_one("#btn-open-log").styles.display = "block" self._failed_stop_pid = None - entry = self._running_entry - if entry: - status = self.query_one("#runner-status", Static) - status.update(self._format_status_line(entry)) - self._debug(f"_show_running: starting log follow for {entry.run_id}") - self._start_log_follow(entry) - self._debug("_show_running: done") + + status = self.query_one("#runner-status", Static) + status.update(self._format_status_line(entry)) + self._debug(f"starting log follow for {entry.name}") + self._start_log_follow(entry) except Exception as e: - self._debug(f"_show_running CRASHED: {e}") + self._debug(f"_show_running_for_entry CRASHED: {e}") def _show_stopped(self, message: str = "Stopped") -> None: """Show controls for a stopped state with logs still visible.""" + self._launching_name = None + self._launching_run_dir = None + self._stopping = False self.query_one("#idle-container").styles.display = "none" self.query_one("#runner-log").styles.display = "block" self.query_one("#run-controls").styles.display = "block" @@ -357,11 +472,9 @@ def _show_stopped(self, message: str = "Stopped") -> None: self.query_one("#btn-restart").styles.display = "none" self.query_one("#btn-open-log").styles.display = "block" self._failed_stop_pid = None - self._following_launch_log = False - # Reset so the next launch.log write is always detected - self._launch_log_mtime = 0.0 status = self.query_one("#runner-status", Static) status.update(message) + self._rebuild_picker() def _show_idle(self) -> None: """Show big idle message — no blueprint running.""" @@ -369,26 +482,28 @@ def _show_idle(self) -> None: self.query_one("#idle-container").styles.display = "block" self.query_one("#runner-log").styles.display = "none" self.query_one("#run-controls").styles.display = "none" + self.query_one("#instance-picker").styles.display = "none" self._failed_stop_pid = None - self._following_launch_log = False - self._launch_log_mtime = 0.0 + self._launching_name = None + self._launching_run_dir = None + self._selected_name = None - # Check if there are past logs to show in status bar - has_past = False + # Check if there are past runs try: - from dimos.core.run_registry import get_most_recent - - entry = get_most_recent() - if entry: - has_past = True - status = self.query_one("#runner-status", Static) - status.update(f"Last run: {entry.blueprint} (run {entry.run_id})") + from dimos.core.instance_registry import _instances_dir + base = _instances_dir() + if base.exists(): + for child in sorted(base.iterdir(), reverse=True): + runs = child / "runs" + if runs.exists() and any(runs.iterdir()): + status = self.query_one("#runner-status", Static) + status.update(f"Last run: {child.name}") + return except Exception: pass - if not has_past: - status = self.query_one("#runner-status", Static) - status.update("No blueprint running") + status = self.query_one("#runner-status", Static) + status.update("No blueprint running") # ------------------------------------------------------------------ # Entry info formatting @@ -398,7 +513,9 @@ def _show_idle(self) -> None: def _format_status_line(entry: Any) -> str: """One-line status bar summary including config overrides.""" overrides = getattr(entry, "config_overrides", None) or {} - parts = [f"Running: {entry.blueprint} (PID {entry.pid}) — double-click log to open"] + name = getattr(entry, "name", getattr(entry, "blueprint", "?")) + pid = getattr(entry, "pid", "?") + parts = [f"Running: {name} (PID {pid}) — double-click log to open"] if overrides: flags = " ".join( f"--{k.replace('_', '-')}" @@ -429,7 +546,6 @@ def _format_launch_header(entry: Any) -> list[str]: # Log streaming # ------------------------------------------------------------------ - # Rich styles matching the structlog compact console color scheme _LEVEL_STYLES: dict[str, str] = { "dbg": "bold cyan", "deb": "bold cyan", @@ -481,41 +597,73 @@ def _write_log_line(self, log_widget: RichLog, rendered: Text | str) -> None: log_widget.write(rendered) def _start_log_follow(self, entry: Any) -> None: + """Tail stdout.log from the instance's run directory.""" self._stop_log = False log_widget = self.query_one("#runner-log", RichLog) + + # If a tail is already running, stop it + if self._log_thread is not None and self._log_thread.is_alive(): + self._stop_log = True + self._log_thread.join(timeout=1.0) + self._stop_log = False + log_widget.clear() # Print launch info header for line in self._format_launch_header(entry): self._write_log_line(log_widget, line) + # Use stdout.log from the instance's run directory + log_path = _stdout_log_path(entry) + if log_path: + self._start_log_follow_from_path(log_path) + + def _start_log_follow_from_path(self, log_path: Path) -> None: + """Tail a log file path, waiting for it to appear if needed.""" + self._stop_log = False + log_widget = self.query_one("#runner-log", RichLog) + + # If a tail is already running, stop it first + if self._log_thread is not None and self._log_thread.is_alive(): + self._stop_log = True + self._log_thread.join(timeout=1.0) + self._stop_log = False + def _follow() -> None: try: - from dimos.core.log_viewer import ( - follow_log, - read_log, - resolve_log_path, - ) - - path = resolve_log_path(entry.run_id) - if not path: + # Wait for log file to appear + if not log_path.exists(): self.app.call_from_thread( - self._write_log_line, log_widget, "[dim]No log file found[/dim]" + self._write_log_line, log_widget, "[dim]Waiting for log...[/dim]" ) - return - - for line in read_log(path, 50): - if self._stop_log: + for _ in range(150): # ~30s + if self._stop_log: + return + if log_path.exists(): + break + time.sleep(0.2) + else: + self.app.call_from_thread( + self._write_log_line, log_widget, "[dim]Log file did not appear[/dim]" + ) return - rendered = self._format_jsonl_line(line) - self.app.call_from_thread(self._write_log_line, log_widget, rendered) - for line in follow_log(path, stop=lambda: self._stop_log): - rendered = self._format_jsonl_line(line) - self.app.call_from_thread(self._write_log_line, log_widget, rendered) + with open(log_path) as f: + while not self._stop_log: + line = f.readline() + if line: + rendered = Text.from_ansi(line.rstrip("\n")) + self.app.call_from_thread( + self._write_log_line, log_widget, rendered + ) + else: + time.sleep(0.2) except Exception as e: self.app.call_from_thread( self._write_log_line, log_widget, f"[red]Error: {e}[/red]" ) + self.app.call_from_thread( + self.app.notify, f"Log follow error: {e}", severity="error", timeout=8, + ) self._log_thread = threading.Thread(target=_follow, daemon=True) self._log_thread.start() @@ -550,13 +698,11 @@ def on_click(self, event: Any) -> None: if is_double: self._handle_double_click(event) else: - # Save current status and show hint status = self.query_one("#runner-status", Static) current = status.renderable if not isinstance(current, str) or "double-click" not in current: self._saved_status = str(current) status.update("double-click to open log file") - # Restore after 2 seconds self.set_timer(2.0, self._restore_status) def _restore_status(self) -> None: @@ -573,38 +719,34 @@ def _get_clicked_line_number(self, event: Any) -> int: """Map a click event to a 1-based line number in the log file.""" try: log_widget = self.query_one("#runner-log", RichLog) - # Convert screen_y to position relative to the RichLog local_y = event.screen_y - log_widget.region.y - # Add scroll offset to get the visual line index line_idx = int(log_widget.scroll_y) + local_y - # 1-based for editors return max(1, line_idx + 1) except Exception: return 1 def _handle_double_click(self, event: Any) -> None: - """Open the plain (no ANSI) launch log in the user's editor.""" + """Open the stdout.log in the user's editor.""" lineno = self._get_clicked_line_number(event) - log_path = _launch_log_plain_path() - if log_path.exists(): + + entry = self._selected_entry + log_path = _stdout_log_path(entry) if entry else None + + # Fallback to launching run_dir during build phase + if log_path is None and self._launching_run_dir is not None: + candidate = self._launching_run_dir / "stdout.log" + if candidate.exists(): + log_path = candidate + + if log_path and log_path.exists(): self._open_source_file(str(log_path), lineno) else: - # Fall back to colored version - log_path = _launch_log_path() - if log_path.exists(): - self._open_source_file(str(log_path), lineno) - else: - self.app.notify("No launch log found", severity="warning") + self.app.notify("No log found", severity="warning") def _open_source_file(self, file_path: str, lineno: int) -> None: - """Open a source file in the user's preferred GUI editor. - - Only launches background (GUI) editors — never suspends the TUI. - Falls back to copying the path to clipboard + notification. - """ + """Open a source file in the user's preferred GUI editor.""" import shutil - # Resolve relative paths against the project root full_path = Path(file_path) if not full_path.is_absolute(): for base in [Path.cwd(), Path(__file__).resolve().parents[5]]: @@ -621,10 +763,8 @@ def _open_source_file(self, file_path: str, lineno: int) -> None: self.app.notify(f"File not found, copied path: {loc}", severity="warning") return - # GUI editors that can be launched as background processes _GUI_EDITORS: list[tuple[str, list[str]]] = [] - # Check $VISUAL and $EDITOR for GUI editors for env_var in ("VISUAL", "EDITOR"): cmd = os.environ.get(env_var, "") if not cmd or not shutil.which(cmd): @@ -639,7 +779,6 @@ def _open_source_file(self, file_path: str, lineno: int) -> None: elif cmd_name in ("idea", "pycharm", "goland", "webstorm", "clion"): _GUI_EDITORS.append((cmd, ["--line", str(lineno), str(full_path)])) - # Fallback: try well-known GUI editors for cmd, args in [ ("code", ["-g", loc]), ("subl", [loc]), @@ -662,19 +801,21 @@ def _open_source_file(self, file_path: str, lineno: int) -> None: except Exception: continue - # No GUI editor found — copy path to clipboard as fallback self.app.copy_to_clipboard(loc) self.app.notify(f"Copied to clipboard: {loc}") def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn-stop": + btn_id = event.button.id or "" + if btn_id == "btn-stop": self._stop_running() - elif event.button.id == "btn-sudo-kill": + elif btn_id == "btn-sudo-kill": self._sudo_kill() - elif event.button.id == "btn-restart": + elif btn_id == "btn-restart": self._restart_running() - elif event.button.id == "btn-open-log": + elif btn_id == "btn-open-log": self._open_log_in_editor() + elif btn_id.startswith("pick-"): + self._on_picker_pressed(btn_id[5:]) def on_key(self, event: Any) -> None: key = getattr(event, "key", "") @@ -717,34 +858,42 @@ def _cycle_button_focus(self, delta: int) -> None: # ------------------------------------------------------------------ def _stop_running(self) -> None: + if self._stopping: + return + self._stopping = True self._stop_log = True log_widget = self.query_one("#runner-log", RichLog) status = self.query_one("#runner-status", Static) - status.update("Stopping blueprint...") + + entry = self._selected_entry + stop_name = getattr(entry, "name", None) or self._launching_name + status.update(f"Stopping {stop_name or 'blueprint'}...") + for bid in ("btn-stop", "btn-restart"): try: self.query_one(f"#{bid}", Button).disabled = True except Exception: pass - entry = self._running_entry - self._running_entry = None - def _do_stop() -> None: permission_error = False - if entry: + if stop_name: try: - from dimos.core.run_registry import stop_entry + from dimos.core.instance_registry import stop as registry_stop - msg, _ = stop_entry(entry) + msg, _ = registry_stop(stop_name) self.app.call_from_thread( log_widget.write, f"[{theme.YELLOW}]{msg}[/{theme.YELLOW}]" ) except PermissionError: permission_error = True + pid = getattr(entry, "pid", "?") self.app.call_from_thread( log_widget.write, - f"[red]Permission denied — cannot stop PID {entry.pid}[/red]", + f"[red]Permission denied — cannot stop PID {pid}[/red]", + ) + self.app.call_from_thread( + self.app.notify, f"Permission denied stopping PID {pid}", severity="error", timeout=8, ) except Exception as e: if ( @@ -753,8 +902,12 @@ def _do_stop() -> None: ): permission_error = True self.app.call_from_thread(log_widget.write, f"[red]Stop error: {e}[/red]") + self.app.call_from_thread( + self.app.notify, f"Stop error: {e}", severity="error", timeout=8, + ) def _after_stop() -> None: + self._stopping = False for bid in ("btn-stop", "btn-restart"): try: self.query_one(f"#{bid}", Button).disabled = False @@ -769,15 +922,22 @@ def _after_stop() -> None: f"Stop failed (permission denied) — try Force Kill for PID {entry.pid}" ) else: - self._show_stopped("Stopped") + # Refresh — if other instances still running, switch to one + self._refresh_entries() + if self._running_entries: + self._selected_name = self._running_entries[0].name + self._show_running() + else: + self._selected_name = None + self._show_stopped(f"Stopped {stop_name}") self.app.call_from_thread(_after_stop) threading.Thread(target=_do_stop, daemon=True).start() def _restart_running(self) -> None: - entry = self._running_entry - name = getattr(entry, "blueprint", None) + entry = self._selected_entry + name = getattr(entry, "name", None) or getattr(entry, "blueprint", None) if not name: return self._stop_log = True @@ -790,96 +950,67 @@ def _restart_running(self) -> None: except Exception: pass - old_entry = self._running_entry - self._running_entry = None + # Gather config overrides + config_overrides: dict[str, object] = {} + try: + from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp + + for inst in self.app._instances: # type: ignore[attr-defined] + if isinstance(inst, ConfigSubApp): + config_overrides = inst.get_overrides() + break + except Exception: + pass + + blueprint = getattr(entry, "blueprint", name) def _do_restart() -> None: - if old_entry: + # Stop old + if entry: try: - from dimos.core.run_registry import stop_entry + from dimos.core.instance_registry import stop as registry_stop - stop_entry(old_entry) + registry_stop(entry.name) except Exception: pass - config_args: list[str] = [] + # Launch new via launch_blueprint try: - from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp - - for inst in self.app._instances: # type: ignore[attr-defined] - if isinstance(inst, ConfigSubApp): - for k, v in inst.get_overrides().items(): - cli_key = k.replace("_", "-") - if isinstance(v, bool): - config_args.append(f"--{cli_key}" if v else f"--no-{cli_key}") - else: - config_args.extend([f"--{cli_key}", str(v)]) - break - except Exception: - pass + from dimos.core.daemon import launch_blueprint - cmd = [ - sys.executable, - "-m", - "dimos.robot.cli.dimos", - *config_args, - "run", - "--daemon", - name, - ] - env = os.environ.copy() - env["FORCE_COLOR"] = "1" - env["PYTHONUNBUFFERED"] = "1" - env["TERM"] = env.get("TERM", "xterm-256color") - try: - import re as _re - - _ANSI_RE = _re.compile(r"\x1b\[[0-9;]*m") - log_file = _launch_log_path() - plain_file = _launch_log_plain_path() - with ( - open(log_file, "w") as f_color, - open(plain_file, "w") as f_plain, - ): - proc = subprocess.Popen( - cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=env, - start_new_session=True, - ) - for raw_line in proc.stdout: # type: ignore[union-attr] - line = raw_line.decode("utf-8", errors="replace") - f_color.write(line) - f_color.flush() - f_plain.write(_ANSI_RE.sub("", line)) - f_plain.flush() - proc.wait() - - # Copy plain log into the run's log directory for archival - try: - from dimos.utils.cli.dio.sub_apps.launcher import _copy_plain_log_to_run_dir + result = launch_blueprint( + robot_types=[blueprint], + config_overrides=config_overrides, + force_replace=True, + ) - _copy_plain_log_to_run_dir(plain_file) - except Exception: - pass - except Exception as e: - self.app.call_from_thread(log_widget.write, f"[red]Restart error: {e}[/red]") + def _after() -> None: + for bid in ("btn-stop", "btn-restart"): + try: + self.query_one(f"#{bid}", Button).disabled = False + except Exception: + pass + self.on_launch_started(result.instance_name, result.run_dir) - def _after() -> None: - for bid in ("btn-stop", "btn-restart"): - try: - self.query_one(f"#{bid}", Button).disabled = False - except Exception: - pass - self._check_running() - if self._running_entry: - self._show_running() - else: - self._show_stopped("Restart failed") + self.app.call_from_thread(_after) + except Exception as e: - self.app.call_from_thread(_after) + def _err() -> None: + for bid in ("btn-stop", "btn-restart"): + try: + self.query_one(f"#{bid}", Button).disabled = False + except Exception: + pass + self.app.call_from_thread(log_widget.write, f"[red]Restart error: {e}[/red]") + self.app.notify(f"Restart failed: {e}", severity="error", timeout=10) + self._refresh_entries() + if self._running_entries: + self._selected_name = self._running_entries[0].name + self._show_running() + else: + self._show_stopped("Restart failed") + + self.app.call_from_thread(_err) threading.Thread(target=_do_restart, daemon=True).start() @@ -902,19 +1033,26 @@ def _do_kill() -> None: log_widget.write, f"[{theme.YELLOW}]Killed PID {pid} with sudo[/{theme.YELLOW}]", ) + # Clean up registry try: - from dimos.core.run_registry import get_most_recent + from dimos.core.instance_registry import list_running, unregister - entry = get_most_recent() - if entry and entry.pid == pid: - entry.remove() + for info in list_running(): + if info.pid == pid: + unregister(info.name) + break except Exception: pass def _after() -> None: self._failed_stop_pid = None - self._running_entry = None - self._show_stopped("Killed with sudo") + self._refresh_entries() + if self._running_entries: + self._selected_name = self._running_entries[0].name + self._show_running() + else: + self._selected_name = None + self._show_stopped("Killed with sudo") self.app.call_from_thread(_after) else: @@ -933,18 +1071,24 @@ def _after() -> None: f"[{theme.YELLOW}]Killed PID {pid} with sudo[/{theme.YELLOW}]", ) try: - from dimos.core.run_registry import get_most_recent + from dimos.core.instance_registry import list_running, unregister - entry = get_most_recent() - if entry and entry.pid == pid: - entry.remove() + for info in list_running(): + if info.pid == pid: + unregister(info.name) + break except Exception: pass def _after2() -> None: self._failed_stop_pid = None - self._running_entry = None - self._show_stopped("Killed with sudo") + self._refresh_entries() + if self._running_entries: + self._selected_name = self._running_entries[0].name + self._show_running() + else: + self._selected_name = None + self._show_stopped("Killed with sudo") self.app.call_from_thread(_after2) return @@ -953,6 +1097,9 @@ def _after2() -> None: log_widget.write, "[red]sudo kill failed — could not obtain sudo credentials[/red]", ) + self.app.call_from_thread( + self.app.notify, "sudo kill failed — no credentials", severity="error", timeout=8, + ) def _reenable() -> None: self.query_one("#btn-sudo-kill", Button).disabled = False @@ -960,6 +1107,9 @@ def _reenable() -> None: self.app.call_from_thread(_reenable) except Exception as e: self.app.call_from_thread(log_widget.write, f"[red]sudo kill error: {e}[/red]") + self.app.call_from_thread( + self.app.notify, f"sudo kill error: {e}", severity="error", timeout=8, + ) def _reenable2() -> None: self.query_one("#btn-sudo-kill", Button).disabled = False @@ -969,23 +1119,20 @@ def _reenable2() -> None: threading.Thread(target=_do_kill, daemon=True).start() def _open_log_in_editor(self) -> None: - """Open the log file in the user's editor (non-blocking).""" - try: - from dimos.core.log_viewer import resolve_log_path - - entry = self._running_entry - if entry: - path = resolve_log_path(entry.run_id) - else: - path = resolve_log_path() # most recent - - if not path: - self.app.notify("No log file found", severity="warning") - return - - self._open_source_file(str(path), 0) - except Exception: - pass + """Open the stdout.log in the user's editor (non-blocking).""" + entry = self._selected_entry + log_path = _stdout_log_path(entry) if entry else None + + # Fallback to launching run_dir during build phase + if log_path is None and self._launching_run_dir is not None: + candidate = self._launching_run_dir / "stdout.log" + if candidate.exists(): + log_path = candidate + + if log_path and log_path.exists(): + self._open_source_file(str(log_path), 0) + else: + self.app.notify("No log file found", severity="warning") def on_unmount_subapp(self) -> None: self._stop_log = True diff --git a/dimos/utils/logging_config.py b/dimos/utils/logging_config.py index dea16a1f7c..6524abfdd5 100644 --- a/dimos/utils/logging_config.py +++ b/dimos/utils/logging_config.py @@ -79,16 +79,15 @@ def get_run_log_dir() -> Path | None: def _get_log_directory() -> Path: - # Check if running from a git repository + # Use DIMOS_HOME (~/.dimos) for installed packages, project logs/ for dev if (DIMOS_PROJECT_ROOT / ".git").exists(): log_dir = DIMOS_LOG_DIR else: - # Running from an installed package - use XDG_STATE_HOME - xdg_state_home = os.getenv("XDG_STATE_HOME") - if xdg_state_home: - log_dir = Path(xdg_state_home) / "dimos" / "logs" + dimos_home = os.environ.get("DIMOS_HOME") + if dimos_home: + log_dir = Path(dimos_home) / "logs" else: - log_dir = Path.home() / ".local" / "state" / "dimos" / "logs" + log_dir = Path.home() / ".dimos" / "logs" try: log_dir.mkdir(parents=True, exist_ok=True) diff --git a/docs/development/daemon.md b/docs/development/daemon.md new file mode 100644 index 0000000000..951f0c1ae8 --- /dev/null +++ b/docs/development/daemon.md @@ -0,0 +1,214 @@ +# DimOS Daemon Architecture + +How blueprint processes are launched, daemonized, logged, and stopped. + +## Overview + +When a blueprint is launched (every `dimos run` daemonizes by default), the process **forks before `build()`** so no threads are killed. The daemon grandchild runs the build, sends a BUILD_OK sentinel through a pipe to the parent, and then loops forever. + +``` +User action What happens +───────────── ──────────── +DIO "launch" tab launcher.py spawns subprocess + or `dimos run --daemon` + ───────────────────────────────────────── + │ CLI parent: │ + │ Create run_dir, write config.json │ + │ Create os.pipe() for startup output │ + │ fork() │ + ───────────────────────────────────────── + │ + ┌─────────┴──────────────────────────────┐ + │ DAEMON (grandchild after double-fork) │ + │ OutputTee: fd 1+2 → pipe + log files │ + │ blueprint.build() │ + │ health_check() │ + │ Write BUILD_OK to pipe, close pipe │ + │ Update current.json with final PID │ + │ install_signal_handlers() │ + │ coordinator.loop() ← blocks forever │ + └────────────────────────────────────────┘ + │ + ┌─────────┴─────────────────────────────┐ + │ CLI PARENT: reads pipe, echoes │ + │ If --detach: exit 0 after BUILD_OK │ + │ If attached: tail stdout.log, │ + │ forward ctrl+c → SIGTERM │ + └───────────────────────────────────────┘ +``` + +## Directory Structure + +``` +~/.dimos/ # DIMOS_HOME (env var override) +└── instances/ + └── / # e.g., "unitree-go2" + ├── current.json # Metadata for running instance (exists while running) + └── runs/ + └── / # e.g., "20260312-143005" + ├── config.json # Full GlobalConfig snapshot + ├── stdout.log # Combined stdout+stderr with ANSI colors + ├── stdout.plain.log # ANSI-stripped copy + └── main.jsonl # Structured JSON logs (structlog) +``` + +## Process Lifecycle + +### 1. Launch (pre-fork) + +**File:** `dimos/robot/cli/dimos.py` — `run()` command + +``` +dimos run --viewer rerun --n-workers 2 --daemon my-blueprint +``` + +Sequence: +1. Resolve instance name (default = blueprint name) +2. Check for name clash — prompt, `--force-replace`, or error +3. Create `run_dir` under `~/.dimos/instances//runs//` +4. `set_run_log_dir(run_dir)` — configures structlog to write `main.jsonl` +5. Dump `config.json` — full GlobalConfig snapshot +6. Create `os.pipe()` for startup output +7. `fork()` — parent reads pipe, child becomes daemon + +### 2. Fork Before Build + +**File:** `dimos/core/daemon.py` — `daemonize()` + +**Key insight: fork BEFORE `blueprint.build()`** so no threads are killed. + +``` +CLI process (PID 100) + │ + ├─ fork() ──→ Child (PID 101) + │ │ + │ ├─ setsid() ← become session leader + │ │ + │ ├─ fork() ──→ Grandchild (PID 102) ← THE DAEMON + │ │ │ + │ │ ├─ stdin → /dev/null + │ │ ├─ OutputTee: fd 1+2 → pipe + log files + │ │ ├─ blueprint.build() ← ALL threads born here + │ │ ├─ health_check() + │ │ ├─ Write BUILD_OK to pipe + │ │ ├─ register(InstanceInfo) + │ │ └─ coordinator.loop() + │ │ + │ └─ os._exit(0) + │ + └─ Reads pipe, echoes to terminal + └─ On BUILD_OK: detach or tail stdout.log +``` + +**No more `restart_daemon_threads()`** — all threads are born in the daemon. + +### 3. Instance Registry + +**File:** `dimos/core/instance_registry.py` + +Each running instance writes `~/.dimos/instances//current.json`: +```json +{ + "name": "unitree-go2", + "pid": 12345, + "blueprint": "unitree-go2", + "started_at": "2026-03-12T14:30:05+00:00", + "run_dir": "/Users/me/.dimos/instances/unitree-go2/runs/20260312-143005", + "grpc_port": 9877, + "original_argv": ["dimos", "run", "--daemon", "unitree-go2"], + "config_overrides": {"dtop": true} +} +``` + +Key operations: `register()`, `unregister()`, `get()`, `list_running()`, `get_sole_running()`, `stop()`, `make_run_dir()` + +### 4. OutputTee + +**File:** `dimos/core/output_tee.py` + +Replaces the old `os.write` monkey-patch. Clean fd-level tee: +1. Creates an internal `os.pipe()` +2. `dup2`s stdout/stderr to the write end +3. Reader thread fans out bytes to: parent pipe fd, `stdout.log`, `stdout.plain.log` (ANSI-stripped) +4. `detach_parent()` stops writing to parent pipe after BUILD_OK + +### 5. Shutdown + +When `dimos stop` sends SIGTERM (or SIGINT): + +``` +Signal received + │ + ├─ coordinator.stop() + │ ├─ Stop StatsMonitor + │ ├─ For each module (reverse order): module.stop() + │ └─ WorkerManager.close_all() + │ + ├─ unregister(name) — deletes current.json + ├─ tee.close() + └─ sys.exit(0) +``` + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `dimos run [-d] [--detach] [--name N] [--force-replace]` | Launch (always daemonizes with -d) | +| `dimos stop [NAME] [-f]` | Stop instance (sole if no name) | +| `dimos restart [NAME] [-f]` | Re-exec with same argv | +| `dimos status [NAME]` | Show instance(s) details | +| `dimos log [NAME] [-f] [-n N] [--json] [--run DT]` | View logs | + +### Name Resolution + +- If NAME given: use that instance +- If omitted and 1 running: use it +- If omitted and 0: "No running instances" +- If omitted and 2+: list them and ask + +### Name Clash Handling + +- **Interactive:** Prompt "Stop existing? [y/N]" +- **DIO:** Uses `--force-replace` (shows TUI confirmation first) +- **Scripting:** `--force-replace` flag + +## Log Flow + +``` + ┌─────────────────────────────────────────────┐ + │ structlog pipeline │ + │ │ + logger.info("...") ──→ │ filter → add_level → timestamp → callsite │ + │ │ + │ ┌──────────┐ ┌──────────────┐ │ + │ │ Console │ │ File │ │ + │ │ Handler │ │ Handler │ │ + │ └────┬─────┘ └──────┬───────┘ │ + └──────────┼────────────────────┼────────────┘ + │ │ + ▼ ▼ + stdout (fd 1) main.jsonl + │ (RotatingFileHandler + │ 10 MiB, 20 backups) + ┌─────────┴─────────┐ + │ OutputTee │ + │ (reader thread) │ + └──┬──────┬──────┬───┘ + │ │ │ + ▼ ▼ ▼ + parent stdout stdout.plain + pipe .log .log + (until (ANSI) (stripped) + BUILD_OK) +``` + +## DIO Integration + +### Launcher (`launcher.py`) +Uses `--detach --force-replace`. The CLI process exits after BUILD_OK, and the launcher detects completion. + +### Status/Runner (`runner.py`) +Tails `stdout.log` from the instance's run directory. Polls `instance_registry.list_running()` every second to detect state changes. + +### Chat (`humancli.py`) +Connects via LCM transports — completely separate from the log pipeline. From 6efa69d6ac3d1bae354d07416cea4810975b282b Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Thu, 12 Mar 2026 14:51:04 -0700 Subject: [PATCH 19/23] launching cleanup --- dimos/core/daemon.py | 20 ++-- dimos/core/instance_registry.py | 4 +- dimos/core/log_viewer.py | 4 +- dimos/core/output_tee.py | 5 +- dimos/core/run_registry.py | 36 +++---- dimos/core/test_daemon.py | 2 +- dimos/robot/cli/dimos.py | 16 +-- dimos/utils/cli/dio/confirm_screen.py | 21 +++- dimos/utils/cli/dio/sub_apps/launcher.py | 16 ++- dimos/utils/cli/dio/sub_apps/runner.py | 124 ++++++++++++++++------- 10 files changed, 165 insertions(+), 83 deletions(-) diff --git a/dimos/core/daemon.py b/dimos/core/daemon.py index d1af74c363..e417e66cdb 100644 --- a/dimos/core/daemon.py +++ b/dimos/core/daemon.py @@ -41,16 +41,16 @@ from __future__ import annotations +from dataclasses import dataclass +from datetime import datetime, timezone import json import os +from pathlib import Path import select import signal import subprocess import sys import time -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path from typing import TYPE_CHECKING from dimos.utils.logging_config import setup_logger @@ -106,13 +106,13 @@ def launch_blueprint( ------- LaunchResult with instance_name and run_dir. """ + from dimos.core.global_config import global_config from dimos.core.instance_registry import ( get, is_pid_alive, make_run_dir, stop as registry_stop, ) - from dimos.core.global_config import global_config config_overrides = config_overrides or {} disable = disable or [] @@ -210,9 +210,7 @@ def _daemon_main(run_dir: Path) -> None: # Redirect stdout/stderr to stdout.log immediately (crash safety). # OutputTee will take over these fds later with its pipe. run_dir.mkdir(parents=True, exist_ok=True) - log_fd = os.open( - str(run_dir / "stdout.log"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644 - ) + log_fd = os.open(str(run_dir / "stdout.log"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) os.dup2(log_fd, 1) # stdout os.dup2(log_fd, 2) # stderr os.close(devnull_fd) @@ -246,9 +244,7 @@ def _daemon_main(run_dir: Path) -> None: blueprint = autoconnect(*map(get_by_name, robot_types)) if disable: - disabled_classes = tuple( - get_module_by_name(d).blueprints[0].module for d in disable - ) + disabled_classes = tuple(get_module_by_name(d).blueprints[0].module for d in disable) blueprint = blueprint.disabled_modules(*disabled_classes) coordinator = blueprint.build(cli_config_overrides=config_overrides) @@ -280,9 +276,7 @@ def _daemon_main(run_dir: Path) -> None: blueprint=blueprint_name, started_at=datetime.now(timezone.utc).isoformat(), run_dir=str(run_dir), - grpc_port=global_config.grpc_port - if hasattr(global_config, "grpc_port") - else 9877, + grpc_port=global_config.grpc_port if hasattr(global_config, "grpc_port") else 9877, original_argv=sys.argv, config_overrides=config_overrides, ) diff --git a/dimos/core/instance_registry.py b/dimos/core/instance_registry.py index 0a57911e4e..529a0b9361 100644 --- a/dimos/core/instance_registry.py +++ b/dimos/core/instance_registry.py @@ -139,9 +139,7 @@ def get_sole_running() -> InstanceInfo | None: if len(running) == 1: return running[0] names = ", ".join(r.name for r in running) - raise SystemExit( - f"Multiple instances running ({names}). Specify a name explicitly." - ) + raise SystemExit(f"Multiple instances running ({names}). Specify a name explicitly.") def stop(name: str, force: bool = False) -> tuple[str, bool]: diff --git a/dimos/core/log_viewer.py b/dimos/core/log_viewer.py index ac104aad79..ce678667df 100644 --- a/dimos/core/log_viewer.py +++ b/dimos/core/log_viewer.py @@ -22,7 +22,7 @@ import time from typing import TYPE_CHECKING -from dimos.core.instance_registry import get, get_sole_running, latest_run_dir, list_running +from dimos.core.instance_registry import get, latest_run_dir, list_running if TYPE_CHECKING: from collections.abc import Callable, Iterator @@ -42,6 +42,7 @@ def resolve_log_path(name: str = "", run_datetime: str = "") -> Path | None: """ if name and run_datetime: from dimos.core.instance_registry import _instances_dir + run_dir = _instances_dir() / name / "runs" / run_datetime return _log_path_if_exists(str(run_dir)) @@ -63,6 +64,7 @@ def resolve_log_path(name: str = "", run_datetime: str = "") -> Path | None: # Try to find the most recent run across all instances from dimos.core.instance_registry import _instances_dir + base = _instances_dir() if not base.exists(): return None diff --git a/dimos/core/output_tee.py b/dimos/core/output_tee.py index 7e07769faf..5cf18b78c8 100644 --- a/dimos/core/output_tee.py +++ b/dimos/core/output_tee.py @@ -23,7 +23,10 @@ import os import re import threading -from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path _ANSI_RE = re.compile(rb"\x1b\[[0-9;]*m") diff --git a/dimos/core/run_registry.py b/dimos/core/run_registry.py index ea10b02d8e..52bc4f973a 100644 --- a/dimos/core/run_registry.py +++ b/dimos/core/run_registry.py @@ -29,8 +29,6 @@ import time from dimos.core.instance_registry import ( - InstanceInfo, - dimos_home, is_pid_alive, list_running, stop as _stop_by_name, @@ -38,16 +36,16 @@ # Re-export __all__ = [ + "LOG_BASE_DIR", + "REGISTRY_DIR", "RunEntry", - "is_pid_alive", + "check_port_conflicts", + "cleanup_stale", + "generate_run_id", "get_most_recent", + "is_pid_alive", "list_runs", "stop_entry", - "cleanup_stale", - "check_port_conflicts", - "generate_run_id", - "LOG_BASE_DIR", - "REGISTRY_DIR", ] @@ -121,16 +119,18 @@ def list_runs(alive_only: bool = True) -> list[RunEntry]: new_entries = list_running() results: list[RunEntry] = [] for info in new_entries: - results.append(RunEntry( - run_id=info.name, - pid=info.pid, - blueprint=info.blueprint, - started_at=info.started_at, - log_dir=info.run_dir, - grpc_port=info.grpc_port, - original_argv=info.original_argv, - config_overrides=info.config_overrides, - )) + results.append( + RunEntry( + run_id=info.name, + pid=info.pid, + blueprint=info.blueprint, + started_at=info.started_at, + log_dir=info.run_dir, + grpc_port=info.grpc_port, + original_argv=info.original_argv, + config_overrides=info.config_overrides, + ) + ) # Also check legacy registry dir REGISTRY_DIR.mkdir(parents=True, exist_ok=True) diff --git a/dimos/core/test_daemon.py b/dimos/core/test_daemon.py index c7b2037de8..bdc68c2b0c 100644 --- a/dimos/core/test_daemon.py +++ b/dimos/core/test_daemon.py @@ -216,7 +216,7 @@ def test_partial_death(self): # Daemon tests # --------------------------------------------------------------------------- -from dimos.core.daemon import install_signal_handlers, launch_blueprint, LaunchResult +from dimos.core.daemon import LaunchResult, install_signal_handlers class TestLaunchResult: diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index f451e82c69..1fac915cbc 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -137,10 +137,16 @@ def _resolve_name(name: str | None) -> InstanceInfo: def run( ctx: typer.Context, robot_types: list[str] = typer.Argument(..., help="Blueprints or modules to run"), - daemon: bool = typer.Option(False, "--daemon", "-d", help="Run as daemon (always daemonizes; without -d stays attached)"), - detach: bool = typer.Option(False, "--detach", help="Exit CLI after successful build (implies --daemon)"), + daemon: bool = typer.Option( + False, "--daemon", "-d", help="Run as daemon (always daemonizes; without -d stays attached)" + ), + detach: bool = typer.Option( + False, "--detach", help="Exit CLI after successful build (implies --daemon)" + ), name: str = typer.Option("", "--name", help="Global instance name (default: blueprint name)"), - force_replace: bool = typer.Option(False, "--force-replace", help="Auto-stop existing instance with same name"), + force_replace: bool = typer.Option( + False, "--force-replace", help="Auto-stop existing instance with same name" + ), disable: list[str] = typer.Option([], "--disable", help="Module names to disable"), ) -> None: """Start a robot blueprint.""" @@ -221,9 +227,7 @@ def run( set_run_log_dir(run_dir) config_snapshot = run_dir / "config.json" - config_snapshot.write_text( - json.dumps(global_config.model_dump(mode="json"), indent=2) - ) + config_snapshot.write_text(json.dumps(global_config.model_dump(mode="json"), indent=2)) blueprint = autoconnect(*map(get_by_name, robot_types)) diff --git a/dimos/utils/cli/dio/confirm_screen.py b/dimos/utils/cli/dio/confirm_screen.py index 5a67cade4e..e645c4f270 100644 --- a/dimos/utils/cli/dio/confirm_screen.py +++ b/dimos/utils/cli/dio/confirm_screen.py @@ -1,15 +1,32 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Modal confirmation and sudo screens for dio.""" from __future__ import annotations import subprocess +from typing import TYPE_CHECKING -from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Center, Horizontal, Vertical +from textual.containers import Center, Vertical from textual.screen import ModalScreen from textual.widgets import Button, Input, Label, Static +if TYPE_CHECKING: + from textual.app import ComposeResult + class ConfirmScreen(ModalScreen[bool]): """A modal popup that asks a yes/no question.""" diff --git a/dimos/utils/cli/dio/sub_apps/launcher.py b/dimos/utils/cli/dio/sub_apps/launcher.py index 093c32eb95..e0538a9d49 100644 --- a/dimos/utils/cli/dio/sub_apps/launcher.py +++ b/dimos/utils/cli/dio/sub_apps/launcher.py @@ -16,16 +16,16 @@ from __future__ import annotations -from pathlib import Path import threading from typing import TYPE_CHECKING, Any from textual.widgets import Input, Label, ListItem, ListView, Static -from dimos.utils.cli import theme from dimos.utils.cli.dio.sub_app import SubApp if TYPE_CHECKING: + from pathlib import Path + from textual.app import ComposeResult @@ -43,6 +43,7 @@ def _debug_log(msg: str) -> None: """Append to the DIO debug log file.""" try: from dimos.core.instance_registry import dimos_home + log_path = dimos_home() / "dio-debug.log" with open(log_path, "a") as f: f.write(f"LAUNCHER: {msg}\n") @@ -107,6 +108,7 @@ def __init__(self) -> None: self._blueprints: list[str] = [] self._filtered: list[str] = [] self._launching = False + self._launching_name: str | None = None # name of blueprint currently launching def compose(self) -> ComposeResult: yield Static("Blueprint Launcher", classes="subapp-header") @@ -215,6 +217,8 @@ def _confirm_and_launch(self, name: str) -> None: from dimos.utils.cli.dio.confirm_screen import ConfirmScreen running = _list_running_names() + if self._launching_name and self._launching_name not in running: + running.append(self._launching_name) _debug_log(f"_confirm_and_launch: name={name} running={running}") if running: names = ", ".join(running) @@ -256,6 +260,7 @@ def _launch(self, name: str) -> None: return self._launching = True + self._launching_name = name self._sync_status() # lock the UI immediately status = self.query_one("#launch-status", Static) status.update(f"Launching {name}...") @@ -285,10 +290,13 @@ def _do_launch() -> None: config_overrides=config_overrides, force_replace=False, ) - _debug_log(f"_do_launch: success! instance={result.instance_name} run_dir={result.run_dir}") + _debug_log( + f"_do_launch: success! instance={result.instance_name} run_dir={result.run_dir}" + ) def _after() -> None: self._launching = False + self._launching_name = None # Tell StatusSubApp immediately self._notify_runner(result.instance_name, result.run_dir) self._sync_status() @@ -296,10 +304,12 @@ def _after() -> None: self.app.call_from_thread(_after) except Exception as e: import traceback + _debug_log(f"_do_launch: EXCEPTION: {e}\n{traceback.format_exc()}") def _err() -> None: self._launching = False + self._launching_name = None self.query_one("#launch-status", Static).update(f"Launch error: {e}") self.app.notify(f"Launch failed: {e}", severity="error", timeout=10) self._sync_status() diff --git a/dimos/utils/cli/dio/sub_apps/runner.py b/dimos/utils/cli/dio/sub_apps/runner.py index 39caf2da5f..e135d41a26 100644 --- a/dimos/utils/cli/dio/sub_apps/runner.py +++ b/dimos/utils/cli/dio/sub_apps/runner.py @@ -22,7 +22,6 @@ import os from pathlib import Path import subprocess -import sys import threading import time from typing import TYPE_CHECKING, Any @@ -187,6 +186,8 @@ def __init__(self) -> None: self._launching_name: str | None = None self._launching_run_dir: Path | None = None self._stopping: bool = False + self._stopped_blueprint: str | None = None # blueprint name after stop (for restart) + self._stopped_run_dir: Path | None = None # run_dir after stop (for log access) self._log_thread: threading.Thread | None = None self._stop_log = False self._failed_stop_pid: int | None = None @@ -334,6 +335,8 @@ def on_launch_started(self, instance_name: str, run_dir: Path) -> None: self._launching_run_dir = run_dir self._selected_name = instance_name self._stopping = False + self._stopped_blueprint = None + self._stopped_run_dir = None # Show controls for the launching state self.query_one("#idle-container").styles.display = "none" @@ -401,12 +404,20 @@ def _poll_running(self) -> None: if changed: self._rebuild_picker() + # Find info about disappeared instances from old_entries + def _find_old(name: str) -> Any: + for e in old_entries: + if getattr(e, "name", None) == name: + return e + return None + # If nothing is running anymore if not self._running_entries and self._launching_name is None: self._debug("-> all instances gone") - self._selected_name = None - self._stop_log = True - self._show_stopped("All processes ended") + old_entry = _find_old(self._selected_name) if self._selected_name else None + blueprint = getattr(old_entry, "blueprint", self._selected_name) + run_dir = Path(old_entry.run_dir) if old_entry and getattr(old_entry, "run_dir", None) else None + self._show_stopped("All processes ended", blueprint=blueprint, run_dir=run_dir) return # If selected instance disappeared @@ -415,14 +426,15 @@ def _poll_running(self) -> None: if self._launching_name == self._selected_name: return self._debug(f"selected instance {self._selected_name} gone") - self._stop_log = True + old_entry = _find_old(self._selected_name) if self._running_entries: # Switch to another running instance self._selected_name = self._running_entries[0].name self._show_running() else: - self._selected_name = None - self._show_stopped("Process ended") + blueprint = getattr(old_entry, "blueprint", self._selected_name) + run_dir = Path(old_entry.run_dir) if old_entry and getattr(old_entry, "run_dir", None) else None + self._show_stopped("Process ended", blueprint=blueprint, run_dir=run_dir) return # New instance appeared (maybe launched externally) @@ -459,17 +471,22 @@ def _show_running_for_entry(self, entry: Any) -> None: except Exception as e: self._debug(f"_show_running_for_entry CRASHED: {e}") - def _show_stopped(self, message: str = "Stopped") -> None: - """Show controls for a stopped state with logs still visible.""" + def _show_stopped(self, message: str = "Stopped", blueprint: str | None = None, run_dir: Path | None = None) -> None: + """Show controls for a stopped state with logs still visible and restart available.""" self._launching_name = None self._launching_run_dir = None self._stopping = False + if blueprint: + self._stopped_blueprint = blueprint + if run_dir: + self._stopped_run_dir = run_dir self.query_one("#idle-container").styles.display = "none" self.query_one("#runner-log").styles.display = "block" self.query_one("#run-controls").styles.display = "block" self.query_one("#btn-stop").styles.display = "none" self.query_one("#btn-sudo-kill").styles.display = "none" - self.query_one("#btn-restart").styles.display = "none" + # Show restart if we know what blueprint was running + self.query_one("#btn-restart").styles.display = "block" if self._stopped_blueprint else "none" self.query_one("#btn-open-log").styles.display = "block" self._failed_stop_pid = None status = self.query_one("#runner-status", Static) @@ -491,6 +508,7 @@ def _show_idle(self) -> None: # Check if there are past runs try: from dimos.core.instance_registry import _instances_dir + base = _instances_dir() if base.exists(): for child in sorted(base.iterdir(), reverse=True): @@ -652,9 +670,7 @@ def _follow() -> None: line = f.readline() if line: rendered = Text.from_ansi(line.rstrip("\n")) - self.app.call_from_thread( - self._write_log_line, log_widget, rendered - ) + self.app.call_from_thread(self._write_log_line, log_widget, rendered) else: time.sleep(0.2) except Exception as e: @@ -662,7 +678,10 @@ def _follow() -> None: self._write_log_line, log_widget, f"[red]Error: {e}[/red]" ) self.app.call_from_thread( - self.app.notify, f"Log follow error: {e}", severity="error", timeout=8, + self.app.notify, + f"Log follow error: {e}", + severity="error", + timeout=8, ) self._log_thread = threading.Thread(target=_follow, daemon=True) @@ -732,11 +751,14 @@ def _handle_double_click(self, event: Any) -> None: entry = self._selected_entry log_path = _stdout_log_path(entry) if entry else None - # Fallback to launching run_dir during build phase - if log_path is None and self._launching_run_dir is not None: - candidate = self._launching_run_dir / "stdout.log" - if candidate.exists(): - log_path = candidate + # Fallback to launching or stopped run_dir + if log_path is None: + for rd in (self._launching_run_dir, self._stopped_run_dir): + if rd is not None: + candidate = rd / "stdout.log" + if candidate.exists(): + log_path = candidate + break if log_path and log_path.exists(): self._open_source_file(str(log_path), lineno) @@ -860,13 +882,26 @@ def _cycle_button_focus(self, delta: int) -> None: def _stop_running(self) -> None: if self._stopping: return + + entry = self._selected_entry + stop_name = getattr(entry, "name", None) or self._launching_name + + from dimos.utils.cli.dio.confirm_screen import ConfirmScreen + + def _on_confirm(result: bool) -> None: + if result: + self._do_stop_confirmed(stop_name, entry) + + self.app.push_screen( + ConfirmScreen(f"Stop {stop_name or 'blueprint'}?", warning=True), + _on_confirm, + ) + + def _do_stop_confirmed(self, stop_name: str | None, entry: Any) -> None: self._stopping = True self._stop_log = True log_widget = self.query_one("#runner-log", RichLog) status = self.query_one("#runner-status", Static) - - entry = self._selected_entry - stop_name = getattr(entry, "name", None) or self._launching_name status.update(f"Stopping {stop_name or 'blueprint'}...") for bid in ("btn-stop", "btn-restart"): @@ -893,7 +928,10 @@ def _do_stop() -> None: f"[red]Permission denied — cannot stop PID {pid}[/red]", ) self.app.call_from_thread( - self.app.notify, f"Permission denied stopping PID {pid}", severity="error", timeout=8, + self.app.notify, + f"Permission denied stopping PID {pid}", + severity="error", + timeout=8, ) except Exception as e: if ( @@ -903,7 +941,10 @@ def _do_stop() -> None: permission_error = True self.app.call_from_thread(log_widget.write, f"[red]Stop error: {e}[/red]") self.app.call_from_thread( - self.app.notify, f"Stop error: {e}", severity="error", timeout=8, + self.app.notify, + f"Stop error: {e}", + severity="error", + timeout=8, ) def _after_stop() -> None: @@ -928,8 +969,9 @@ def _after_stop() -> None: self._selected_name = self._running_entries[0].name self._show_running() else: - self._selected_name = None - self._show_stopped(f"Stopped {stop_name}") + blueprint = getattr(entry, "blueprint", stop_name) + run_dir = Path(entry.run_dir) if entry and getattr(entry, "run_dir", None) else None + self._show_stopped(f"Stopped {stop_name}", blueprint=blueprint, run_dir=run_dir) self.app.call_from_thread(_after_stop) @@ -938,6 +980,9 @@ def _after_stop() -> None: def _restart_running(self) -> None: entry = self._selected_entry name = getattr(entry, "name", None) or getattr(entry, "blueprint", None) + # Fall back to stopped blueprint if no running entry + if not name: + name = self._stopped_blueprint if not name: return self._stop_log = True @@ -962,7 +1007,7 @@ def _restart_running(self) -> None: except Exception: pass - blueprint = getattr(entry, "blueprint", name) + blueprint = getattr(entry, "blueprint", name) if entry else name def _do_restart() -> None: # Stop old @@ -993,7 +1038,7 @@ def _after() -> None: self.on_launch_started(result.instance_name, result.run_dir) self.app.call_from_thread(_after) - except Exception as e: + except Exception: def _err() -> None: for bid in ("btn-stop", "btn-restart"): @@ -1098,7 +1143,10 @@ def _after2() -> None: "[red]sudo kill failed — could not obtain sudo credentials[/red]", ) self.app.call_from_thread( - self.app.notify, "sudo kill failed — no credentials", severity="error", timeout=8, + self.app.notify, + "sudo kill failed — no credentials", + severity="error", + timeout=8, ) def _reenable() -> None: @@ -1108,7 +1156,10 @@ def _reenable() -> None: except Exception as e: self.app.call_from_thread(log_widget.write, f"[red]sudo kill error: {e}[/red]") self.app.call_from_thread( - self.app.notify, f"sudo kill error: {e}", severity="error", timeout=8, + self.app.notify, + f"sudo kill error: {e}", + severity="error", + timeout=8, ) def _reenable2() -> None: @@ -1123,11 +1174,14 @@ def _open_log_in_editor(self) -> None: entry = self._selected_entry log_path = _stdout_log_path(entry) if entry else None - # Fallback to launching run_dir during build phase - if log_path is None and self._launching_run_dir is not None: - candidate = self._launching_run_dir / "stdout.log" - if candidate.exists(): - log_path = candidate + # Fallback to launching or stopped run_dir + if log_path is None: + for rd in (self._launching_run_dir, self._stopped_run_dir): + if rd is not None: + candidate = rd / "stdout.log" + if candidate.exists(): + log_path = candidate + break if log_path and log_path.exists(): self._open_source_file(str(log_path), 0) From a0987f5dcd7f772c464a8c3c1e32bede5d055594 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Fri, 13 Mar 2026 09:41:03 -0700 Subject: [PATCH 20/23] fixed stdin question, dtop and lcmspy not updating again --- dimos/core/instance_registry.py | 13 ++++- dimos/core/test_daemon.py | 41 +++++++++----- dimos/protocol/service/lcmservice.py | 1 - .../service/system_configurator/base.py | 4 +- dimos/utils/cli/dio/app.py | 40 +++++++++++++- dimos/utils/cli/dio/sub_apps/__init__.py | 1 - dimos/utils/cli/dio/sub_apps/agentspy.py | 21 ++++++- dimos/utils/cli/dio/sub_apps/launcher.py | 23 ++++++++ dimos/utils/cli/dio/sub_apps/runner.py | 32 ++++++++--- dimos/utils/prompt.py | 55 ++++++++++++++++--- 10 files changed, 192 insertions(+), 39 deletions(-) diff --git a/dimos/core/instance_registry.py b/dimos/core/instance_registry.py index 529a0b9361..a804fbcd75 100644 --- a/dimos/core/instance_registry.py +++ b/dimos/core/instance_registry.py @@ -180,9 +180,18 @@ def stop(name: str, force: bool = False) -> tuple[str, bool]: def make_run_dir(name: str) -> Path: - """Create ``instances//runs//`` and return its path.""" + """Create ``instances//runs//`` and return its path. + + Appends a numeric suffix (``-2``, ``-3``, ...) when a directory for the + current second already exists, preventing collisions from rapid launches. + """ ts = time.strftime("%Y%m%d-%H%M%S") - run_dir = _instances_dir() / name / "runs" / ts + base = _instances_dir() / name / "runs" + run_dir = base / ts + suffix = 2 + while run_dir.exists(): + run_dir = base / f"{ts}-{suffix}" + suffix += 1 run_dir.mkdir(parents=True, exist_ok=True) return run_dir diff --git a/dimos/core/test_daemon.py b/dimos/core/test_daemon.py index bdc68c2b0c..0c9f46c8b6 100644 --- a/dimos/core/test_daemon.py +++ b/dimos/core/test_daemon.py @@ -217,6 +217,7 @@ def test_partial_death(self): # --------------------------------------------------------------------------- from dimos.core.daemon import LaunchResult, install_signal_handlers +from dimos.core.instance_registry import InstanceInfo, register class TestLaunchResult: @@ -231,40 +232,54 @@ def test_launch_result_fields(self, tmp_path: Path): class TestSignalHandler: """test_signal_handler_cleans_registry.""" - def test_signal_handler_cleans_registry(self, tmp_registry: Path): - entry = _make_entry() - entry.save() - assert entry.registry_path.exists() + def test_signal_handler_cleans_registry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("dimos.core.instance_registry._instances_dir", lambda: tmp_path) + + info = InstanceInfo( + name="test-instance", + pid=os.getpid(), + blueprint="test", + started_at="2026-03-06T12:00:00Z", + run_dir=str(tmp_path / "runs" / "test"), + ) + register(info) + assert (tmp_path / "test-instance" / "current.json").exists() coord = mock.MagicMock() with mock.patch("signal.signal") as mock_signal: - install_signal_handlers(entry, coord) - # Capture the handler closure registered for SIGTERM + install_signal_handlers(info, coord) handler = mock_signal.call_args_list[0][0][1] with pytest.raises(SystemExit): handler(signal.SIGTERM, None) # Registry file should be cleaned up - assert not entry.registry_path.exists() - # Coordinator should have been stopped + assert not (tmp_path / "test-instance" / "current.json").exists() coord.stop.assert_called_once() - def test_signal_handler_tolerates_stop_error(self, tmp_registry: Path): - entry = _make_entry() - entry.save() + def test_signal_handler_tolerates_stop_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr("dimos.core.instance_registry._instances_dir", lambda: tmp_path) + + info = InstanceInfo( + name="test-instance", + pid=os.getpid(), + blueprint="test", + started_at="2026-03-06T12:00:00Z", + run_dir=str(tmp_path / "runs" / "test"), + ) + register(info) coord = mock.MagicMock() coord.stop.side_effect = RuntimeError("boom") with mock.patch("signal.signal") as mock_signal: - install_signal_handlers(entry, coord) + install_signal_handlers(info, coord) handler = mock_signal.call_args_list[0][0][1] with pytest.raises(SystemExit): handler(signal.SIGTERM, None) # Entry still removed even if stop() throws - assert not entry.registry_path.exists() + assert not (tmp_path / "test-instance" / "current.json").exists() # --------------------------------------------------------------------------- diff --git a/dimos/protocol/service/lcmservice.py b/dimos/protocol/service/lcmservice.py index 98b77e5d16..5cd4563fd1 100644 --- a/dimos/protocol/service/lcmservice.py +++ b/dimos/protocol/service/lcmservice.py @@ -115,7 +115,6 @@ def start(self) -> None: else: self.l = lcm.LCM(self.config.url) if self.config.url else lcm.LCM() - self._stop_event.clear() self._thread = threading.Thread(target=self._lcm_loop) self._thread.daemon = True diff --git a/dimos/protocol/service/system_configurator/base.py b/dimos/protocol/service/system_configurator/base.py index 8339964fee..7b0127b8e6 100644 --- a/dimos/protocol/service/system_configurator/base.py +++ b/dimos/protocol/service/system_configurator/base.py @@ -123,7 +123,9 @@ def configure_system(checks: list[SystemConfigurator], check_only: bool = False) summary = "\n".join(all_lines) else: summary = " (system configuration)" - if not confirm(f"Apply these changes?\n{summary}", default=True, question_id="system-configure"): + if not confirm( + f"Apply these changes?\n{summary}", default=True, question_id="system-configure" + ): if any(check.critical for check in failing): raise SystemExit(1) return diff --git a/dimos/utils/cli/dio/app.py b/dimos/utils/cli/dio/app.py index 0c7a3814d6..70df10bcaa 100644 --- a/dimos/utils/cli/dio/app.py +++ b/dimos/utils/cli/dio/app.py @@ -210,12 +210,48 @@ def _panel_for_widget(self, widget: Widget | None) -> int | None: # Click-to-focus panel # ------------------------------------------------------------------ - def on_click(self, event: Click) -> None: - """When a display pane is clicked, focus that panel.""" + async def on_click(self, event: Click) -> None: + """Handle clicks on display panes (focus panel) and sidebar tabs (switch/focus sub-app).""" + # Check if a sidebar tab was clicked + tab_idx = self._tab_for_widget(event.widget) + if tab_idx is not None: + await self._on_tab_clicked(tab_idx) + return + # Otherwise, clicking a display pane focuses that panel panel = self._panel_for_widget(event.widget) if panel is not None and panel != self._focused_panel: self._focus_panel(panel) + def _tab_for_widget(self, widget: Widget | None) -> int | None: + """Return sub-app index if the widget is inside a sidebar tab, or None.""" + node = widget + while node is not None: + node_id = getattr(node, "id", None) or "" + if node_id.startswith("tab-"): + try: + return int(node_id.split("-")[1]) + except (ValueError, IndexError): + return None + node = node.parent + return None + + async def _on_tab_clicked(self, tab_idx: int) -> None: + """Switch to or focus the sub-app at *tab_idx*.""" + # Check if this sub-app is already visible in any panel + for p in range(self._num_panels): + if self._panel_idx[p] == tab_idx: + # Already visible — just focus that panel + self._focus_panel(p) + return + # Not visible — switch the focused panel to show this sub-app + self._panel_idx[self._focused_panel] = tab_idx + await self._place_instances() + self._sync_tabs() + self._force_focus_subapp(self._instances[tab_idx]) + self._log( + f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] tab_click -> show {self._sub_app_classes[tab_idx].TITLE} in panel {self._focused_panel}" + ) + # ------------------------------------------------------------------ # Key logging # ------------------------------------------------------------------ diff --git a/dimos/utils/cli/dio/sub_apps/__init__.py b/dimos/utils/cli/dio/sub_apps/__init__.py index 4ed116499a..04e7e4010b 100644 --- a/dimos/utils/cli/dio/sub_apps/__init__.py +++ b/dimos/utils/cli/dio/sub_apps/__init__.py @@ -10,7 +10,6 @@ def get_sub_apps() -> list[type[SubApp]]: """Return all available sub-app classes in display order.""" - from dimos.utils.cli.dio.sub_apps.agentspy import AgentSpySubApp from dimos.utils.cli.dio.sub_apps.config import ConfigSubApp from dimos.utils.cli.dio.sub_apps.dtop import DtopSubApp from dimos.utils.cli.dio.sub_apps.humancli import HumanCLISubApp diff --git a/dimos/utils/cli/dio/sub_apps/agentspy.py b/dimos/utils/cli/dio/sub_apps/agentspy.py index 49bb76f27e..83ad2f6e7c 100644 --- a/dimos/utils/cli/dio/sub_apps/agentspy.py +++ b/dimos/utils/cli/dio/sub_apps/agentspy.py @@ -1,15 +1,30 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """AgentSpy sub-app — embedded agent message monitor.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any -from textual.app import ComposeResult from textual.widgets import RichLog -from dimos.utils.cli import theme from dimos.utils.cli.dio.sub_app import SubApp +if TYPE_CHECKING: + from textual.app import ComposeResult + class AgentSpySubApp(SubApp): TITLE = "agentspy" diff --git a/dimos/utils/cli/dio/sub_apps/launcher.py b/dimos/utils/cli/dio/sub_apps/launcher.py index e0538a9d49..5b1bc2e79c 100644 --- a/dimos/utils/cli/dio/sub_apps/launcher.py +++ b/dimos/utils/cli/dio/sub_apps/launcher.py @@ -282,6 +282,29 @@ def _launch(self, name: str) -> None: def _do_launch() -> None: _debug_log("_do_launch: thread started") try: + # Run autoconf before spawning the daemon (prompts route through TUI hooks) + try: + from dimos.protocol.service.lcmservice import autoconf + _debug_log("_do_launch: running autoconf") + autoconf() + _debug_log("_do_launch: autoconf done") + except SystemExit: + _debug_log("_do_launch: autoconf rejected (critical check declined)") + def _cancelled() -> None: + self._launching = False + self._launching_name = None + self.query_one("#launch-status", Static).update("Launch cancelled (system config required)") + self.app.notify("Launch cancelled — system configuration is required", severity="warning", timeout=8) + self._sync_status() + self.app.call_from_thread(_cancelled) + return + except Exception as autoconf_err: + _debug_log(f"_do_launch: autoconf error: {autoconf_err}") + def _autoconf_err() -> None: + self.app.notify(f"System config error: {autoconf_err}", severity="error", timeout=10) + self.app.call_from_thread(_autoconf_err) + # Continue with launch — autoconf failure shouldn't block + from dimos.core.daemon import launch_blueprint _debug_log(f"_do_launch: calling launch_blueprint(robot_types=[{name}])") diff --git a/dimos/utils/cli/dio/sub_apps/runner.py b/dimos/utils/cli/dio/sub_apps/runner.py index e135d41a26..40e4e28bf8 100644 --- a/dimos/utils/cli/dio/sub_apps/runner.py +++ b/dimos/utils/cli/dio/sub_apps/runner.py @@ -187,7 +187,7 @@ def __init__(self) -> None: self._launching_run_dir: Path | None = None self._stopping: bool = False self._stopped_blueprint: str | None = None # blueprint name after stop (for restart) - self._stopped_run_dir: Path | None = None # run_dir after stop (for log access) + self._stopped_run_dir: Path | None = None # run_dir after stop (for log access) self._log_thread: threading.Thread | None = None self._stop_log = False self._failed_stop_pid: int | None = None @@ -416,7 +416,11 @@ def _find_old(name: str) -> Any: self._debug("-> all instances gone") old_entry = _find_old(self._selected_name) if self._selected_name else None blueprint = getattr(old_entry, "blueprint", self._selected_name) - run_dir = Path(old_entry.run_dir) if old_entry and getattr(old_entry, "run_dir", None) else None + run_dir = ( + Path(old_entry.run_dir) + if old_entry and getattr(old_entry, "run_dir", None) + else None + ) self._show_stopped("All processes ended", blueprint=blueprint, run_dir=run_dir) return @@ -433,7 +437,11 @@ def _find_old(name: str) -> Any: self._show_running() else: blueprint = getattr(old_entry, "blueprint", self._selected_name) - run_dir = Path(old_entry.run_dir) if old_entry and getattr(old_entry, "run_dir", None) else None + run_dir = ( + Path(old_entry.run_dir) + if old_entry and getattr(old_entry, "run_dir", None) + else None + ) self._show_stopped("Process ended", blueprint=blueprint, run_dir=run_dir) return @@ -471,7 +479,9 @@ def _show_running_for_entry(self, entry: Any) -> None: except Exception as e: self._debug(f"_show_running_for_entry CRASHED: {e}") - def _show_stopped(self, message: str = "Stopped", blueprint: str | None = None, run_dir: Path | None = None) -> None: + def _show_stopped( + self, message: str = "Stopped", blueprint: str | None = None, run_dir: Path | None = None + ) -> None: """Show controls for a stopped state with logs still visible and restart available.""" self._launching_name = None self._launching_run_dir = None @@ -486,7 +496,9 @@ def _show_stopped(self, message: str = "Stopped", blueprint: str | None = None, self.query_one("#btn-stop").styles.display = "none" self.query_one("#btn-sudo-kill").styles.display = "none" # Show restart if we know what blueprint was running - self.query_one("#btn-restart").styles.display = "block" if self._stopped_blueprint else "none" + self.query_one("#btn-restart").styles.display = ( + "block" if self._stopped_blueprint else "none" + ) self.query_one("#btn-open-log").styles.display = "block" self._failed_stop_pid = None status = self.query_one("#runner-status", Static) @@ -970,8 +982,14 @@ def _after_stop() -> None: self._show_running() else: blueprint = getattr(entry, "blueprint", stop_name) - run_dir = Path(entry.run_dir) if entry and getattr(entry, "run_dir", None) else None - self._show_stopped(f"Stopped {stop_name}", blueprint=blueprint, run_dir=run_dir) + run_dir = ( + Path(entry.run_dir) + if entry and getattr(entry, "run_dir", None) + else None + ) + self._show_stopped( + f"Stopped {stop_name}", blueprint=blueprint, run_dir=run_dir + ) self.app.call_from_thread(_after_stop) diff --git a/dimos/utils/prompt.py b/dimos/utils/prompt.py index 6b4451cec1..b565aa1b54 100644 --- a/dimos/utils/prompt.py +++ b/dimos/utils/prompt.py @@ -1,3 +1,17 @@ +# Copyright 2026 Dimensional Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Unified prompts for DimOS. Usage:: @@ -141,11 +155,7 @@ def sudo_prompt(message: str = "sudo password required") -> bool: if result.returncode == 0: return True - # Non-interactive stdin — can't prompt for password - if not sys.stdin.isatty(): - print(f"assuming no for: {message} (cannot prompt for password non-interactively)") - return False - + # Try TUI hook first (works even when stdin is not a tty) with _lock: hook = _dio_sudo_hook @@ -154,6 +164,11 @@ def sudo_prompt(message: str = "sudo password required") -> bool: if result is not None: return result + # Non-interactive stdin — can't prompt for password + if not sys.stdin.isatty(): + print(f"assuming no for: {message} (cannot prompt for password non-interactively)") + return False + return _terminal_sudo(message) @@ -174,12 +189,22 @@ def _terminal_confirm(message: str, default: bool) -> bool: body = Text(message, style="bold #b5e4f4") console.print() - console.print(Panel(body, border_style="#00eeee", padding=(1, 3), title="[bold #00eeee]confirm[/bold #00eeee]", title_align="left")) + console.print( + Panel( + body, + border_style="#00eeee", + padding=(1, 3), + title="[bold #00eeee]confirm[/bold #00eeee]", + title_align="left", + ) + ) if default: console.print("[bold #00eeee]y[/bold #00eeee][#404040]/n:[/#404040] ", end="") else: - console.print("[#404040]y/[/#404040][bold #00eeee]n[/bold #00eeee][#404040]:[/#404040] ", end="") + console.print( + "[#404040]y/[/#404040][bold #00eeee]n[/bold #00eeee][#404040]:[/#404040] ", end="" + ) try: answer = input().strip().lower() @@ -210,7 +235,15 @@ def _terminal_sudo(message: str) -> bool: console = Console() body = Text(message, style="bold #b5e4f4") console.print() - console.print(Panel(body, border_style="#ffcc00", padding=(1, 3), title="[bold #ffcc00]sudo[/bold #ffcc00]", title_align="left")) + console.print( + Panel( + body, + border_style="#ffcc00", + padding=(1, 3), + title="[bold #ffcc00]sudo[/bold #ffcc00]", + title_align="left", + ) + ) except Exception: print(message) @@ -230,5 +263,9 @@ def _terminal_sudo(message: str) -> bool: ) if result.returncode == 0: return True - print("\033[91mIncorrect password, try again.\033[0m" if attempt < 2 else "\033[91mIncorrect password.\033[0m") + print( + "\033[91mIncorrect password, try again.\033[0m" + if attempt < 2 + else "\033[91mIncorrect password.\033[0m" + ) return False From bd4dfc0e2c7a08a061ea079ea9899b108c479cd7 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Fri, 13 Mar 2026 10:35:44 -0700 Subject: [PATCH 21/23] misc --- dimos/core/output_tee.py | 29 +++++++++++++++--- dimos/utils/cli/dio/app.py | 39 +++++++++++------------- dimos/utils/cli/dio/sub_app.py | 8 +++++ dimos/utils/cli/dio/sub_apps/dtop.py | 6 ++++ dimos/utils/cli/dio/sub_apps/humancli.py | 12 ++++++++ dimos/utils/cli/dio/sub_apps/launcher.py | 10 ++++++ dimos/utils/cli/dio/sub_apps/lcmspy.py | 17 +++++++++++ dimos/utils/cli/dio/sub_apps/runner.py | 20 ++++++++++++ 8 files changed, 116 insertions(+), 25 deletions(-) diff --git a/dimos/core/output_tee.py b/dimos/core/output_tee.py index 5cf18b78c8..ce76096040 100644 --- a/dimos/core/output_tee.py +++ b/dimos/core/output_tee.py @@ -40,10 +40,13 @@ class OutputTee: Directory where ``stdout.log`` and ``stdout.plain.log`` are created. """ + _MAX_LOGGED_ERRORS = 5 # avoid flooding tee_errors.log on repeated failures + def __init__(self, run_dir: Path) -> None: self._run_dir = run_dir self._reader_thread: threading.Thread | None = None self._stopped = False + self._error_count = 0 # Internal pipe: we dup2 stdout/stderr to write_fd; the reader # thread reads from read_fd and fans out. @@ -79,17 +82,35 @@ def _reader_loop(self) -> None: try: self._color_log.write(data) self._color_log.flush() - except Exception: - pass + except Exception as exc: + self._log_tee_error("stdout.log", exc) # Write to stdout.plain.log (ANSI stripped) try: self._plain_log.write(_ANSI_RE.sub(b"", data)) self._plain_log.flush() - except Exception: - pass + except Exception as exc: + self._log_tee_error("stdout.plain.log", exc) except OSError: pass # pipe closed + def _log_tee_error(self, target: str, exc: Exception) -> None: + """Record a write error to ``tee_errors.log`` in the run directory. + + The TUI runner polls for this file and shows a notification on + first appearance. Only the first few errors are logged to avoid + flooding disk on sustained failures (e.g. disk full). + """ + self._error_count += 1 + if self._error_count > self._MAX_LOGGED_ERRORS: + return + try: + msg = f"OutputTee: failed to write to {target}: {exc}\n" + err_path = self._run_dir / "tee_errors.log" + with open(err_path, "a") as f: + f.write(msg) + except Exception: + pass # nothing more we can do + def close(self) -> None: """Shut down the tee (flush, close files, join reader).""" self._stopped = True diff --git a/dimos/utils/cli/dio/app.py b/dimos/utils/cli/dio/app.py index 70df10bcaa..18d328714a 100644 --- a/dimos/utils/cli/dio/app.py +++ b/dimos/utils/cli/dio/app.py @@ -68,7 +68,7 @@ def __init__(self, *, debug: bool = False) -> None: saved_theme = self._load_saved_theme() theme.set_theme(saved_theme) self.theme = f"dimos-{saved_theme}" - self._debug = debug + self._debug = debug # controls the visual debug panel only self._sub_app_classes = get_sub_apps() n = len(self._sub_app_classes) # Which sub-app index each panel shows @@ -81,17 +81,14 @@ def __init__(self, *, debug: bool = False) -> None: self._quit_timer: object | None = None # Track which panel each instance is currently mounted in self._instance_pane: dict[int, int] = {} # instance_idx -> panel (0..N-1) - # Debug log - self._debug_log_path: str | None = None - self._debug_log_file: object | None = None - if debug: - from pathlib import Path - - log_path = Path.home() / ".dimos" / "dio-debug.log" - log_path.parent.mkdir(parents=True, exist_ok=True) - f = open(log_path, "w") - self._debug_log_path = str(log_path) - self._debug_log_file = f + # Debug log — always write to file, --debug only controls the visual panel + from pathlib import Path + + log_path = Path.home() / ".dimos" / "dio-debug.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + f = open(log_path, "w") + self._debug_log_path = str(log_path) + self._debug_log_file = f @staticmethod def _load_saved_theme() -> str: @@ -117,22 +114,23 @@ def _load_saved_theme() -> str: # ------------------------------------------------------------------ def _log(self, msg: str) -> None: - if not self._debug: - return import re plain = re.sub(r"\[/?[^\]]*\]", "", msg) + # Always write to debug log file if self._debug_log_file is not None: try: self._debug_log_file.write(plain + "\n") # type: ignore[union-attr] self._debug_log_file.flush() # type: ignore[union-attr] except Exception: pass - try: - panel = self.query_one("#debug-log", RichLog) - panel.write(msg) - except Exception: - pass + # Only write to visual panel when --debug + if self._debug: + try: + panel = self.query_one("#debug-log", RichLog) + panel.write(msg) + except Exception: + pass # ------------------------------------------------------------------ # Compose @@ -571,8 +569,7 @@ def main() -> None: clear_dio_hook() sys.stdin.close() sys.stdin = _real_stdin - if app._debug_log_path: - print(f"Debug log: {app._debug_log_path}") + print(f"Debug log: {app._debug_log_path}") if app._debug_log_file: try: app._debug_log_file.close() # type: ignore[union-attr] diff --git a/dimos/utils/cli/dio/sub_app.py b/dimos/utils/cli/dio/sub_app.py index b065f55a7b..3cebf3f5d6 100644 --- a/dimos/utils/cli/dio/sub_app.py +++ b/dimos/utils/cli/dio/sub_app.py @@ -101,3 +101,11 @@ def on_unmount_subapp(self) -> None: Override to stop LCM subscriptions, timers, etc. """ + + def reinit_lcm(self) -> None: + """Called after autoconf changes network config (e.g. multicast). + + Sub-apps that hold LCM connections should override this to + tear down and recreate them, since connections created before + multicast was configured will be dead. + """ diff --git a/dimos/utils/cli/dio/sub_apps/dtop.py b/dimos/utils/cli/dio/sub_apps/dtop.py index 84abb28559..eff3f51147 100644 --- a/dimos/utils/cli/dio/sub_apps/dtop.py +++ b/dimos/utils/cli/dio/sub_apps/dtop.py @@ -148,6 +148,12 @@ def on_unmount_subapp(self) -> None: pass self._lcm = None + def reinit_lcm(self) -> None: + self._debug("reinit_lcm called (autoconf changed network config)") + self._latest = None + self._reconnecting = False + self.run_worker(self._init_lcm, exclusive=True, thread=True) + def _reconnect_lcm(self) -> None: """Tear down and re-create the LCM subscription.""" try: diff --git a/dimos/utils/cli/dio/sub_apps/humancli.py b/dimos/utils/cli/dio/sub_apps/humancli.py index ddd09122bc..95159ff62c 100644 --- a/dimos/utils/cli/dio/sub_apps/humancli.py +++ b/dimos/utils/cli/dio/sub_apps/humancli.py @@ -191,6 +191,18 @@ def on_unmount_subapp(self) -> None: if self._thinking: self._thinking.hide() + def reinit_lcm(self) -> None: + """Recreate LCM transports after autoconf changed network config.""" + self._human_transport = None + self._agent_transport = None + self._idle_transport = None + self._agent_seen = False + if self._agent_timeout_timer is not None: + self._agent_timeout_timer.stop() + self._agent_timeout_timer = None + self._set_conn_state(_ConnState.CONNECTING) + self.run_worker(self._init_transports, exclusive=True, thread=True) + # ── connection state ────────────────────────────────────────── def _set_conn_state(self, state: _ConnState, error: str = "") -> None: diff --git a/dimos/utils/cli/dio/sub_apps/launcher.py b/dimos/utils/cli/dio/sub_apps/launcher.py index 5b1bc2e79c..8160a1890b 100644 --- a/dimos/utils/cli/dio/sub_apps/launcher.py +++ b/dimos/utils/cli/dio/sub_apps/launcher.py @@ -288,6 +288,16 @@ def _do_launch() -> None: _debug_log("_do_launch: running autoconf") autoconf() _debug_log("_do_launch: autoconf done") + # Autoconf may have changed network config (e.g. enabled + # multicast). Tell all sub-apps to recreate their LCM + # connections so they pick up the new config. + def _reinit_all_lcm() -> None: + for inst in self.app._instances: # type: ignore[attr-defined] + try: + inst.reinit_lcm() + except Exception: + pass + self.app.call_from_thread(_reinit_all_lcm) except SystemExit: _debug_log("_do_launch: autoconf rejected (critical check declined)") def _cancelled() -> None: diff --git a/dimos/utils/cli/dio/sub_apps/lcmspy.py b/dimos/utils/cli/dio/sub_apps/lcmspy.py index b04b3f7a26..32437dbbfc 100644 --- a/dimos/utils/cli/dio/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dio/sub_apps/lcmspy.py @@ -52,6 +52,12 @@ def __init__(self) -> None: super().__init__() self._spy: Any = None + def _debug(self, msg: str) -> None: + try: + self.app._log(f"[dim]LCMSPY:[/dim] {msg}") # type: ignore[attr-defined] + except Exception: + pass + def compose(self) -> ComposeResult: table: DataTable = DataTable(zebra_stripes=False, cursor_type=None) # type: ignore[arg-type] table.add_column("Topic") @@ -90,6 +96,17 @@ def on_unmount_subapp(self) -> None: pass self._spy = None + def reinit_lcm(self) -> None: + self._debug("reinit_lcm called (autoconf changed network config)") + # Stop existing spy and start fresh + if self._spy: + try: + self._spy.stop() + except Exception: + pass + self._spy = None + self.run_worker(self._init_lcm, exclusive=True, thread=True) + def _refresh_table(self) -> None: if not self._spy: return diff --git a/dimos/utils/cli/dio/sub_apps/runner.py b/dimos/utils/cli/dio/sub_apps/runner.py index 40e4e28bf8..4c269fdc55 100644 --- a/dimos/utils/cli/dio/sub_apps/runner.py +++ b/dimos/utils/cli/dio/sub_apps/runner.py @@ -192,6 +192,7 @@ def __init__(self) -> None: self._stop_log = False self._failed_stop_pid: int | None = None self._poll_count = 0 + self._tee_error_notified: set[str] = set() # run_dirs already notified self._last_click_time: float = 0.0 self._last_click_y: int = -1 self._saved_status: str = "" @@ -401,6 +402,9 @@ def _poll_running(self) -> None: f"poll #{self._poll_count}: old={old_names} new={new_names} changed={changed}" ) + # Check for OutputTee write errors in running instances + self._check_tee_errors() + if changed: self._rebuild_picker() @@ -452,6 +456,22 @@ def _find_old(name: str) -> Any: self._selected_name = self._running_entries[0].name self._show_running() + def _check_tee_errors(self) -> None: + """Notify once per run_dir if OutputTee encountered write errors.""" + for entry in self._running_entries: + rd = getattr(entry, "run_dir", None) + if not rd or rd in self._tee_error_notified: + continue + err_file = Path(rd) / "tee_errors.log" + if err_file.exists(): + self._tee_error_notified.add(rd) + name = getattr(entry, "name", "unknown") + self.app.notify( + f"Log write errors in {name} — check tee_errors.log", + severity="error", + timeout=10, + ) + def _show_running(self) -> None: """Show controls for the selected running blueprint.""" entry = self._selected_entry From a9f5709b20be19b2c2caf36f837ebad897bae215 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Fri, 13 Mar 2026 11:33:15 -0700 Subject: [PATCH 22/23] misc fixes/cleanup --- dimos/core/daemon.py | 10 +++---- dimos/core/test_daemon.py | 4 ++- dimos/utils/cli/dio/app.py | 36 ++++++++++++------------ dimos/utils/cli/dio/sub_apps/dtop.py | 2 +- dimos/utils/cli/dio/sub_apps/launcher.py | 21 ++++++++++++-- dimos/utils/cli/dio/sub_apps/lcmspy.py | 2 +- dimos/utils/cli/dio/sub_apps/runner.py | 10 +++---- dimos/utils/prompt.py | 11 ++++---- 8 files changed, 57 insertions(+), 39 deletions(-) diff --git a/dimos/core/daemon.py b/dimos/core/daemon.py index e417e66cdb..5f5be6d10f 100644 --- a/dimos/core/daemon.py +++ b/dimos/core/daemon.py @@ -140,11 +140,11 @@ def launch_blueprint( # Create run directory run_dir = make_run_dir(instance_name) - # Dump full config snapshot - global_config.update(**config_overrides) - (run_dir / "config.json").write_text( - json.dumps(global_config.model_dump(mode="json"), indent=2) - ) + # Dump config snapshot without mutating the host's global_config. + # The daemon subprocess applies overrides itself from launch_params.json. + snapshot = global_config.model_dump(mode="json") + snapshot.update(config_overrides) + (run_dir / "config.json").write_text(json.dumps(snapshot, indent=2)) # Write launch parameters for the subprocess to read launch_params = { diff --git a/dimos/core/test_daemon.py b/dimos/core/test_daemon.py index 0c9f46c8b6..766b4ce980 100644 --- a/dimos/core/test_daemon.py +++ b/dimos/core/test_daemon.py @@ -257,7 +257,9 @@ def test_signal_handler_cleans_registry(self, tmp_path: Path, monkeypatch: pytes assert not (tmp_path / "test-instance" / "current.json").exists() coord.stop.assert_called_once() - def test_signal_handler_tolerates_stop_error(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + def test_signal_handler_tolerates_stop_error( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ): monkeypatch.setattr("dimos.core.instance_registry._instances_dir", lambda: tmp_path) info = InstanceInfo( diff --git a/dimos/utils/cli/dio/app.py b/dimos/utils/cli/dio/app.py index 18d328714a..d8af4767a9 100644 --- a/dimos/utils/cli/dio/app.py +++ b/dimos/utils/cli/dio/app.py @@ -113,7 +113,7 @@ def _load_saved_theme() -> str: # Debug log # ------------------------------------------------------------------ - def _log(self, msg: str) -> None: + def _dlog(self, msg: str) -> None: import re plain = re.sub(r"\[/?[^\]]*\]", "", msg) @@ -167,9 +167,9 @@ async def on_mount(self) -> None: self._sync_hint() if self._instances: self._force_focus_subapp(self._instances[self._panel_idx[0]]) - self._log(f"[dim]mounted {len(self._instances)} sub-apps, debug={self._debug}[/dim]") + self._dlog(f"[dim]mounted {len(self._instances)} sub-apps, debug={self._debug}[/dim]") if self._debug_log_path: - self._log(f"[dim]log file: {self._debug_log_path}[/dim]") + self._dlog(f"[dim]log file: {self._debug_log_path}[/dim]") async def on_resize(self, _event: Resize) -> None: old = self._num_panels @@ -246,7 +246,7 @@ async def _on_tab_clicked(self, tab_idx: int) -> None: await self._place_instances() self._sync_tabs() self._force_focus_subapp(self._instances[tab_idx]) - self._log( + self._dlog( f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] tab_click -> show {self._sub_app_classes[tab_idx].TITLE} in panel {self._focused_panel}" ) @@ -259,7 +259,7 @@ def on_key(self, event: Key) -> None: focused_name = type(focused).__name__ if focused else "None" focused_id = getattr(focused, "id", None) or "" panel = self._panel_for_widget(focused) - self._log( + self._dlog( f"[{theme.DEBUG_KEY}]KEY[/{theme.DEBUG_KEY}] [bold {theme.CYAN}]{event.key!r}[/bold {theme.CYAN}]" f" char={event.character!r}" f" focused=[{theme.DEBUG_FOCUS}]{focused_name}#{focused_id}[/{theme.DEBUG_FOCUS}]" @@ -306,7 +306,7 @@ async def _place_instances(self) -> None: await inst.remove() await dest.mount(inst) self._instance_pane[i] = target_panel - self._log( + self._dlog( f"[dim] moved {self._sub_app_classes[i].TITLE} -> panel{target_panel}[/dim]" ) inst.styles.display = "block" @@ -317,7 +317,7 @@ async def _place_instances(self) -> None: f"p{p}={self._sub_app_classes[self._panel_idx[p]].TITLE}" for p in range(self._num_panels) ) - self._log(f"[dim]placed: {names}[/dim]") + self._dlog(f"[dim]placed: {names}[/dim]") _TAB_SELECTED_CLASSES = [f"--selected-{i}" for i in range(1, _MAX_PANELS + 1)] @@ -350,21 +350,21 @@ def _sync_hint(self) -> None: # ------------------------------------------------------------------ async def action_tab_prev(self) -> None: - self._log( + self._dlog( f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] tab_prev panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" ) self._clear_quit_pending() await self._move_tab(-1) async def action_tab_next(self) -> None: - self._log( + self._dlog( f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] tab_next panel={self._focused_panel} idx={self._panel_idx[: self._num_panels]}" ) self._clear_quit_pending() await self._move_tab(1) def action_focus_prev_panel(self) -> None: - self._log( + self._dlog( f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_prev_panel (was panel={self._focused_panel})" ) self._clear_quit_pending() @@ -372,7 +372,7 @@ def action_focus_prev_panel(self) -> None: self._focus_panel(new) def action_focus_next_panel(self) -> None: - self._log( + self._dlog( f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] focus_next_panel (was panel={self._focused_panel})" ) self._clear_quit_pending() @@ -385,17 +385,17 @@ def action_copy_text(self) -> None: if selected: self.copy_to_clipboard(selected) self.screen.clear_selection() - self._log( + self._dlog( f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text (copied to clipboard)" ) else: - self._log( + self._dlog( f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] copy_text -> no selection, treating as quit" ) self._handle_quit_press() def action_quit_or_esc(self) -> None: - self._log(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] quit_or_esc") + self._dlog(f"[{theme.DEBUG_ACTION}]ACTION[/{theme.DEBUG_ACTION}] quit_or_esc") self._handle_quit_press() # ------------------------------------------------------------------ @@ -414,7 +414,7 @@ def _focus_panel(self, panel: int) -> None: actual_name = type(actual).__name__ if actual else "None" actual_id = getattr(actual, "id", None) or "" actual_panel = self._panel_for_widget(actual) - self._log( + self._dlog( f" -> FOCUS panel {old}->{panel} sub-app={self._sub_app_classes[idx].TITLE}" f" actual_focus={actual_name}#{actual_id} in panel={actual_panel}" ) @@ -429,7 +429,7 @@ def _force_focus_subapp(self, subapp: SubApp) -> None: if target is not None: self.screen.set_focus(target) else: - self._log(f"[dim]WARNING: no focusable widget in {subapp.TITLE}[/dim]") + self._dlog(f"[dim]WARNING: no focusable widget in {subapp.TITLE}[/dim]") async def _move_tab(self, delta: int) -> None: n = len(self._sub_app_classes) @@ -445,7 +445,7 @@ async def _move_tab(self, delta: int) -> None: attempts += 1 self._panel_idx[panel] = idx - self._log( + self._dlog( f" -> MOVE panel={panel} {self._sub_app_classes[old_idx].TITLE}->{self._sub_app_classes[idx].TITLE} " f"idx={self._panel_idx[: self._num_panels]}" ) @@ -455,7 +455,7 @@ async def _move_tab(self, delta: int) -> None: actual = self.focused actual_name = type(actual).__name__ if actual else "None" actual_id = getattr(actual, "id", None) or "" - self._log( + self._dlog( f" -> after focus: {actual_name}#{actual_id} in panel={self._panel_for_widget(actual)}" ) diff --git a/dimos/utils/cli/dio/sub_apps/dtop.py b/dimos/utils/cli/dio/sub_apps/dtop.py index eff3f51147..fd035d8f54 100644 --- a/dimos/utils/cli/dio/sub_apps/dtop.py +++ b/dimos/utils/cli/dio/sub_apps/dtop.py @@ -75,7 +75,7 @@ def __init__(self) -> None: def _debug(self, msg: str) -> None: try: - self.app._log(f"[{theme.BTN_MUTED}]DTOP:[/{theme.BTN_MUTED}] {msg}") # type: ignore[attr-defined] + self.app._dlog(f"[{theme.BTN_MUTED}]DTOP:[/{theme.BTN_MUTED}] {msg}") # type: ignore[attr-defined] except Exception: pass diff --git a/dimos/utils/cli/dio/sub_apps/launcher.py b/dimos/utils/cli/dio/sub_apps/launcher.py index 8160a1890b..7177a5b5ec 100644 --- a/dimos/utils/cli/dio/sub_apps/launcher.py +++ b/dimos/utils/cli/dio/sub_apps/launcher.py @@ -285,9 +285,11 @@ def _do_launch() -> None: # Run autoconf before spawning the daemon (prompts route through TUI hooks) try: from dimos.protocol.service.lcmservice import autoconf + _debug_log("_do_launch: running autoconf") autoconf() _debug_log("_do_launch: autoconf done") + # Autoconf may have changed network config (e.g. enabled # multicast). Tell all sub-apps to recreate their LCM # connections so they pick up the new config. @@ -297,21 +299,34 @@ def _reinit_all_lcm() -> None: inst.reinit_lcm() except Exception: pass + self.app.call_from_thread(_reinit_all_lcm) except SystemExit: _debug_log("_do_launch: autoconf rejected (critical check declined)") + def _cancelled() -> None: self._launching = False self._launching_name = None - self.query_one("#launch-status", Static).update("Launch cancelled (system config required)") - self.app.notify("Launch cancelled — system configuration is required", severity="warning", timeout=8) + self.query_one("#launch-status", Static).update( + "Launch cancelled (system config required)" + ) + self.app.notify( + "Launch cancelled — system configuration is required", + severity="warning", + timeout=8, + ) self._sync_status() + self.app.call_from_thread(_cancelled) return except Exception as autoconf_err: _debug_log(f"_do_launch: autoconf error: {autoconf_err}") + def _autoconf_err() -> None: - self.app.notify(f"System config error: {autoconf_err}", severity="error", timeout=10) + self.app.notify( + f"System config error: {autoconf_err}", severity="error", timeout=10 + ) + self.app.call_from_thread(_autoconf_err) # Continue with launch — autoconf failure shouldn't block diff --git a/dimos/utils/cli/dio/sub_apps/lcmspy.py b/dimos/utils/cli/dio/sub_apps/lcmspy.py index 32437dbbfc..adea2b0c9e 100644 --- a/dimos/utils/cli/dio/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dio/sub_apps/lcmspy.py @@ -54,7 +54,7 @@ def __init__(self) -> None: def _debug(self, msg: str) -> None: try: - self.app._log(f"[dim]LCMSPY:[/dim] {msg}") # type: ignore[attr-defined] + self.app._dlog(f"[dim]LCMSPY:[/dim] {msg}") # type: ignore[attr-defined] except Exception: pass diff --git a/dimos/utils/cli/dio/sub_apps/runner.py b/dimos/utils/cli/dio/sub_apps/runner.py index 4c269fdc55..04eb8edfd0 100644 --- a/dimos/utils/cli/dio/sub_apps/runner.py +++ b/dimos/utils/cli/dio/sub_apps/runner.py @@ -209,7 +209,7 @@ def _selected_entry(self) -> Any: def _debug(self, msg: str) -> None: """Log to the DIO debug panel if available.""" try: - self.app._log(f"[{theme.BTN_MUTED}]STATUS:[/{theme.BTN_MUTED}] {msg}") # type: ignore[attr-defined] + self.app._dlog(f"[{theme.BTN_MUTED}]STATUS:[/{theme.BTN_MUTED}] {msg}") # type: ignore[attr-defined] except Exception: pass @@ -1076,16 +1076,16 @@ def _after() -> None: self.on_launch_started(result.instance_name, result.run_dir) self.app.call_from_thread(_after) - except Exception: + except Exception as e: - def _err() -> None: + def _err(err: Exception = e) -> None: for bid in ("btn-stop", "btn-restart"): try: self.query_one(f"#{bid}", Button).disabled = False except Exception: pass - self.app.call_from_thread(log_widget.write, f"[red]Restart error: {e}[/red]") - self.app.notify(f"Restart failed: {e}", severity="error", timeout=10) + self.app.call_from_thread(log_widget.write, f"[red]Restart error: {err}[/red]") + self.app.notify(f"Restart failed: {err}", severity="error", timeout=10) self._refresh_entries() if self._running_entries: self._selected_name = self._running_entries[0].name diff --git a/dimos/utils/prompt.py b/dimos/utils/prompt.py index b565aa1b54..b7d17ded93 100644 --- a/dimos/utils/prompt.py +++ b/dimos/utils/prompt.py @@ -112,10 +112,11 @@ def confirm( if hook is not None: result = hook(message, default) if result is not None: + answer = bool(result) if question_id is not None: with _lock: - _answer_cache[question_id] = result - return result + _answer_cache[question_id] = answer + return answer # Non-interactive stdin (piped, /dev/null, etc.) — auto-accept default if not sys.stdin.isatty(): @@ -160,9 +161,9 @@ def sudo_prompt(message: str = "sudo password required") -> bool: hook = _dio_sudo_hook if hook is not None: - result = hook(message) - if result is not None: - return result + hook_result = hook(message) + if hook_result is not None: + return bool(hook_result) # Non-interactive stdin — can't prompt for password if not sys.stdin.isatty(): From 313ed9bc6aca4605652dfa25cb59da3e5b559843 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Fri, 13 Mar 2026 14:17:44 -0700 Subject: [PATCH 23/23] cleanup --- dimos/core/run_registry.py | 5 +-- dimos/utils/cli/dio/app.py | 40 +++++++++++++----------- dimos/utils/cli/dio/sub_app.py | 7 +++-- dimos/utils/cli/dio/sub_apps/dtop.py | 3 +- dimos/utils/cli/dio/sub_apps/launcher.py | 5 +-- dimos/utils/cli/dio/sub_apps/lcmspy.py | 2 +- dimos/utils/cli/dio/sub_apps/runner.py | 7 +++-- 7 files changed, 39 insertions(+), 30 deletions(-) diff --git a/dimos/core/run_registry.py b/dimos/core/run_registry.py index 52bc4f973a..7a45625655 100644 --- a/dimos/core/run_registry.py +++ b/dimos/core/run_registry.py @@ -132,8 +132,9 @@ def list_runs(alive_only: bool = True) -> list[RunEntry]: ) ) - # Also check legacy registry dir - REGISTRY_DIR.mkdir(parents=True, exist_ok=True) + # Also check legacy registry dir (don't create it if it doesn't exist) + if not REGISTRY_DIR.exists(): + return results seen_pids = {r.pid for r in results} for f in sorted(REGISTRY_DIR.glob("*.json")): try: diff --git a/dimos/utils/cli/dio/app.py b/dimos/utils/cli/dio/app.py index d8af4767a9..d5c0529366 100644 --- a/dimos/utils/cli/dio/app.py +++ b/dimos/utils/cli/dio/app.py @@ -31,7 +31,9 @@ from dimos.utils.cli.dio.sub_apps import get_sub_apps if TYPE_CHECKING: + from textual.dom import DOMNode from textual.events import Click, Key, Resize + from textual.timer import Timer from textual.widget import Widget from dimos.utils.cli.dio.sub_app import SubApp @@ -63,7 +65,7 @@ def __init__(self, *, debug: bool = False) -> None: super().__init__() # Register all DimOS themes for t in theme.get_textual_themes(): - self.register_theme(t) + self.register_theme(t) # type: ignore[arg-type] # Load saved theme from config saved_theme = self._load_saved_theme() theme.set_theme(saved_theme) @@ -78,7 +80,7 @@ def __init__(self, *, debug: bool = False) -> None: self._initialized = False self._instances: list[SubApp] = [] self._quit_pressed_at: float = 0.0 - self._quit_timer: object | None = None + self._quit_timer: Timer | None = None # Track which panel each instance is currently mounted in self._instance_pane: dict[int, int] = {} # instance_idx -> panel (0..N-1) # Debug log — always write to file, --debug only controls the visual panel @@ -99,7 +101,7 @@ def _load_saved_theme() -> str: try: config_path = Path(sys.prefix) / "dio-config.json" data = json.loads(config_path.read_text()) - name = data.get("theme", theme.DEFAULT_THEME) + name = str(data.get("theme", theme.DEFAULT_THEME)) # Migrate old theme names _MIGRATION = {"dark": "dark-one"} name = _MIGRATION.get(name, name) @@ -192,7 +194,7 @@ async def on_unmount(self) -> None: def _panel_for_widget(self, widget: Widget | None) -> int | None: """Return which panel (0..N-1) contains the given widget, or None.""" - node = widget + node: DOMNode | None = widget while node is not None: node_id = getattr(node, "id", None) or "" if node_id.startswith("display-"): @@ -222,7 +224,7 @@ async def on_click(self, event: Click) -> None: def _tab_for_widget(self, widget: Widget | None) -> int | None: """Return sub-app index if the widget is inside a sidebar tab, or None.""" - node = widget + node: DOMNode | None = widget while node is not None: node_id = getattr(node, "id", None) or "" if node_id.startswith("tab-"): @@ -468,13 +470,13 @@ def _handle_quit_press(self) -> None: bar = self.query_one("#hint-bar", Static) bar.update("Press Esc or Ctrl+Q again to exit") if self._quit_timer is not None: - self._quit_timer.stop() # type: ignore[union-attr] + self._quit_timer.stop() self._quit_timer = self.set_timer(_QUIT_WINDOW, self._clear_quit_pending) def _clear_quit_pending(self) -> None: self._quit_pressed_at = 0.0 if self._quit_timer is not None: - self._quit_timer.stop() # type: ignore[union-attr] + self._quit_timer.stop() self._quit_timer = None if self._initialized: self._sync_hint() @@ -492,22 +494,23 @@ def _handle_confirm(self, message: str, default: bool) -> bool | None: from dimos.utils.cli.dio.confirm_screen import ConfirmScreen with self._pending_confirms_lock: - if message in self._pending_confirms: - # Another thread is already showing this question — wait for it - event, result = self._pending_confirms[message] - else: + if message not in self._pending_confirms: event = threading.Event() result: list[bool] = [] self._pending_confirms[message] = (event, result) def _push() -> None: - def _on_result(value: bool) -> None: - result.append(value) + def _on_result(value: bool | None) -> None: + if value is not None: + result.append(value) event.set() self.push_screen(ConfirmScreen(message, default), callback=_on_result) self.call_from_thread(_push) + else: + # Another thread is already showing this question — wait for it + event, result = self._pending_confirms[message] event.wait() @@ -524,21 +527,22 @@ def _handle_sudo(self, message: str) -> bool | None: from dimos.utils.cli.dio.confirm_screen import SudoScreen with self._pending_sudos_lock: - if message in self._pending_sudos: - event, result = self._pending_sudos[message] - else: + if message not in self._pending_sudos: event = threading.Event() result: list[bool] = [] self._pending_sudos[message] = (event, result) def _push() -> None: - def _on_result(value: bool) -> None: - result.append(value) + def _on_result(value: bool | None) -> None: + if value is not None: + result.append(value) event.set() self.push_screen(SudoScreen(message), callback=_on_result) self.call_from_thread(_push) + else: + event, result = self._pending_sudos[message] event.wait() diff --git a/dimos/utils/cli/dio/sub_app.py b/dimos/utils/cli/dio/sub_app.py index 3cebf3f5d6..b2a57823a3 100644 --- a/dimos/utils/cli/dio/sub_app.py +++ b/dimos/utils/cli/dio/sub_app.py @@ -16,6 +16,7 @@ from __future__ import annotations +from textual.dom import DOMNode from textual.widget import Widget @@ -41,17 +42,17 @@ class SubApp(Widget): can_focus = False def __init__(self, *args: object, **kwargs: object) -> None: - super().__init__(*args, **kwargs) + super().__init__(*args, **kwargs) # type: ignore[arg-type] self._subapp_initialized = False @property - def has_focus(self) -> bool: + def has_focus(self) -> bool: # type: ignore[override] """True if the currently focused widget is inside this sub-app.""" focused = self.app.focused if focused is None: return False # Walk up the DOM tree to see if focused widget is a descendant - node = focused + node: DOMNode | None = focused while node is not None: if node is self: return True diff --git a/dimos/utils/cli/dio/sub_apps/dtop.py b/dimos/utils/cli/dio/sub_apps/dtop.py index fd035d8f54..ecb63a0734 100644 --- a/dimos/utils/cli/dio/sub_apps/dtop.py +++ b/dimos/utils/cli/dio/sub_apps/dtop.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: from textual.app import ComposeResult + from textual.widget import Widget class DtopSubApp(SubApp): @@ -99,7 +100,7 @@ def compose(self) -> ComposeResult: with VerticalScroll(id="dtop-scroll", classes="waiting"): yield Static(self._waiting_panel(), id="dtop-panels") - def get_focus_target(self) -> object | None: + def get_focus_target(self) -> Widget | None: """Return the VerticalScroll for focus.""" try: return self.query_one("#dtop-scroll") diff --git a/dimos/utils/cli/dio/sub_apps/launcher.py b/dimos/utils/cli/dio/sub_apps/launcher.py index 7177a5b5ec..eaf2727919 100644 --- a/dimos/utils/cli/dio/sub_apps/launcher.py +++ b/dimos/utils/cli/dio/sub_apps/launcher.py @@ -27,6 +27,7 @@ from pathlib import Path from textual.app import ComposeResult + from textual.widget import Widget def _list_running_names() -> list[str]: @@ -128,7 +129,7 @@ def on_resume_subapp(self) -> None: def _start_poll_timer(self) -> None: self.set_interval(2.0, self._sync_status) - def get_focus_target(self) -> object | None: + def get_focus_target(self) -> Widget | None: try: return self.query_one("#launch-filter", Input) except Exception: @@ -231,7 +232,7 @@ def _confirm_and_launch(self, name: str) -> None: message = f"Launch {name}?" warning = False - def _on_confirm(result: bool) -> None: + def _on_confirm(result: bool | None) -> None: _debug_log(f"_on_confirm: result={result}") if result: self._launch(name) diff --git a/dimos/utils/cli/dio/sub_apps/lcmspy.py b/dimos/utils/cli/dio/sub_apps/lcmspy.py index adea2b0c9e..96a30eed41 100644 --- a/dimos/utils/cli/dio/sub_apps/lcmspy.py +++ b/dimos/utils/cli/dio/sub_apps/lcmspy.py @@ -59,7 +59,7 @@ def _debug(self, msg: str) -> None: pass def compose(self) -> ComposeResult: - table: DataTable = DataTable(zebra_stripes=False, cursor_type=None) # type: ignore[arg-type] + table: DataTable[str] = DataTable(zebra_stripes=False, cursor_type=None) # type: ignore[arg-type] table.add_column("Topic") table.add_column("Freq (Hz)") table.add_column("Bandwidth") diff --git a/dimos/utils/cli/dio/sub_apps/runner.py b/dimos/utils/cli/dio/sub_apps/runner.py index 04eb8edfd0..f96900f866 100644 --- a/dimos/utils/cli/dio/sub_apps/runner.py +++ b/dimos/utils/cli/dio/sub_apps/runner.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: from textual.app import ComposeResult + from textual.widget import Widget def _get_all_running() -> list[Any]: @@ -258,7 +259,7 @@ def _start_poll_timer(self) -> None: self.set_interval(1.0, self._poll_running) self._debug("timer started") - def get_focus_target(self) -> object | None: + def get_focus_target(self) -> Widget | None: if self._running_entries or self._launching_name is not None: try: return self.query_one("#runner-log", RichLog) @@ -772,7 +773,7 @@ def _get_clicked_line_number(self, event: Any) -> int: log_widget = self.query_one("#runner-log", RichLog) local_y = event.screen_y - log_widget.region.y line_idx = int(log_widget.scroll_y) + local_y - return max(1, line_idx + 1) + return int(max(1, line_idx + 1)) except Exception: return 1 @@ -920,7 +921,7 @@ def _stop_running(self) -> None: from dimos.utils.cli.dio.confirm_screen import ConfirmScreen - def _on_confirm(result: bool) -> None: + def _on_confirm(result: bool | None) -> None: if result: self._do_stop_confirmed(stop_name, entry)