From b6751c1e1b1b5e342eb84e9af940365312577a08 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Wed, 27 May 2026 14:59:17 +0900 Subject: [PATCH 01/15] feat(launcher): add quick launch tui base Signed-off-by: Mael-RABOT --- Dockerfile.humble | 3 + install.sh | 2 +- launch_lucy.sh | 40 ++--------- launcher.py | 176 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 34 deletions(-) create mode 100644 launcher.py diff --git a/Dockerfile.humble b/Dockerfile.humble index c3edf86..df1b6be 100644 --- a/Dockerfile.humble +++ b/Dockerfile.humble @@ -77,6 +77,9 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && npm install -g yarn \ && rm -rf /var/lib/apt/lists/* +# Setup bashrc with alias for the TUI launcher +RUN echo "alias launcher='python3 /workspace/launcher.py'" >> ~/.bashrc + WORKDIR /workspace ENTRYPOINT ["/bin/bash"] CMD ["-c", "source /opt/ros/humble/setup.bash && exec /bin/bash"] diff --git a/install.sh b/install.sh index 40eb1ea..df4b14a 100755 --- a/install.sh +++ b/install.sh @@ -147,7 +147,7 @@ docker_workspace_install() { read -r -d '' inner_cmd <<'EOS' || true source /opt/ros/humble/setup.bash \ && cd /workspace \ - && rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common micro_ros_agent" \ + && rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common" \ && rm -rf build/camera_ros install/camera_ros \ && colcon build --symlink-install \ && if [ -f src/lucy_control_panel/package.json ]; then \ diff --git a/launch_lucy.sh b/launch_lucy.sh index 532c386..88c70a9 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -120,9 +120,7 @@ SOURCE_WORKSPACE="cd $WORKSPACE && source install/setup.bash" LAUNCH_GAZEBO_RVIZ_BRIDGE_CP="ros2 launch lucy_bringup lucy.launch.py gazebo:=true rviz:=true" LAUNCH_RVIZ_BRIDGE_CP="ros2 launch lucy_bringup lucy.launch.py rviz:=true" -# Preamble run inside the container: source ROS + overlay, then start the Vite -# control panel in the background. An EXIT/INT/TERM trap stops Vite when the -# foreground command (bash -i in dev mode, or `ros2 launch` in normal mode) ends. +# Preamble run inside the container: source ROS + overlay. read -r -d '' CONTAINER_PREAMBLE <<'EOS' || true set -e source /opt/ros/humble/setup.bash @@ -132,30 +130,6 @@ if [[ ! -f install/setup.bash ]]; then exit 1 fi source install/setup.bash - -cleanup_lucy_bg() { - [[ -n "${CP_PID:-}" ]] && kill "$CP_PID" 2>/dev/null || true -} -trap cleanup_lucy_bg EXIT INT TERM - -CP_PID= -if [[ -f src/lucy_control_panel/package.json ]]; then - cd src/lucy_control_panel - if command -v yarn >/dev/null 2>&1; then - yarn dev > /tmp/lucy-control-panel-vite.log 2>&1 & - CP_PID=$! - elif command -v npm >/dev/null 2>&1; then - npm run dev > /tmp/lucy-control-panel-vite.log 2>&1 & - CP_PID=$! - else - echo "Control panel: yarn/npm missing in image; rebuild Docker image." >&2 - fi - cd /workspace - if [[ -n "${CP_PID:-}" ]]; then - disown "$CP_PID" 2>/dev/null || true - echo "Control panel (Vite) in background (PID $CP_PID). Host UI: http://localhost:${LUCY_CP_PUBLISHED_HOST_PORT}/ — log: tail -f /tmp/lucy-control-panel-vite.log" - fi -fi EOS INTERACTIVE_CONTAINER_SCRIPT="${CONTAINER_PREAMBLE} @@ -173,16 +147,16 @@ ${LAUNCH_GAZEBO_RVIZ_BRIDGE_CP} if [ $# -eq 0 ]; then if [ "$DEV_MODE" = 1 ]; then echo "DEV mode: interactive Humble shell (workspace already built by ./install.sh). Mount: $WORKSPACE" - echo " Control panel: http://localhost:${PORT_CONTROL_PANEL}/ — log: tail -f /tmp/lucy-control-panel-vite.log" + echo " Control panel: Use tui_lucy.py to start" echo " Rosbridge on host: port ${PORT_ROSBRIDGE}" echo "" - echo " Typical launches:" - echo " • Gazebo + RViz + Control Panel -> $LAUNCH_GAZEBO_RVIZ_BRIDGE_CP" - echo " • RViz + Control Panel -> $LAUNCH_RVIZ_BRIDGE_CP" + echo " Typical launches (or use tui_lucy.py):" + echo " • Gazebo + RViz -> ros2 launch lucy_bringup lucy.launch.py gazebo:=true rviz:=true" + echo " • RViz -> ros2 launch lucy_bringup lucy.launch.py rviz:=true" CONTAINER_SCRIPT="$INTERACTIVE_CONTAINER_SCRIPT" else - echo "Starting Lucy stack: Control Panel + RViz + Gazebo (set DEV=true for an interactive shell)." - echo " Control panel: http://localhost:${PORT_CONTROL_PANEL}/ — log: tail -f /tmp/lucy-control-panel-vite.log" + echo "Starting Lucy stack: RViz + Gazebo (set DEV=true for an interactive shell)." + echo " Control panel: Use tui_lucy.py to start" echo " Rosbridge on host: port ${PORT_ROSBRIDGE}" echo " Launching: $LAUNCH_GAZEBO_RVIZ_BRIDGE_CP" CONTAINER_SCRIPT="$NORMAL_CONTAINER_SCRIPT" diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..18a9492 --- /dev/null +++ b/launcher.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +import curses +import os +import sys +import subprocess +import time + +def is_in_docker(): + """Check if the script is running inside a Docker container.""" + return os.path.exists('/.dockerenv') + +def is_cp_running(): + """Check if the Control Panel (Vite) is running.""" + result = subprocess.run("ps aux | grep '[v]ite'", shell=True, capture_output=True, text=True) + return result.returncode == 0 + +def stop_cp(): + """Stop the Control Panel completely.""" + os.system("pkill -9 -f 'vite' > /dev/null 2>&1") + time.sleep(0.5) + +def main(stdscr): + """Main function to run the TUI.""" + curses.curs_set(0) + stdscr.nodelay(0) + stdscr.timeout(-1) + curses.start_color() + curses.use_default_colors() + + if curses.has_colors(): + curses.init_pair(1, curses.COLOR_YELLOW, -1) + curses.init_pair(2, curses.COLOR_RED, -1) + curses.init_pair(3, curses.COLOR_CYAN, -1) + else: + curses.init_pair(1, 0, 0) + curses.init_pair(2, 0, 0) + curses.init_pair(3, 0, 0) + + # Use lists instead of tuples so they are mutable + options = [ + ["Core (Lucy Bringup)", "Base robot software stack", False, 'core'], + ["... with Simulator", "(Gazebo)", False, 'modifier'], + ["... with Visualizer", "(RViz)", False, 'modifier'], + ["... with Real Hardware", "(Connect to physical robot)", False, 'modifier'], + ["Control Panel", "Web-based UI (standalone)", is_cp_running(), 'standalone'], + ["Lucy CLI", "Command Line Interface (standalone)", False, 'standalone'], + ] + current_option = 0 + + while True: + # --- Logic updates based on state --- + core_selected = options[0][2] + + for i in range(1, 4): + if not core_selected: + options[i][2] = False + + sim_selected = options[1][2] + real_selected = options[3][2] + + # --- Drawing --- + stdscr.clear() + h, w = stdscr.getmaxyx() + title = "Lucy In-Container Launcher" + stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) + stdscr.addstr(h - 1, 2, "Enter: Launch | Space: Toggle | Q: Quit", curses.A_DIM) + + stdscr.addstr(2, 2, "Primary Launch Target", curses.A_BOLD | curses.color_pair(1)) + + prefix = "> " if current_option == 0 else " " + checkbox = "[x]" if options[0][2] else "[ ]" + stdscr.addstr(4, 4, f"{prefix}{checkbox} {options[0][0]}", curses.A_BOLD) + stdscr.addstr(4, 4 + len(prefix) + len(checkbox) + len(options[0][0]) + 1, f"- {options[0][1]}", curses.A_DIM) + + for i in range(1, 4): + prefix = "> " if current_option == i else " " + checkbox = "[x]" if options[i][2] else "[ ]" + line_attr = curses.A_NORMAL if core_selected else curses.A_DIM + stdscr.addstr(5 + i, 6, f"{prefix}{checkbox} {options[i][0]}", line_attr) + stdscr.addstr(5 + i, 6 + len(prefix) + len(checkbox) + len(options[i][0]) + 1, f"{options[i][1]}", line_attr | curses.A_DIM) + + stdscr.addstr(10, 2, "Standalone Tools", curses.A_BOLD | curses.color_pair(3)) + for i in range(4, 6): + prefix = "> " if current_option == i else " " + checkbox = "[x]" if options[i][2] else "[ ]" + stdscr.addstr(11 + (i - 4), 4, f"{prefix}{checkbox} {options[i][0]}", curses.A_NORMAL) + stdscr.addstr(11 + (i - 4), 4 + len(prefix) + len(checkbox) + len(options[i][0]) + 1, f"- {options[i][1]}", curses.A_DIM) + + if sim_selected and real_selected: + stdscr.addstr(h - 2, 2, "Warning: Simulator and Real Hardware are mutually exclusive.", curses.color_pair(2)) + + stdscr.refresh() + + # --- Input Handling --- + key = stdscr.getch() + + if key == curses.KEY_UP: + current_option = (current_option - 1) % len(options) + elif key == curses.KEY_DOWN: + current_option = (current_option + 1) % len(options) + elif key == ord(' '): + options[current_option][2] = not options[current_option][2] + + if current_option == 1 and options[1][2]: + options[3][2] = False + elif current_option == 3 and options[3][2]: + options[1][2] = False + + elif key == ord('\n'): + break + elif key == ord('q') or key == ord('Q') or key == 27: + return "Quit", None + + selections = [opt[2] for opt in options] + return "Launch", selections + + +if __name__ == "__main__": + if not is_in_docker(): + print("Error: This script must be run inside the Lucy Docker container.", file=sys.stderr) + sys.exit(1) + + if not sys.stdout.isatty(): + print("Error: This TUI must be run in a terminal.", file=sys.stderr) + sys.exit(1) + + try: + status, message = curses.wrapper(main) + except Exception as e: + print(f"A terminal error occurred: {e}", file=sys.stderr) + sys.exit(1) + + if status == "Quit": + print("No action taken.") + sys.exit(0) + + if status == "Error": + print(f"\nError: {message}", file=sys.stderr) + sys.exit(1) + + if status == "Launch": + core, sim, rviz, real, cp, cli = message + + cp_is_running = is_cp_running() + if cp and not cp_is_running: + print("Starting Control Panel...") + subprocess.Popen(["yarn", "dev"], cwd="/workspace/src/lucy_control_panel", stdout=open("/tmp/lucy-cp.log", "w"), stderr=subprocess.STDOUT, preexec_fn=os.setpgrp) + elif not cp and cp_is_running: + print("Stopping Control Panel...") + stop_cp() + + ros_cmd = "" + if cli: + if core: + print("Warning: Lucy CLI is a standalone tool. Ignoring Core launch options.", file=sys.stderr) + ros_cmd = "ros2 run lucy_cli tui" + elif core: + launch_args = [] + if sim: + launch_args.append("gazebo:=true") + if rviz: + launch_args.append("rviz:=true") + if real: + launch_args.append("real:=true") + ros_cmd = f"ros2 launch lucy_bringup lucy.launch.py {' '.join(launch_args)}" + + if ros_cmd: + print(f"\nExecuting: {ros_cmd}") + print("-" * 50) + try: + subprocess.run(ros_cmd, shell=True, check=True) + except (subprocess.CalledProcessError, KeyboardInterrupt): + print("\nCommand terminated.") + else: + print("\nNo primary target selected to launch.") From a87f5324b0e8e65b0bd7f5ace49bba473338b148 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Wed, 27 May 2026 15:02:48 +0900 Subject: [PATCH 02/15] evol(launcher): config Signed-off-by: Mael-RABOT --- config/launcher_config.json | 68 +++++++++ launcher.py | 265 ++++++++++++++++++++++-------------- 2 files changed, 234 insertions(+), 99 deletions(-) create mode 100644 config/launcher_config.json diff --git a/config/launcher_config.json b/config/launcher_config.json new file mode 100644 index 0000000..2490694 --- /dev/null +++ b/config/launcher_config.json @@ -0,0 +1,68 @@ +{ + "packages": [ + { + "id": "core", + "name": "Core (Lucy Bringup)", + "description": "Base robot software stack", + "type": "core", + "dependencies": [], + "conflicts": [], + "command": "ros2 launch lucy_bringup lucy.launch.py", + "default_on": false + }, + { + "id": "gazebo", + "name": "... with Simulator", + "description": "(Gazebo)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": ["real"], + "command": "gazebo:=true", + "default_on": false + }, + { + "id": "rviz", + "name": "... with Visualizer", + "description": "(RViz)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": [], + "command": "rviz:=true", + "default_on": false + }, + { + "id": "real", + "name": "... with Real Hardware", + "description": "(Connect to physical robot)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": ["gazebo"], + "command": "real:=true", + "default_on": false + }, + { + "id": "control_panel", + "name": "Control Panel", + "description": "Web-based UI", + "type": "standalone", + "dependencies": [], + "conflicts": ["lucy_cli"], + "command": { + "start": "cd /workspace/src/lucy_control_panel && yarn dev", + "stop": "pkill -9 -f 'vite'", + "is_running": "ps aux | grep '[v]ite'" + }, + "default_on": false + }, + { + "id": "lucy_cli", + "name": "Lucy CLI", + "description": "Command Line Interface", + "type": "standalone", + "dependencies": ["core"], + "conflicts": ["control_panel", "core"], + "command": "ros2 run lucy_cli tui", + "default_on": false + } + ] +} diff --git a/launcher.py b/launcher.py index 18a9492..84c20b9 100644 --- a/launcher.py +++ b/launcher.py @@ -5,23 +5,89 @@ import sys import subprocess import time +import json + +CONFIG_FILE = "/workspace/config/launcher_config.json" def is_in_docker(): - """Check if the script is running inside a Docker container.""" return os.path.exists('/.dockerenv') -def is_cp_running(): - """Check if the Control Panel (Vite) is running.""" - result = subprocess.run("ps aux | grep '[v]ite'", shell=True, capture_output=True, text=True) - return result.returncode == 0 - -def stop_cp(): - """Stop the Control Panel completely.""" - os.system("pkill -9 -f 'vite' > /dev/null 2>&1") - time.sleep(0.5) +def load_config(): + if not os.path.exists(CONFIG_FILE): + print(f"Error: Configuration file not found at {CONFIG_FILE}", file=sys.stderr) + sys.exit(1) + with open(CONFIG_FILE, 'r') as f: + try: + return json.load(f) + except json.JSONDecodeError as e: + print(f"Error parsing config file: {e}", file=sys.stderr) + sys.exit(1) + +def run_shell_command(cmd, capture_output=False): + """Utility to run a shell command.""" + if capture_output: + result = subprocess.run(cmd, shell=True, capture_output=True, text=True) + return result.returncode == 0 + else: + subprocess.run(cmd, shell=True) + +class Package: + def __init__(self, data): + self.id = data['id'] + self.name = data['name'] + self.description = data.get('description', '') + self.type = data['type'] + self.dependencies = data.get('dependencies', []) + self.conflicts = data.get('conflicts', []) + self.command = data.get('command', '') + self.selected = data.get('default_on', False) + + # Determine initial state for complex standalone apps (like control panel) + if isinstance(self.command, dict) and 'is_running' in self.command: + self.selected = run_shell_command(self.command['is_running'], capture_output=True) + + def is_standalone_background(self): + return isinstance(self.command, dict) + +class LauncherState: + def __init__(self, config_data): + self.packages = [Package(p) for p in config_data['packages']] + self.package_map = {p.id: p for p in self.packages} + + def get_by_id(self, pkg_id): + return self.package_map.get(pkg_id) + + def toggle(self, pkg_id): + pkg = self.get_by_id(pkg_id) + if not pkg: return + + # If turning ON + if not pkg.selected: + # Check dependencies + missing_deps = [dep for dep in pkg.dependencies if not self.get_by_id(dep).selected] + if missing_deps: + return f"Cannot enable '{pkg.name}'. Missing dependencies: {', '.join(missing_deps)}" + + # Resolve conflicts (turn off conflicting packages) + for conflict_id in pkg.conflicts: + conflict_pkg = self.get_by_id(conflict_id) + if conflict_pkg and conflict_pkg.selected: + conflict_pkg.selected = False + + pkg.selected = True + + # If turning OFF + else: + # Turn off anything that depends on this + for other_pkg in self.packages: + if pkg_id in other_pkg.dependencies and other_pkg.selected: + other_pkg.selected = False + + pkg.selected = False + + return None # No error def main(stdscr): - """Main function to run the TUI.""" curses.curs_set(0) stdscr.nodelay(0) stdscr.timeout(-1) @@ -37,83 +103,77 @@ def main(stdscr): curses.init_pair(2, 0, 0) curses.init_pair(3, 0, 0) - # Use lists instead of tuples so they are mutable - options = [ - ["Core (Lucy Bringup)", "Base robot software stack", False, 'core'], - ["... with Simulator", "(Gazebo)", False, 'modifier'], - ["... with Visualizer", "(RViz)", False, 'modifier'], - ["... with Real Hardware", "(Connect to physical robot)", False, 'modifier'], - ["Control Panel", "Web-based UI (standalone)", is_cp_running(), 'standalone'], - ["Lucy CLI", "Command Line Interface (standalone)", False, 'standalone'], - ] - current_option = 0 + config_data = load_config() + state = LauncherState(config_data) + + current_idx = 0 + error_msg = None while True: - # --- Logic updates based on state --- - core_selected = options[0][2] - - for i in range(1, 4): - if not core_selected: - options[i][2] = False - - sim_selected = options[1][2] - real_selected = options[3][2] - - # --- Drawing --- stdscr.clear() h, w = stdscr.getmaxyx() - title = "Lucy In-Container Launcher" + title = "Lucy Configurable Launcher" stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) stdscr.addstr(h - 1, 2, "Enter: Launch | Space: Toggle | Q: Quit", curses.A_DIM) - stdscr.addstr(2, 2, "Primary Launch Target", curses.A_BOLD | curses.color_pair(1)) + if error_msg: + stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) + error_msg = None # Clear after displaying once + + # Group packages for display + cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier']] + standalones = [p for p in state.packages if p.type == 'standalone'] + + display_list = [] - prefix = "> " if current_option == 0 else " " - checkbox = "[x]" if options[0][2] else "[ ]" - stdscr.addstr(4, 4, f"{prefix}{checkbox} {options[0][0]}", curses.A_BOLD) - stdscr.addstr(4, 4 + len(prefix) + len(checkbox) + len(options[0][0]) + 1, f"- {options[0][1]}", curses.A_DIM) - - for i in range(1, 4): - prefix = "> " if current_option == i else " " - checkbox = "[x]" if options[i][2] else "[ ]" - line_attr = curses.A_NORMAL if core_selected else curses.A_DIM - stdscr.addstr(5 + i, 6, f"{prefix}{checkbox} {options[i][0]}", line_attr) - stdscr.addstr(5 + i, 6 + len(prefix) + len(checkbox) + len(options[i][0]) + 1, f"{options[i][1]}", line_attr | curses.A_DIM) - - stdscr.addstr(10, 2, "Standalone Tools", curses.A_BOLD | curses.color_pair(3)) - for i in range(4, 6): - prefix = "> " if current_option == i else " " - checkbox = "[x]" if options[i][2] else "[ ]" - stdscr.addstr(11 + (i - 4), 4, f"{prefix}{checkbox} {options[i][0]}", curses.A_NORMAL) - stdscr.addstr(11 + (i - 4), 4 + len(prefix) + len(checkbox) + len(options[i][0]) + 1, f"- {options[i][1]}", curses.A_DIM) - - if sim_selected and real_selected: - stdscr.addstr(h - 2, 2, "Warning: Simulator and Real Hardware are mutually exclusive.", curses.color_pair(2)) + row = 2 + stdscr.addstr(row, 2, "Primary Launch Targets", curses.A_BOLD | curses.color_pair(1)) + row += 2 + for p in cores_and_mods: + display_list.append(p) + prefix = "> " if current_idx == len(display_list) - 1 else " " + checkbox = "[x]" if p.selected else "[ ]" + + # Determine visual state based on dependencies + can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies) + attr = curses.A_NORMAL if can_enable else curses.A_DIM + if p.type == 'core': attr |= curses.A_BOLD + + indent = " " if p.type == 'modifier' else "" + stdscr.addstr(row, 4, f"{prefix}{indent}{checkbox} {p.name}", attr) + stdscr.addstr(row, 4 + len(prefix) + len(indent) + len(checkbox) + len(p.name) + 1, f"- {p.description}", attr | curses.A_DIM) + row += 1 + + row += 1 + stdscr.addstr(row, 2, "Standalone Tools", curses.A_BOLD | curses.color_pair(3)) + row += 1 + for p in standalones: + display_list.append(p) + prefix = "> " if current_idx == len(display_list) - 1 else " " + checkbox = "[x]" if p.selected else "[ ]" + stdscr.addstr(row, 4, f"{prefix}{checkbox} {p.name}", curses.A_NORMAL) + stdscr.addstr(row, 4 + len(prefix) + len(checkbox) + len(p.name) + 1, f"- {p.description}", curses.A_DIM) + row += 1 stdscr.refresh() - # --- Input Handling --- key = stdscr.getch() if key == curses.KEY_UP: - current_option = (current_option - 1) % len(options) + current_idx = (current_idx - 1) % len(display_list) elif key == curses.KEY_DOWN: - current_option = (current_option + 1) % len(options) + current_idx = (current_idx + 1) % len(display_list) elif key == ord(' '): - options[current_option][2] = not options[current_option][2] - - if current_option == 1 and options[1][2]: - options[3][2] = False - elif current_option == 3 and options[3][2]: - options[1][2] = False - + pkg_to_toggle = display_list[current_idx] + err = state.toggle(pkg_to_toggle.id) + if err: + error_msg = err elif key == ord('\n'): break elif key == ord('q') or key == ord('Q') or key == 27: return "Quit", None - selections = [opt[2] for opt in options] - return "Launch", selections + return "Launch", state if __name__ == "__main__": @@ -126,7 +186,7 @@ def main(stdscr): sys.exit(1) try: - status, message = curses.wrapper(main) + status, state = curses.wrapper(main) except Exception as e: print(f"A terminal error occurred: {e}", file=sys.stderr) sys.exit(1) @@ -134,43 +194,50 @@ def main(stdscr): if status == "Quit": print("No action taken.") sys.exit(0) - - if status == "Error": - print(f"\nError: {message}", file=sys.stderr) - sys.exit(1) if status == "Launch": - core, sim, rviz, real, cp, cli = message + # 1. Handle Background Standalones (like Control Panel) + for pkg in state.packages: + if pkg.is_standalone_background(): + was_running = run_shell_command(pkg.command['is_running'], capture_output=True) + if pkg.selected and not was_running: + print(f"Starting {pkg.name}...") + # Using Popen to run in background + subprocess.Popen( + pkg.command['start'], + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + preexec_fn=os.setpgrp + ) + elif not pkg.selected and was_running: + print(f"Stopping {pkg.name}...") + run_shell_command(pkg.command['stop']) + time.sleep(0.5) + + # 2. Build Primary Execution Command + core_pkg = next((p for p in state.packages if p.type == 'core' and p.selected), None) + cli_pkg = next((p for p in state.packages if p.id == 'lucy_cli' and p.selected), None) - cp_is_running = is_cp_running() - if cp and not cp_is_running: - print("Starting Control Panel...") - subprocess.Popen(["yarn", "dev"], cwd="/workspace/src/lucy_control_panel", stdout=open("/tmp/lucy-cp.log", "w"), stderr=subprocess.STDOUT, preexec_fn=os.setpgrp) - elif not cp and cp_is_running: - print("Stopping Control Panel...") - stop_cp() - - ros_cmd = "" - if cli: - if core: - print("Warning: Lucy CLI is a standalone tool. Ignoring Core launch options.", file=sys.stderr) - ros_cmd = "ros2 run lucy_cli tui" - elif core: - launch_args = [] - if sim: - launch_args.append("gazebo:=true") - if rviz: - launch_args.append("rviz:=true") - if real: - launch_args.append("real:=true") - ros_cmd = f"ros2 launch lucy_bringup lucy.launch.py {' '.join(launch_args)}" + final_cmd = "" - if ros_cmd: - print(f"\nExecuting: {ros_cmd}") + if cli_pkg: + final_cmd = cli_pkg.command + elif core_pkg: + base_cmd = core_pkg.command + args = [] + for pkg in state.packages: + if pkg.type == 'modifier' and pkg.selected and pkg.command: + args.append(pkg.command) + final_cmd = f"{base_cmd} {' '.join(args)}" + + # 3. Execute + if final_cmd: + print(f"\nExecuting: {final_cmd}") print("-" * 50) try: - subprocess.run(ros_cmd, shell=True, check=True) + subprocess.run(final_cmd, shell=True, check=True) except (subprocess.CalledProcessError, KeyboardInterrupt): print("\nCommand terminated.") else: - print("\nNo primary target selected to launch.") + print("\nConfiguration applied. No foreground command to execute.") From 83bb1480cbc6f2c801d1cc28f6da012dafe02ee9 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Wed, 27 May 2026 15:37:52 +0900 Subject: [PATCH 03/15] evol(launcher): tmux support Signed-off-by: Mael-RABOT --- config/launcher_config.json | 12 +- launch_lucy.sh | 33 +++-- launcher.py | 276 +++++++++++++++++++----------------- 3 files changed, 167 insertions(+), 154 deletions(-) diff --git a/config/launcher_config.json b/config/launcher_config.json index 2490694..5afd5bc 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -43,14 +43,14 @@ { "id": "control_panel", "name": "Control Panel", - "description": "Web-based UI", + "description": "Web-based UI (background)", "type": "standalone", "dependencies": [], - "conflicts": ["lucy_cli"], + "conflicts": [], "command": { - "start": "cd /workspace/src/lucy_control_panel && yarn dev", - "stop": "pkill -9 -f 'vite'", - "is_running": "ps aux | grep '[v]ite'" + "start": "tmux new-window -d -n control_panel 'cd /workspace/src/lucy_control_panel && yarn dev'", + "stop": "tmux kill-window -t lucy_ws:control_panel 2>/dev/null || true", + "is_running": "tmux list-windows -F '#{window_name}' | grep -q '^control_panel$'" }, "default_on": false }, @@ -60,7 +60,7 @@ "description": "Command Line Interface", "type": "standalone", "dependencies": ["core"], - "conflicts": ["control_panel", "core"], + "conflicts": [], "command": "ros2 run lucy_cli tui", "default_on": false } diff --git a/launch_lucy.sh b/launch_lucy.sh index 88c70a9..3842d26 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -132,8 +132,20 @@ fi source install/setup.bash EOS +# In DEV mode, attach to a tmux session. Exiting the last tmux window will exit the container. +read -r -d '' TMUX_SCRIPT <<'EOS' || true +if [ -z "$TMUX" ]; then + # Attach to session if it exists, otherwise create it. + # When the last window is closed, the server exits, the script ends, and the container stops. + tmux attach-session -t lucy_ws || tmux new-session -s lucy_ws -n 'Lucy Workspace' +else + # Already inside tmux, do nothing special. + bash -i +fi +EOS + INTERACTIVE_CONTAINER_SCRIPT="${CONTAINER_PREAMBLE} -bash -i +${TMUX_SCRIPT} " NORMAL_CONTAINER_SCRIPT="${CONTAINER_PREAMBLE} @@ -145,23 +157,9 @@ ${LAUNCH_GAZEBO_RVIZ_BRIDGE_CP} # ---------------------------------------------------------------------------- if [ $# -eq 0 ]; then - if [ "$DEV_MODE" = 1 ]; then - echo "DEV mode: interactive Humble shell (workspace already built by ./install.sh). Mount: $WORKSPACE" - echo " Control panel: Use tui_lucy.py to start" - echo " Rosbridge on host: port ${PORT_ROSBRIDGE}" - echo "" - echo " Typical launches (or use tui_lucy.py):" - echo " • Gazebo + RViz -> ros2 launch lucy_bringup lucy.launch.py gazebo:=true rviz:=true" - echo " • RViz -> ros2 launch lucy_bringup lucy.launch.py rviz:=true" - CONTAINER_SCRIPT="$INTERACTIVE_CONTAINER_SCRIPT" - else - echo "Starting Lucy stack: RViz + Gazebo (set DEV=true for an interactive shell)." - echo " Control panel: Use tui_lucy.py to start" - echo " Rosbridge on host: port ${PORT_ROSBRIDGE}" - echo " Launching: $LAUNCH_GAZEBO_RVIZ_BRIDGE_CP" - CONTAINER_SCRIPT="$NORMAL_CONTAINER_SCRIPT" - fi + CONTAINER_SCRIPT="$INTERACTIVE_CONTAINER_SCRIPT" docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${DOCKER_RUN_IT[@]}" --rm \ + --name lucy_dev \ "${DOCKER_PORT_ARGS[@]}" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "${X11_ARGS[@]}" \ @@ -170,6 +168,7 @@ if [ $# -eq 0 ]; then "$IMAGE_NAME" -c "$CONTAINER_SCRIPT" else docker run "${DOCKER_RUN_PLATFORM_ARGS[@]}" "${DOCKER_RUN_IT[@]}" --rm \ + --name lucy_dev \ "${DOCKER_PORT_ARGS[@]}" \ -v "$SCRIPT_DIR:$WORKSPACE" \ "${X11_ARGS[@]}" \ diff --git a/launcher.py b/launcher.py index 84c20b9..b8af973 100644 --- a/launcher.py +++ b/launcher.py @@ -8,31 +8,41 @@ import json CONFIG_FILE = "/workspace/config/launcher_config.json" +STATE_FILE = "/tmp/launcher_state.json" def is_in_docker(): return os.path.exists('/.dockerenv') +def is_in_tmux(): + return 'TMUX' in os.environ + def load_config(): if not os.path.exists(CONFIG_FILE): - print(f"Error: Configuration file not found at {CONFIG_FILE}", file=sys.stderr) - sys.exit(1) + raise FileNotFoundError(f"Configuration file not found at {CONFIG_FILE}") with open(CONFIG_FILE, 'r') as f: + return json.load(f) + +def load_state(): + if not os.path.exists(STATE_FILE): + return {"modifiers": []} + with open(STATE_FILE, 'r') as f: try: return json.load(f) - except json.JSONDecodeError as e: - print(f"Error parsing config file: {e}", file=sys.stderr) - sys.exit(1) + except json.JSONDecodeError: + return {"modifiers": []} + +def save_state(state_data): + with open(STATE_FILE, 'w') as f: + json.dump(state_data, f) def run_shell_command(cmd, capture_output=False): - """Utility to run a shell command.""" if capture_output: - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - return result.returncode == 0 + return subprocess.run(cmd, shell=True, capture_output=True, text=True).returncode == 0 else: subprocess.run(cmd, shell=True) class Package: - def __init__(self, data): + def __init__(self, data, running_modifiers): self.id = data['id'] self.name = data['name'] self.description = data.get('description', '') @@ -42,16 +52,27 @@ def __init__(self, data): self.command = data.get('command', '') self.selected = data.get('default_on', False) - # Determine initial state for complex standalone apps (like control panel) - if isinstance(self.command, dict) and 'is_running' in self.command: - self.selected = run_shell_command(self.command['is_running'], capture_output=True) + self.update_running_status(running_modifiers) - def is_standalone_background(self): + def update_running_status(self, running_modifiers): + if self.is_complex_command(): + self.selected = run_shell_command(self.command['is_running'], capture_output=True) + elif self.type == 'modifier': + self.selected = self.id in running_modifiers + elif self.type == 'core': + self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) + if not self.selected: # If core isn't running, clear modifier state + save_state({"modifiers": []}) + elif self.type == 'standalone': + self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) + + def is_complex_command(self): return isinstance(self.command, dict) class LauncherState: def __init__(self, config_data): - self.packages = [Package(p) for p in config_data['packages']] + running_state = load_state() + self.packages = [Package(p, running_state['modifiers']) for p in config_data['packages']] self.package_map = {p.id: p for p in self.packages} def get_by_id(self, pkg_id): @@ -59,33 +80,64 @@ def get_by_id(self, pkg_id): def toggle(self, pkg_id): pkg = self.get_by_id(pkg_id) - if not pkg: return + if not pkg: return None - # If turning ON if not pkg.selected: - # Check dependencies missing_deps = [dep for dep in pkg.dependencies if not self.get_by_id(dep).selected] if missing_deps: - return f"Cannot enable '{pkg.name}'. Missing dependencies: {', '.join(missing_deps)}" - - # Resolve conflicts (turn off conflicting packages) + return f"Needs: {', '.join(missing_deps)}" + for conflict_id in pkg.conflicts: conflict_pkg = self.get_by_id(conflict_id) if conflict_pkg and conflict_pkg.selected: conflict_pkg.selected = False - pkg.selected = True - - # If turning OFF else: - # Turn off anything that depends on this for other_pkg in self.packages: if pkg_id in other_pkg.dependencies and other_pkg.selected: other_pkg.selected = False - pkg.selected = False - - return None # No error + return None + +def draw_tui(stdscr, state, current_idx, error_msg): + stdscr.clear() + h, w = stdscr.getmaxyx() + title = "Lucy Configurable Launcher (TMUX)" + stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) + + # Updated footer to include the Exit Workspace option + stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | Q: Close Launcher | X: Stop All & Exit Docker", curses.A_DIM) + + if error_msg: + stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) + + cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier']] + standalones = [p for p in state.packages if p.type == 'standalone'] + display_list = cores_and_mods + standalones + + row = 2 + stdscr.addstr(row, 2, "Primary Launch Targets", curses.A_BOLD | curses.color_pair(1)) + row += 2 + for i, p in enumerate(cores_and_mods): + prefix = "> " if current_idx == i else " " + checkbox = "[x]" if p.selected else "[ ]" + can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies) + attr = curses.A_NORMAL if can_enable else curses.A_DIM + if p.type == 'core': attr |= curses.A_BOLD + indent = " " if p.type == 'modifier' else "" + stdscr.addstr(row + i, 4, f"{prefix}{indent}{checkbox} {p.name}", attr) + + row += len(cores_and_mods) + 1 + stdscr.addstr(row, 2, "Standalone Tools", curses.A_BOLD | curses.color_pair(3)) + row += 1 + for i, p in enumerate(standalones): + list_idx = i + len(cores_and_mods) + prefix = "> " if current_idx == list_idx else " " + checkbox = "[x]" if p.selected else "[ ]" + stdscr.addstr(row + i, 4, f"{prefix}{checkbox} {p.name}", curses.A_NORMAL) + + stdscr.refresh() + return display_list def main(stdscr): curses.curs_set(0) @@ -98,65 +150,15 @@ def main(stdscr): curses.init_pair(1, curses.COLOR_YELLOW, -1) curses.init_pair(2, curses.COLOR_RED, -1) curses.init_pair(3, curses.COLOR_CYAN, -1) - else: - curses.init_pair(1, 0, 0) - curses.init_pair(2, 0, 0) - curses.init_pair(3, 0, 0) - config_data = load_config() - state = LauncherState(config_data) - + state = LauncherState(load_config()) current_idx = 0 error_msg = None while True: - stdscr.clear() - h, w = stdscr.getmaxyx() - title = "Lucy Configurable Launcher" - stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) - stdscr.addstr(h - 1, 2, "Enter: Launch | Space: Toggle | Q: Quit", curses.A_DIM) - - if error_msg: - stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) - error_msg = None # Clear after displaying once - - # Group packages for display - cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier']] - standalones = [p for p in state.packages if p.type == 'standalone'] - - display_list = [] + display_list = draw_tui(stdscr, state, current_idx, error_msg) + error_msg = None - row = 2 - stdscr.addstr(row, 2, "Primary Launch Targets", curses.A_BOLD | curses.color_pair(1)) - row += 2 - for p in cores_and_mods: - display_list.append(p) - prefix = "> " if current_idx == len(display_list) - 1 else " " - checkbox = "[x]" if p.selected else "[ ]" - - # Determine visual state based on dependencies - can_enable = all(state.get_by_id(dep).selected for dep in p.dependencies) - attr = curses.A_NORMAL if can_enable else curses.A_DIM - if p.type == 'core': attr |= curses.A_BOLD - - indent = " " if p.type == 'modifier' else "" - stdscr.addstr(row, 4, f"{prefix}{indent}{checkbox} {p.name}", attr) - stdscr.addstr(row, 4 + len(prefix) + len(indent) + len(checkbox) + len(p.name) + 1, f"- {p.description}", attr | curses.A_DIM) - row += 1 - - row += 1 - stdscr.addstr(row, 2, "Standalone Tools", curses.A_BOLD | curses.color_pair(3)) - row += 1 - for p in standalones: - display_list.append(p) - prefix = "> " if current_idx == len(display_list) - 1 else " " - checkbox = "[x]" if p.selected else "[ ]" - stdscr.addstr(row, 4, f"{prefix}{checkbox} {p.name}", curses.A_NORMAL) - stdscr.addstr(row, 4 + len(prefix) + len(checkbox) + len(p.name) + 1, f"- {p.description}", curses.A_DIM) - row += 1 - - stdscr.refresh() - key = stdscr.getch() if key == curses.KEY_UP: @@ -165,24 +167,17 @@ def main(stdscr): current_idx = (current_idx + 1) % len(display_list) elif key == ord(' '): pkg_to_toggle = display_list[current_idx] - err = state.toggle(pkg_to_toggle.id) - if err: - error_msg = err + error_msg = state.toggle(pkg_to_toggle.id) elif key == ord('\n'): - break - elif key == ord('q') or key == ord('Q') or key == 27: + return "Launch", state + elif key in [ord('x'), ord('X')]: + return "ExitWorkspace", state + elif key in [ord('q'), ord('Q'), 27]: return "Quit", None - return "Launch", state - - if __name__ == "__main__": - if not is_in_docker(): - print("Error: This script must be run inside the Lucy Docker container.", file=sys.stderr) - sys.exit(1) - - if not sys.stdout.isatty(): - print("Error: This TUI must be run in a terminal.", file=sys.stderr) + if not is_in_docker() or not is_in_tmux(): + print("Error: This script must be run inside the 'lucy_ws' tmux session within the Docker container.", file=sys.stderr) sys.exit(1) try: @@ -195,49 +190,68 @@ def main(stdscr): print("No action taken.") sys.exit(0) + if status == "ExitWorkspace": + print("\nStopping all processes and exiting workspace...") + + # 1. Gracefully stop complex background processes (like the Control Panel) + for pkg in state.packages: + if pkg.is_complex_command(): + run_shell_command(pkg.command['stop']) + + # 2. Clear state file to avoid stale data on next boot + if os.path.exists(STATE_FILE): + os.remove(STATE_FILE) + + # 3. Kill the entire tmux session. + # This will shut down tmux, end the launch_lucy.sh script, and exit the container! + print("Terminating tmux session...") + time.sleep(0.5) + run_shell_command("tmux kill-session -t lucy_ws 2>/dev/null") + sys.exit(0) + if status == "Launch": - # 1. Handle Background Standalones (like Control Panel) + last_launched_window = None + + # 1. Handle Complex/Background Standalones for pkg in state.packages: - if pkg.is_standalone_background(): + if pkg.is_complex_command(): was_running = run_shell_command(pkg.command['is_running'], capture_output=True) if pkg.selected and not was_running: - print(f"Starting {pkg.name}...") - # Using Popen to run in background - subprocess.Popen( - pkg.command['start'], - shell=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - preexec_fn=os.setpgrp - ) + print(f"Executing start command for {pkg.name}...") + run_shell_command(pkg.command['start']) elif not pkg.selected and was_running: - print(f"Stopping {pkg.name}...") + print(f"Executing stop command for {pkg.name}...") run_shell_command(pkg.command['stop']) - time.sleep(0.5) - # 2. Build Primary Execution Command - core_pkg = next((p for p in state.packages if p.type == 'core' and p.selected), None) - cli_pkg = next((p for p in state.packages if p.id == 'lucy_cli' and p.selected), None) - - final_cmd = "" - - if cli_pkg: - final_cmd = cli_pkg.command - elif core_pkg: + # 2. Handle Core/Modifier Processes + core_pkg = next((p for p in state.packages if p.type == 'core'), None) + if core_pkg.selected: + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") base_cmd = core_pkg.command - args = [] - for pkg in state.packages: - if pkg.type == 'modifier' and pkg.selected and pkg.command: - args.append(pkg.command) - final_cmd = f"{base_cmd} {' '.join(args)}" - - # 3. Execute - if final_cmd: - print(f"\nExecuting: {final_cmd}") - print("-" * 50) - try: - subprocess.run(final_cmd, shell=True, check=True) - except (subprocess.CalledProcessError, KeyboardInterrupt): - print("\nCommand terminated.") + + selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] + modifier_args = [p.command for p in selected_modifiers] + modifier_ids = [p.id for p in selected_modifiers] + + full_cmd = f"{base_cmd} {' '.join(modifier_args)}" + print(f"Launching Core in 'core' window: {full_cmd}") + run_shell_command(f"tmux new-window -d -t lucy_ws -n core '{full_cmd}; echo \"--- Process finished, press any key to close ---\"; read'") + save_state({"modifiers": modifier_ids}) else: - print("\nConfiguration applied. No foreground command to execute.") + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + save_state({"modifiers": []}) + + # 3. Handle Simple Standalone Processes + for pkg in state.packages: + if not pkg.is_complex_command() and pkg.type == 'standalone': + run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") + if pkg.selected: + print(f"Launching {pkg.name} in '{pkg.id}' window...") + run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") + last_launched_window = pkg.id + + if last_launched_window: + run_shell_command(f"tmux select-window -t lucy_ws:{last_launched_window}") + + print("\nConfiguration applied. Check tmux windows for status.") + print("Use `Ctrl+B, w` to see all windows.") From 57160c5dfe4ac2834bd3a95eb8352c45c61b63b8 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Wed, 27 May 2026 15:56:07 +0900 Subject: [PATCH 04/15] evol(launcher): QoL Signed-off-by: Mael-RABOT --- config/launcher_config.json | 10 +++ launch_lucy.sh | 13 +++- launcher.py | 126 ++++++++++++++++-------------------- 3 files changed, 77 insertions(+), 72 deletions(-) diff --git a/config/launcher_config.json b/config/launcher_config.json index 5afd5bc..c10d3bf 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -63,6 +63,16 @@ "conflicts": [], "command": "ros2 run lucy_cli tui", "default_on": false + }, + { + "id": "console", + "name": "Open Console", + "description": "Opens a new interactive terminal window", + "type": "standalone", + "dependencies": [], + "conflicts": [], + "command": "echo \"To navigate, press Ctrl+B then W\" && bash -i", + "default_on": false } ] } diff --git a/launch_lucy.sh b/launch_lucy.sh index 3842d26..e9911ab 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -135,9 +135,18 @@ EOS # In DEV mode, attach to a tmux session. Exiting the last tmux window will exit the container. read -r -d '' TMUX_SCRIPT <<'EOS' || true if [ -z "$TMUX" ]; then - # Attach to session if it exists, otherwise create it. + # Start tmux server and create session if it doesn't exist + tmux start-server + if ! tmux has-session -t lucy_ws 2>/dev/null; then + tmux new-session -d -s lucy_ws -n 'Lucy Workspace' + fi + + # Send the command + tmux send-keys -t lucy_ws "launcher" C-m + + # Attach to session. # When the last window is closed, the server exits, the script ends, and the container stops. - tmux attach-session -t lucy_ws || tmux new-session -s lucy_ws -n 'Lucy Workspace' + tmux attach-session -t lucy_ws else # Already inside tmux, do nothing special. bash -i diff --git a/launcher.py b/launcher.py index b8af973..7d9ec9d 100644 --- a/launcher.py +++ b/launcher.py @@ -61,7 +61,7 @@ def update_running_status(self, running_modifiers): self.selected = self.id in running_modifiers elif self.type == 'core': self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) - if not self.selected: # If core isn't running, clear modifier state + if not self.selected: save_state({"modifiers": []}) elif self.type == 'standalone': self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) @@ -99,16 +99,16 @@ def toggle(self, pkg_id): pkg.selected = False return None -def draw_tui(stdscr, state, current_idx, error_msg): +def draw_tui(stdscr, state, current_idx, error_msg, status_msg): stdscr.clear() h, w = stdscr.getmaxyx() - title = "Lucy Configurable Launcher (TMUX)" + title = "Lucy Control Center" stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) - - # Updated footer to include the Exit Workspace option - stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | Q: Close Launcher | X: Stop All & Exit Docker", curses.A_DIM) + stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | X: Stop All & Exit Docker", curses.A_DIM) - if error_msg: + if status_msg: + stdscr.addstr(h - 2, 2, status_msg, curses.A_BOLD) + elif error_msg: stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier']] @@ -139,6 +139,38 @@ def draw_tui(stdscr, state, current_idx, error_msg): stdscr.refresh() return display_list +def apply_changes(state): + last_launched_window = None + + for pkg in state.packages: + if pkg.is_complex_command(): + was_running = run_shell_command(pkg.command['is_running'], capture_output=True) + if pkg.selected and not was_running: + run_shell_command(pkg.command['start']) + elif not pkg.selected and was_running: + run_shell_command(pkg.command['stop']) + elif pkg.type == 'core': + if pkg.selected: + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + base_cmd = pkg.command + selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] + modifier_args = [p.command for p in selected_modifiers] + modifier_ids = [p.id for p in selected_modifiers] + full_cmd = f"{base_cmd} {' '.join(modifier_args)}" + run_shell_command(f"tmux new-window -d -t lucy_ws -n core '{full_cmd}; echo \"--- Process finished, press any key to close ---\"; read'") + save_state({"modifiers": modifier_ids}) + else: + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + save_state({"modifiers": []}) + elif pkg.type == 'standalone': + run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") + if pkg.selected: + run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") + last_launched_window = pkg.id + + if last_launched_window: + run_shell_command(f"tmux select-window -t lucy_ws:{last_launched_window}") + def main(stdscr): curses.curs_set(0) stdscr.nodelay(0) @@ -154,10 +186,12 @@ def main(stdscr): state = LauncherState(load_config()) current_idx = 0 error_msg = None + status_msg = None while True: - display_list = draw_tui(stdscr, state, current_idx, error_msg) + display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg) error_msg = None + status_msg = None key = stdscr.getch() @@ -169,11 +203,19 @@ def main(stdscr): pkg_to_toggle = display_list[current_idx] error_msg = state.toggle(pkg_to_toggle.id) elif key == ord('\n'): - return "Launch", state + apply_changes(state) + status_msg = "Configuration Applied!" + state = LauncherState(load_config()) elif key in [ord('x'), ord('X')]: - return "ExitWorkspace", state + h, w = stdscr.getmaxyx() + stdscr.addstr(h - 2, 2, "Stop all processes and exit Docker? (y/n)", curses.A_BOLD | curses.color_pair(2)) + stdscr.refresh() + confirm_key = stdscr.getch() + if confirm_key in [ord('y'), ord('Y')]: + return "ExitWorkspace", state elif key in [ord('q'), ord('Q'), 27]: - return "Quit", None + # Q is now a no-op, but we can keep it for future use if needed + pass if __name__ == "__main__": if not is_in_docker() or not is_in_tmux(): @@ -186,72 +228,16 @@ def main(stdscr): print(f"A terminal error occurred: {e}", file=sys.stderr) sys.exit(1) - if status == "Quit": - print("No action taken.") - sys.exit(0) - if status == "ExitWorkspace": print("\nStopping all processes and exiting workspace...") - - # 1. Gracefully stop complex background processes (like the Control Panel) for pkg in state.packages: if pkg.is_complex_command(): run_shell_command(pkg.command['stop']) - - # 2. Clear state file to avoid stale data on next boot if os.path.exists(STATE_FILE): os.remove(STATE_FILE) - - # 3. Kill the entire tmux session. - # This will shut down tmux, end the launch_lucy.sh script, and exit the container! print("Terminating tmux session...") time.sleep(0.5) run_shell_command("tmux kill-session -t lucy_ws 2>/dev/null") - sys.exit(0) - - if status == "Launch": - last_launched_window = None - - # 1. Handle Complex/Background Standalones - for pkg in state.packages: - if pkg.is_complex_command(): - was_running = run_shell_command(pkg.command['is_running'], capture_output=True) - if pkg.selected and not was_running: - print(f"Executing start command for {pkg.name}...") - run_shell_command(pkg.command['start']) - elif not pkg.selected and was_running: - print(f"Executing stop command for {pkg.name}...") - run_shell_command(pkg.command['stop']) - - # 2. Handle Core/Modifier Processes - core_pkg = next((p for p in state.packages if p.type == 'core'), None) - if core_pkg.selected: - run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") - base_cmd = core_pkg.command - - selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] - modifier_args = [p.command for p in selected_modifiers] - modifier_ids = [p.id for p in selected_modifiers] - - full_cmd = f"{base_cmd} {' '.join(modifier_args)}" - print(f"Launching Core in 'core' window: {full_cmd}") - run_shell_command(f"tmux new-window -d -t lucy_ws -n core '{full_cmd}; echo \"--- Process finished, press any key to close ---\"; read'") - save_state({"modifiers": modifier_ids}) - else: - run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") - save_state({"modifiers": []}) - - # 3. Handle Simple Standalone Processes - for pkg in state.packages: - if not pkg.is_complex_command() and pkg.type == 'standalone': - run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") - if pkg.selected: - print(f"Launching {pkg.name} in '{pkg.id}' window...") - run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") - last_launched_window = pkg.id - - if last_launched_window: - run_shell_command(f"tmux select-window -t lucy_ws:{last_launched_window}") - - print("\nConfiguration applied. Check tmux windows for status.") - print("Use `Ctrl+B, w` to see all windows.") + else: + # If we quit the loop for any other reason, just exit the script + pass From 3e632e377d6f563c105d167c7da5debf80069551 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Wed, 27 May 2026 16:01:07 +0900 Subject: [PATCH 05/15] fix(launcher): remove exit to force launcher flow Signed-off-by: Mael-RABOT --- Dockerfile.humble | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile.humble b/Dockerfile.humble index df1b6be..9aa7d23 100644 --- a/Dockerfile.humble +++ b/Dockerfile.humble @@ -79,6 +79,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ # Setup bashrc with alias for the TUI launcher RUN echo "alias launcher='python3 /workspace/launcher.py'" >> ~/.bashrc +RUN echo "alias exit=''" >> ~/.bashrc # Disable 'exit' to force launcher usage WORKDIR /workspace ENTRYPOINT ["/bin/bash"] From 0c260b289114e26ba746960912c0e55cc587701c Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Wed, 27 May 2026 16:08:47 +0900 Subject: [PATCH 06/15] evol(docs): dev doc, readme & rqt tool Signed-off-by: Mael-RABOT --- README.md | 18 ++++++++++++++- config/launcher_config.json | 10 +++++++++ docs/launcher_packages.md | 45 +++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 docs/launcher_packages.md diff --git a/README.md b/README.md index 32860ee..0b24326 100644 --- a/README.md +++ b/README.md @@ -18,10 +18,26 @@ chmod +x install.sh launch_lucy.sh ./launch_lucy.sh ``` -Starts the **control panel** in the background and runs `lucy_bringup` with Gazebo and RViz inside the container (GUI / X11 forwarded automatically when available). +Starts the workspace, running everything inside a single **tmux** session in the Docker container. + +You will immediately see the **Lucy Control Center** TUI: +- Use **Up/Down Arrows** to navigate. +- Press **Space** to toggle a package or tool on/off. +- Press **Enter** to apply your changes. (New tools open in their own background windows). +- Press **X** to stop all processes and exit the Docker container entirely. + +### Managing Tmux Windows + +Because all tools (like the console or the control panel) run in background windows, you need to know a few basic `tmux` commands to navigate between them: + +- **`Ctrl+B` then `W`**: Opens a menu of all running windows. Use the arrows to select one and press Enter to switch to it. +- **`Ctrl+B` then `N`**: Go to the next window. +- **`Ctrl+B` then `P`**: Go to the previous window. +- **`Ctrl+B` then `D`**: Detach from the session (keeps the container running in the background). Open the control panel at **http://localhost:5000/**. ## More - [`docs/developer_lucy_packages.md`](docs/developer_lucy_packages.md) — developer guide: per-repo docs, all `install.sh` / `launch_lucy.sh` flags, dev mode, ports, environment overrides, packages overview. +- [`docs/launcher_packages.md`](docs/launcher_packages.md) — launcher guide: how to add new packages to the launcher UI and understand the configuration fields. diff --git a/config/launcher_config.json b/config/launcher_config.json index c10d3bf..7d62c7f 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -73,6 +73,16 @@ "conflicts": [], "command": "echo \"To navigate, press Ctrl+B then W\" && bash -i", "default_on": false + }, + { + "id": "rqt", + "name": "rqt GUI", + "description": "Main ROS 2 GUI tool", + "type": "standalone", + "dependencies": ["core"], + "conflicts": [], + "command": "rqt", + "default_on": false } ] } diff --git a/docs/launcher_packages.md b/docs/launcher_packages.md new file mode 100644 index 0000000..7afd906 --- /dev/null +++ b/docs/launcher_packages.md @@ -0,0 +1,45 @@ +### Adding a New Package to the Launcher + +The launcher's configuration is entirely driven by the `launcher_config.json` file. To add a new package, tool, or modifier, you just need to add a new JSON object to the `packages` list in this file. + +For example, if you wanted to add an `rqt_graph` standalone tool, you would append this to the `packages` array: + +```json +{ + "id": "rqt_graph", + "name": "ROS Qt Graph", + "description": "Visualizes the ROS 2 computation graph", + "type": "standalone", + "dependencies": ["core"], + "conflicts": [], + "command": "ros2 run rqt_graph rqt_graph", + "default_on": false +} +``` + +### Configuration Fields Explained + +Every package entry in `config/launcher_config.json` uses the following fields to dictate how it behaves in the launcher: + +| Field | Type | Description | +| :--- | :--- | :--- | +| `id` | `string` | A unique identifier for the package. Used internally for window names, resolving dependencies, and tracking the state. Must be unique. | +| `name` | `string` | The display name shown in the launcher's terminal UI. | +| `description` | `string` | A short description shown alongside the package name. | +| `type` | `string` | The classification of the package. It defines how the launcher handles it. Must be one of:
- `"core"`: The primary process. Usually only one exists. Selecting it clears and launches a base command.
- `"modifier"`: Arguments appended to the `"core"` command when active (e.g. `gazebo:=true`).
- `"standalone"`: A separate process launched in its own dedicated tmux window (e.g. CLI tools or background services). | +| `dependencies` | `array` of `strings` | A list of `id`s that must be enabled before this package can be toggled on. The launcher will block you and show a warning if dependencies are missing. | +| `conflicts` | `array` of `strings` | A list of `id`s that cannot run alongside this package. Toggling this package on will automatically toggle the conflicting packages off. | +| `command` | `string` or `object` | The shell command to execute.
- **For `"core"`**: The base command (e.g. `ros2 launch ...`).
- **For `"modifier"`**: The argument string appended to the core command.
- **For `"standalone"`**: A simple string executed in a new tmux window, or a complex object containing `"start"`, `"stop"`, and `"is_running"` shell commands for custom background handling (like the web control panel). | +| `default_on` | `boolean` | If set to `true`, the package will be selected by default when the launcher boots up (currently unused as the launcher loads an empty initial state, but available for future functionality). | + +### Under the Hood (`launcher.py` and `launch_lucy.sh`) + +When you run `./launch_lucy.sh`: + +1. It builds and enters the Docker container. +2. It drops you into a **tmux** session named `lucy_ws`. +3. It automatically runs `launcher.py` (the TUI) in the main window. + +When you apply changes in `launcher.py`: +- **Core + Modifiers:** The script takes the core command, appends all active modifier commands, and spins up a dedicated `core` tmux window. +- **Standalones:** The script spins up a new tmux window named after the package's `id` and executes its command. Alternatively, if a complex command object is provided, it executes the explicit `start` and `stop` shell commands in the background. From 6251427325131af7ab6ddff2b4117c6e4f36a30d Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Thu, 28 May 2026 08:10:22 +0900 Subject: [PATCH 07/15] feat(tui): encapsulate install.sh in tui Signed-off-by: Mael-RABOT --- Lucy.py | 130 ++++++++++++++++++++++++++++++++++++ config/launcher_config.json | 8 +-- docs/launcher_packages.md | 10 +-- launcher.py | 22 ++++-- 4 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 Lucy.py diff --git a/Lucy.py b/Lucy.py new file mode 100644 index 0000000..a70d8d6 --- /dev/null +++ b/Lucy.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 + +import curses +import os +import subprocess +import sys + +def get_dev_mode(): + if not os.path.exists(".env"): + return False + with open(".env", "r") as f: + for line in f: + if line.strip().startswith("DEV="): + return line.strip().split("=")[1].lower() == "true" + return False + +def set_dev_mode(is_enabled): + lines = [] + dev_found = False + if os.path.exists(".env"): + with open(".env", "r") as f: + lines = f.readlines() + + with open(".env", "w") as f: + for line in lines: + if line.strip().startswith("DEV="): + f.write(f"DEV={str(is_enabled).lower()}\n") + dev_found = True + else: + f.write(line) + if not dev_found: + f.write(f"DEV={str(is_enabled).lower()}\n") + +def run_command(command, interactive=False): + """Runs a command. + + If interactive is True, runs natively in the terminal. + """ + print(f"--- Running: {' '.join(command)} ---") + try: + if interactive: + # Inherit standard IO to maintain terminal size and TTY functionality + return subprocess.run(command).returncode + else: + # Popen is fine for non-interactive scripts like install/build + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + while True: + output = process.stdout.readline() + if output == '' and process.poll() is not None: + break + if output: + print(output.strip()) + return process.poll() + + except FileNotFoundError: + print(f"Error: Command '{command[0]}' not found. Make sure it's in your PATH and executable.") + return -1 + except Exception as e: + print(f"An error occurred: {e}") + return -1 + +def main_tui(stdscr): + """The main curses TUI function. Returns the command to run.""" + curses.curs_set(0) + stdscr.nodelay(0) + stdscr.timeout(-1) + curses.start_color() + curses.use_default_colors() + curses.init_pair(1, curses.COLOR_CYAN, -1) + + is_dev_mode = get_dev_mode() + current_idx = 0 + options = ["Developer Mode", "Install", "Rebuild", "Launch", "Exit"] + + while True: + stdscr.clear() + h, w = stdscr.getmaxyx() + title = "Lucy Workspace Manager" + stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) + + for i, option in enumerate(options): + prefix = "> " if current_idx == i else " " + + if option == "Developer Mode": + checkbox = "[x]" if is_dev_mode else "[ ]" + stdscr.addstr(2 + i, 4, f"{prefix}{checkbox} {option}") + else: + stdscr.addstr(2 + i, 4, f"{prefix}{option}") + + stdscr.addstr(h - 2, 2, "Enter/Space: Select/Toggle | Up/Down: Navigate", curses.A_DIM) + stdscr.refresh() + + key = stdscr.getch() + + if key == curses.KEY_UP: + current_idx = (current_idx - 1) % len(options) + elif key == curses.KEY_DOWN: + current_idx = (current_idx + 1) % len(options) + elif key in [ord(' '), ord('\n')]: + selected_option = options[current_idx] + + if selected_option == "Developer Mode": + is_dev_mode = not is_dev_mode + set_dev_mode(is_dev_mode) + elif selected_option == "Install": + return {"cmd": ["./install.sh"], "interactive": False} + elif selected_option == "Rebuild": + return {"cmd": ["./install.sh", "--build-only"], "interactive": False} + elif selected_option == "Launch": + return {"cmd": ["./launch_lucy.sh"], "interactive": True} + elif selected_option == "Exit": + return None + +if __name__ == "__main__": + task = None + try: + # curses.wrapper handles all the init/deinit of the terminal + task = curses.wrapper(main_tui) + except KeyboardInterrupt: + print("\nExiting.") + sys.exit(0) + + if task: + rc = run_command(task["cmd"], interactive=task.get("interactive", False)) + if not task.get("interactive", False): + print(f"--- Command finished with exit code {rc} ---") + print("Press Enter to exit.") + input() + + sys.exit(0) diff --git a/config/launcher_config.json b/config/launcher_config.json index 7d62c7f..99f1762 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -44,7 +44,7 @@ "id": "control_panel", "name": "Control Panel", "description": "Web-based UI (background)", - "type": "standalone", + "type": "interface", "dependencies": [], "conflicts": [], "command": { @@ -58,7 +58,7 @@ "id": "lucy_cli", "name": "Lucy CLI", "description": "Command Line Interface", - "type": "standalone", + "type": "interface", "dependencies": ["core"], "conflicts": [], "command": "ros2 run lucy_cli tui", @@ -68,7 +68,7 @@ "id": "console", "name": "Open Console", "description": "Opens a new interactive terminal window", - "type": "standalone", + "type": "tool", "dependencies": [], "conflicts": [], "command": "echo \"To navigate, press Ctrl+B then W\" && bash -i", @@ -78,7 +78,7 @@ "id": "rqt", "name": "rqt GUI", "description": "Main ROS 2 GUI tool", - "type": "standalone", + "type": "tool", "dependencies": ["core"], "conflicts": [], "command": "rqt", diff --git a/docs/launcher_packages.md b/docs/launcher_packages.md index 7afd906..8c3e561 100644 --- a/docs/launcher_packages.md +++ b/docs/launcher_packages.md @@ -2,14 +2,14 @@ The launcher's configuration is entirely driven by the `launcher_config.json` file. To add a new package, tool, or modifier, you just need to add a new JSON object to the `packages` list in this file. -For example, if you wanted to add an `rqt_graph` standalone tool, you would append this to the `packages` array: +For example, if you wanted to add an `rqt_graph` tool, you would append this to the `packages` array: ```json { "id": "rqt_graph", "name": "ROS Qt Graph", "description": "Visualizes the ROS 2 computation graph", - "type": "standalone", + "type": "tool", "dependencies": ["core"], "conflicts": [], "command": "ros2 run rqt_graph rqt_graph", @@ -26,10 +26,10 @@ Every package entry in `config/launcher_config.json` uses the following fields t | `id` | `string` | A unique identifier for the package. Used internally for window names, resolving dependencies, and tracking the state. Must be unique. | | `name` | `string` | The display name shown in the launcher's terminal UI. | | `description` | `string` | A short description shown alongside the package name. | -| `type` | `string` | The classification of the package. It defines how the launcher handles it. Must be one of:
- `"core"`: The primary process. Usually only one exists. Selecting it clears and launches a base command.
- `"modifier"`: Arguments appended to the `"core"` command when active (e.g. `gazebo:=true`).
- `"standalone"`: A separate process launched in its own dedicated tmux window (e.g. CLI tools or background services). | +| `type` | `string` | The classification of the package. It defines how the launcher handles it. Must be one of:
- `"core"`: The primary process. Usually only one exists. Selecting it clears and launches a base command.
- `"modifier"`: Arguments appended to the `"core"` command when active (e.g. `gazebo:=true`).
- `"interface"`: A user interface process launched in its own dedicated tmux window (e.g. CLI tools or background web services).
- `"tool"`: A utility process launched in its own dedicated tmux window (e.g. ROS standard tools or terminal sessions). | | `dependencies` | `array` of `strings` | A list of `id`s that must be enabled before this package can be toggled on. The launcher will block you and show a warning if dependencies are missing. | | `conflicts` | `array` of `strings` | A list of `id`s that cannot run alongside this package. Toggling this package on will automatically toggle the conflicting packages off. | -| `command` | `string` or `object` | The shell command to execute.
- **For `"core"`**: The base command (e.g. `ros2 launch ...`).
- **For `"modifier"`**: The argument string appended to the core command.
- **For `"standalone"`**: A simple string executed in a new tmux window, or a complex object containing `"start"`, `"stop"`, and `"is_running"` shell commands for custom background handling (like the web control panel). | +| `command` | `string` or `object` | The shell command to execute.
- **For `"core"`**: The base command (e.g. `ros2 launch ...`).
- **For `"modifier"`**: The argument string appended to the core command.
- **For `"interface"` / `"tool"`**: A simple string executed in a new tmux window, or a complex object containing `"start"`, `"stop"`, and `"is_running"` shell commands for custom background handling (like the web control panel). | | `default_on` | `boolean` | If set to `true`, the package will be selected by default when the launcher boots up (currently unused as the launcher loads an empty initial state, but available for future functionality). | ### Under the Hood (`launcher.py` and `launch_lucy.sh`) @@ -42,4 +42,4 @@ When you run `./launch_lucy.sh`: When you apply changes in `launcher.py`: - **Core + Modifiers:** The script takes the core command, appends all active modifier commands, and spins up a dedicated `core` tmux window. -- **Standalones:** The script spins up a new tmux window named after the package's `id` and executes its command. Alternatively, if a complex command object is provided, it executes the explicit `start` and `stop` shell commands in the background. +- **Interfaces / Tools:** The script spins up a new tmux window named after the package's `id` and executes its command. Alternatively, if a complex command object is provided, it executes the explicit `start` and `stop` shell commands in the background. diff --git a/launcher.py b/launcher.py index 7d9ec9d..d7a3307 100644 --- a/launcher.py +++ b/launcher.py @@ -63,7 +63,7 @@ def update_running_status(self, running_modifiers): self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) if not self.selected: save_state({"modifiers": []}) - elif self.type == 'standalone': + elif self.type in ['tool', 'interface']: self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) def is_complex_command(self): @@ -112,8 +112,9 @@ def draw_tui(stdscr, state, current_idx, error_msg, status_msg): stdscr.addstr(h - 2, 2, f"Warning: {error_msg}", curses.color_pair(2)) cores_and_mods = [p for p in state.packages if p.type in ['core', 'modifier']] - standalones = [p for p in state.packages if p.type == 'standalone'] - display_list = cores_and_mods + standalones + interfaces = [p for p in state.packages if p.type == 'interface'] + tools = [p for p in state.packages if p.type == 'tool'] + display_list = cores_and_mods + interfaces + tools row = 2 stdscr.addstr(row, 2, "Primary Launch Targets", curses.A_BOLD | curses.color_pair(1)) @@ -128,14 +129,23 @@ def draw_tui(stdscr, state, current_idx, error_msg, status_msg): stdscr.addstr(row + i, 4, f"{prefix}{indent}{checkbox} {p.name}", attr) row += len(cores_and_mods) + 1 - stdscr.addstr(row, 2, "Standalone Tools", curses.A_BOLD | curses.color_pair(3)) + stdscr.addstr(row, 2, "Interfaces", curses.A_BOLD | curses.color_pair(3)) row += 1 - for i, p in enumerate(standalones): + for i, p in enumerate(interfaces): list_idx = i + len(cores_and_mods) prefix = "> " if current_idx == list_idx else " " checkbox = "[x]" if p.selected else "[ ]" stdscr.addstr(row + i, 4, f"{prefix}{checkbox} {p.name}", curses.A_NORMAL) + row += len(interfaces) + 1 + stdscr.addstr(row, 2, "Tools", curses.A_BOLD | curses.color_pair(3)) + row += 1 + for i, p in enumerate(tools): + list_idx = i + len(cores_and_mods) + len(interfaces) + prefix = "> " if current_idx == list_idx else " " + checkbox = "[x]" if p.selected else "[ ]" + stdscr.addstr(row + i, 4, f"{prefix}{checkbox} {p.name}", curses.A_NORMAL) + stdscr.refresh() return display_list @@ -162,7 +172,7 @@ def apply_changes(state): else: run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") save_state({"modifiers": []}) - elif pkg.type == 'standalone': + elif pkg.type in ['tool', 'interface']: run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") if pkg.selected: run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") From b8b1b84dbe0062210e9178cb23d0efa6d5080068 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Thu, 28 May 2026 08:34:21 +0900 Subject: [PATCH 08/15] evol(Lucy.py): add windows support win supported though git bash & WSL as fallback Signed-off-by: Mael-RABOT --- README.md | 24 ++-- config/launcher_config.json | 172 ++++++++++++++-------------- windows/Lucy.py | 217 ++++++++++++++++++++++++++++++++++++ windows/README.md | 30 +++++ 4 files changed, 349 insertions(+), 94 deletions(-) create mode 100644 windows/Lucy.py create mode 100644 windows/README.md diff --git a/README.md b/README.md index 0b24326..b25967a 100644 --- a/README.md +++ b/README.md @@ -4,21 +4,29 @@ Workspace bringup for the Lucy / InMoov humanoid. Everything (ROS 2 Humble, Gaze ## Install +> For UNIX based system: ```bash chmod +x install.sh launch_lucy.sh -./install.sh # Linux, Intel Mac, Windows WSL, x86_64 VMs -./install.sh --arm # Apple Silicon (M1 / M2 / M3) under Docker Desktop ``` -`install.sh` clones the sub-repositories listed in `config/repos.json` into `src/`, builds the Docker image, and compiles the workspace inside the container. +The installation is handled by the `Lucy.py` script. Only use the `./install.sh` script as a fallback. -## Launch +## Launch & Manage -```bash -./launch_lucy.sh -``` +We provide a Python-based Text User Interface (TUI) to easily manage the workspace. It handles installing, rebuilding, and launching the environment. + +From the repository root, run the manager for your platform: + +| OS | Command | +| :--- |:-------------------------------------------------------------------| +| **Linux / macOS** | `python3 Lucy.py` | +| **Windows** | `python windows/Lucy.py` (See [Windows README](windows/README.md)) | + +The manager includes a **Developer Mode** toggle. When ON, it configures the installer to pull repositories using SSH instead of HTTP. + +### Using the Workspace -Starts the workspace, running everything inside a single **tmux** session in the Docker container. +Selecting **Launch** from the manager starts the workspace, running everything inside a single **tmux** session within the Docker container. You will immediately see the **Lucy Control Center** TUI: - Use **Up/Down Arrows** to navigate. diff --git a/config/launcher_config.json b/config/launcher_config.json index 99f1762..e070d4a 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -1,88 +1,88 @@ { - "packages": [ - { - "id": "core", - "name": "Core (Lucy Bringup)", - "description": "Base robot software stack", - "type": "core", - "dependencies": [], - "conflicts": [], - "command": "ros2 launch lucy_bringup lucy.launch.py", - "default_on": false - }, - { - "id": "gazebo", - "name": "... with Simulator", - "description": "(Gazebo)", - "type": "modifier", - "dependencies": ["core"], - "conflicts": ["real"], - "command": "gazebo:=true", - "default_on": false - }, - { - "id": "rviz", - "name": "... with Visualizer", - "description": "(RViz)", - "type": "modifier", - "dependencies": ["core"], - "conflicts": [], - "command": "rviz:=true", - "default_on": false - }, - { - "id": "real", - "name": "... with Real Hardware", - "description": "(Connect to physical robot)", - "type": "modifier", - "dependencies": ["core"], - "conflicts": ["gazebo"], - "command": "real:=true", - "default_on": false - }, - { - "id": "control_panel", - "name": "Control Panel", - "description": "Web-based UI (background)", - "type": "interface", - "dependencies": [], - "conflicts": [], - "command": { - "start": "tmux new-window -d -n control_panel 'cd /workspace/src/lucy_control_panel && yarn dev'", - "stop": "tmux kill-window -t lucy_ws:control_panel 2>/dev/null || true", - "is_running": "tmux list-windows -F '#{window_name}' | grep -q '^control_panel$'" - }, - "default_on": false - }, - { - "id": "lucy_cli", - "name": "Lucy CLI", - "description": "Command Line Interface", - "type": "interface", - "dependencies": ["core"], - "conflicts": [], - "command": "ros2 run lucy_cli tui", - "default_on": false - }, - { - "id": "console", - "name": "Open Console", - "description": "Opens a new interactive terminal window", - "type": "tool", - "dependencies": [], - "conflicts": [], - "command": "echo \"To navigate, press Ctrl+B then W\" && bash -i", - "default_on": false - }, - { - "id": "rqt", - "name": "rqt GUI", - "description": "Main ROS 2 GUI tool", - "type": "tool", - "dependencies": ["core"], - "conflicts": [], - "command": "rqt", - "default_on": false - } - ] + "packages": [ + { + "id": "core", + "name": "Core (Lucy Bringup)", + "description": "Base robot software stack", + "type": "core", + "dependencies": [], + "conflicts": [], + "command": "ros2 launch lucy_bringup lucy.launch.py", + "default_on": false + }, + { + "id": "gazebo", + "name": "... with Simulator", + "description": "(Gazebo)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": ["real"], + "command": "gazebo:=true", + "default_on": false + }, + { + "id": "rviz", + "name": "... with Visualizer", + "description": "(RViz)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": [], + "command": "rviz:=true", + "default_on": false + }, + { + "id": "real", + "name": "... with Real Hardware", + "description": "(Connect to physical robot)", + "type": "modifier", + "dependencies": ["core"], + "conflicts": ["gazebo"], + "command": "real:=true", + "default_on": false + }, + { + "id": "control_panel", + "name": "Control Panel", + "description": "Web-based UI (http://localhost:5000/)", + "type": "interface", + "dependencies": [], + "conflicts": [], + "command": { + "start": "tmux new-window -d -n control_panel 'cd /workspace/src/lucy_control_panel && yarn dev'", + "stop": "tmux kill-window -t lucy_ws:control_panel 2>/dev/null || true", + "is_running": "tmux list-windows -F '#{window_name}' | grep -q '^control_panel$'" + }, + "default_on": false + }, + { + "id": "lucy_cli", + "name": "Lucy CLI", + "description": "Command Line Interface", + "type": "interface", + "dependencies": ["core"], + "conflicts": [], + "command": "ros2 run lucy_cli tui", + "default_on": false + }, + { + "id": "console", + "name": "Open Console", + "description": "Opens a new interactive terminal window", + "type": "tool", + "dependencies": [], + "conflicts": [], + "command": "echo \"To navigate, press Ctrl+B then W\" && bash -i", + "default_on": false + }, + { + "id": "rqt", + "name": "rqt GUI", + "description": "Main ROS 2 GUI tool", + "type": "tool", + "dependencies": ["core"], + "conflicts": [], + "command": "rqt", + "default_on": false + } + ] } diff --git a/windows/Lucy.py b/windows/Lucy.py new file mode 100644 index 0000000..cbc71fd --- /dev/null +++ b/windows/Lucy.py @@ -0,0 +1,217 @@ +# This script provides a native Windows TUI for managing the Lucy workspace. +# It replicates the logic of the .sh scripts by calling git and docker directly. +# +# PREREQUISITES: +# 1. Python 3 +# 2. Git for Windows (must be in the system's PATH) +# 3. Docker Desktop for Windows (must be running) +# 4. The 'questionary' library: pip install questionary +# + +import os +import subprocess +import sys +import json +import questionary + +# --- Platform Check --- +if sys.platform != "win32": + print("Error: This script is designed for Windows only.", file=sys.stderr) + print("On Linux or macOS, please use the main './tui.py' script.", file=sys.stderr) + sys.exit(1) + +# --- Configuration --- +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ENV_FILE = os.path.join(PROJECT_ROOT, ".env") +REPOS_FILE = os.path.join(PROJECT_ROOT, "config", "repos.json") +DOCKERFILE = os.path.join(PROJECT_ROOT, "Dockerfile.humble") +IMAGE_NAME = "lucy_ros2:humble" +WORKSPACE_DIR_HOST = PROJECT_ROOT +WORKSPACE_DIR_CONTAINER = "/workspace" + +# --- Helper Functions --- + +def run_command(command, check=True, interactive=False): + """Runs a command, streaming its output if not interactive.""" + print(f"--- Running: {' '.join(command)} ---") + try: + if interactive: + # For interactive commands, run directly and attach to the terminal. + return subprocess.run(command, check=check).returncode + else: + # For non-interactive commands, capture and stream output. + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in iter(process.stdout.readline, ''): + print(line.strip()) + process.wait() + if check and process.returncode != 0: + raise subprocess.CalledProcessError(process.returncode, command) + return process.returncode + except FileNotFoundError: + print(f"Error: Command '{command[0]}' not found. Is it in your PATH?") + return -1 + except subprocess.CalledProcessError as e: + print(f"Command failed with exit code {e.returncode}") + return e.returncode + +def get_dev_mode(): + if not os.path.exists(ENV_FILE): + return False + with open(ENV_FILE, "r") as f: + for line in f: + if line.strip().startswith("DEV="): + return line.strip().split("=")[1].lower() == "true" + return False + +def set_dev_mode(is_enabled): + lines = [] + dev_found = False + if os.path.exists(ENV_FILE): + with open(ENV_FILE, "r") as f: + lines = f.readlines() + with open(ENV_FILE, "w") as f: + for line in lines: + if line.strip().startswith("DEV="): + f.write(f"DEV={str(is_enabled).lower()}\n") + dev_found = True + else: + f.write(line) + if not dev_found: + f.write(f"DEV={str(is_enabled).lower()}\n") + +# --- Core Logic Functions --- + +def clone_or_update_repos(): + """Clones or updates repositories based on repos.json.""" + is_dev = get_dev_mode() + print(f"Developer mode is {'ON' if is_dev else 'OFF'}.") + + with open(REPOS_FILE, 'r') as f: + repos = json.load(f)['repos'] + + src_dir = os.path.join(PROJECT_ROOT, 'src') + os.makedirs(src_dir, exist_ok=True) + + for repo in repos: + repo_name = repo['name'] + repo_path = os.path.join(src_dir, repo_name) + url_key = 'url_ssh' if is_dev else 'url_https' + repo_url = repo[url_key] + branch = repo['branch'] + + if os.path.exists(os.path.join(repo_path, '.git')): + print(f"Updating repo: {repo_name}") + run_command(['git', '-C', repo_path, 'fetch']) + run_command(['git', '-C', repo_path, 'checkout', branch]) + run_command(['git', '-C', repo_path, 'pull']) + else: + print(f"Cloning repo: {repo_name}") + run_command(['git', 'clone', '-b', branch, repo_url, repo_path]) + +def build_docker_image(): + """Builds the main Docker image.""" + print("Building Docker image...") + run_command(['docker', 'build', '-t', IMAGE_NAME, '-f', DOCKERFILE, '.'], check=True) + +def build_workspace(): + """Runs the colcon build process inside the container.""" + print("Building workspace inside the container...") + # Using single quotes for the python string avoids escaping issues with the inner double quotes. + inner_cmd = ( + 'source /opt/ros/humble/setup.bash && ' + 'cd /workspace && ' + 'rosdep install --from-paths src --ignore-src -r -y --skip-keys="audio_common" && ' + 'colcon build --symlink-install && ' + 'if [ -f src/lucy_control_panel/package.json ]; then ' + '(cd src/lucy_control_panel && yarn install); ' + 'fi' + ) + docker_cmd = [ + 'docker', 'run', '--rm', + '-v', f'{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}', + IMAGE_NAME, + 'bash', '-c', inner_cmd + ] + run_command(docker_cmd) + +def launch_workspace(): + """Launches the main tmux session in the container.""" + print("Launching workspace...") + + container_script = ( + 'source /opt/ros/humble/setup.bash && ' + 'cd /workspace && source install/setup.bash && ' + 'tmux start-server && ' + "if ! tmux has-session -t lucy_ws 2>/dev/null; then " + "tmux new-session -d -s lucy_ws -n 'Lucy Workspace' 'launcher'; " + 'fi && ' + 'tmux attach-session -t lucy_ws' + ) + + docker_cmd = [ + 'docker', 'run', '-it', '--rm', + '--name', 'lucy_dev_win', + '-p', '9090:9090', + '-p', '5000:5000', + '-v', f'{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}', + IMAGE_NAME, + 'bash', '-c', container_script + ] + + run_command(docker_cmd, interactive=True) + + +# --- Main TUI --- + +def main(): + is_dev_mode = get_dev_mode() + + while True: + dev_status = "ON" if is_dev_mode else "OFF" + + choice = questionary.select( + "Lucy Workspace Manager (Native Windows)", + choices=[ + f"Toggle Developer Mode (Currently: {dev_status})", + "Install (Full)", + "Rebuild (Workspace only)", + "Launch", + "Exit" + ], + use_indicator=True + ).ask() + + if choice is None or choice == "Exit": + break + + if choice.startswith("Toggle"): + is_dev_mode = not is_dev_mode + set_dev_mode(is_dev_mode) + print(f"Developer mode set to: {'ON' if is_dev_mode else 'OFF'}") + + elif choice == "Install (Full)": + clone_or_update_repos() + build_docker_image() + build_workspace() + print("--- Full install complete! ---") + + elif choice == "Rebuild (Workspace only)": + build_workspace() + print("--- Workspace rebuild complete! ---") + + elif choice == "Launch": + launch_workspace() + + if choice != "Launch": + print("\nPress Enter to return to the menu.") + input() + +if __name__ == "__main__": + os.chdir(PROJECT_ROOT) + try: + main() + except KeyboardInterrupt: + print("\nExiting.") + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) diff --git a/windows/README.md b/windows/README.md new file mode 100644 index 0000000..b284ef1 --- /dev/null +++ b/windows/README.md @@ -0,0 +1,30 @@ +# Windows Native TUI + +This directory contains a Windows-native version of the main TUI (`Lucy.py`). It provides the same functionality as the Linux/macOS script but is designed to be run directly on Windows. + +## How it Works + +The script is designed to be run from a **Git Bash** terminal. It executes the project's `.sh` scripts (`install.sh`, `launch_lucy.sh`) directly using the bash interpreter that comes with Git for Windows. + +If the script is run outside of Git Bash, it will attempt to fall back to using **WSL (Windows Subsystem for Linux)**. + +## Prerequisites + +1. **Git for Windows**: You must have Git for Windows installed, which includes Git Bash. +2. **Docker Desktop**: Ensure Docker Desktop for Windows is installed and running. +3. **Python 3**: Python must be installed on your Windows system. +4. **questionary library**: This script depends on the `questionary` library to create the interactive command-line interface. + + Install it using pip: + ```bash + pip install questionary + ``` + +## Usage + +1. **Open Git Bash**: Open a Git Bash terminal. +2. **Navigate to the project root**: `cd /path/to/lucy_ws` +3. **Run the script**: + ```bash + python windows/Lucy.py + ``` From 96cda4e6e5682890ff26cd6f815cbf36efcd88eb Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Thu, 28 May 2026 08:43:51 +0900 Subject: [PATCH 09/15] evol(Lucy.py): update windows support & build in CI Signed-off-by: Mael-RABOT --- .github/workflows/install-and-launch.yml | 25 ++++++++ windows/Lucy.py | 80 +++++++++++++----------- windows/README.md | 29 ++++----- 3 files changed, 80 insertions(+), 54 deletions(-) diff --git a/.github/workflows/install-and-launch.yml b/.github/workflows/install-and-launch.yml index 28f8d8f..8c3ade5 100644 --- a/.github/workflows/install-and-launch.yml +++ b/.github/workflows/install-and-launch.yml @@ -70,3 +70,28 @@ jobs: # Single command in the container -> bypasses the control-panel / Gazebo / RViz auto-launch. - name: Launch smoke test (headless, no TTY) run: ./launch_lucy.sh --headless ros2 doctor --report + + build-windows-exe: + name: Build Windows Executable + runs-on: windows-latest + needs: install-and-launch # Optional: run only if the main tests pass + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: Install PyInstaller + run: pip install pyinstaller + + - name: Build the executable + run: pyinstaller --onefile --name Lucy windows/Lucy.py + + - name: Upload the executable + uses: actions/upload-artifact@v4 + with: + name: Lucy-windows-exe + path: dist/Lucy.exe diff --git a/windows/Lucy.py b/windows/Lucy.py index cbc71fd..dda7d34 100644 --- a/windows/Lucy.py +++ b/windows/Lucy.py @@ -1,27 +1,33 @@ # This script provides a native Windows TUI for managing the Lucy workspace. # It replicates the logic of the .sh scripts by calling git and docker directly. +# This script is designed to be compiled into a standalone .exe file. # -# PREREQUISITES: +# PREREQUISITES for running from source: # 1. Python 3 # 2. Git for Windows (must be in the system's PATH) # 3. Docker Desktop for Windows (must be running) -# 4. The 'questionary' library: pip install questionary # import os import subprocess import sys import json -import questionary # --- Platform Check --- if sys.platform != "win32": print("Error: This script is designed for Windows only.", file=sys.stderr) - print("On Linux or macOS, please use the main './tui.py' script.", file=sys.stderr) sys.exit(1) # --- Configuration --- -PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# When running as a PyInstaller executable, the script is extracted to a temp folder. +# We need to determine the project root relative to the executable's location. +if getattr(sys, 'frozen', False): + # Running as a compiled executable + PROJECT_ROOT = os.path.dirname(sys.executable) +else: + # Running as a .py script + PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ENV_FILE = os.path.join(PROJECT_ROOT, ".env") REPOS_FILE = os.path.join(PROJECT_ROOT, "config", "repos.json") DOCKERFILE = os.path.join(PROJECT_ROOT, "Dockerfile.humble") @@ -116,7 +122,6 @@ def build_docker_image(): def build_workspace(): """Runs the colcon build process inside the container.""" print("Building workspace inside the container...") - # Using single quotes for the python string avoids escaping issues with the inner double quotes. inner_cmd = ( 'source /opt/ros/humble/setup.bash && ' 'cd /workspace && ' @@ -128,7 +133,7 @@ def build_workspace(): ) docker_cmd = [ 'docker', 'run', '--rm', - '-v', f'{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}', + '-v', f'"{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}"', IMAGE_NAME, 'bash', '-c', inner_cmd ] @@ -153,7 +158,7 @@ def launch_workspace(): '--name', 'lucy_dev_win', '-p', '9090:9090', '-p', '5000:5000', - '-v', f'{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}', + '-v', f'"{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}"', IMAGE_NAME, 'bash', '-c', container_script ] @@ -164,49 +169,50 @@ def launch_workspace(): # --- Main TUI --- def main(): - is_dev_mode = get_dev_mode() - while True: + is_dev_mode = get_dev_mode() dev_status = "ON" if is_dev_mode else "OFF" - choice = questionary.select( - "Lucy Workspace Manager (Native Windows)", - choices=[ - f"Toggle Developer Mode (Currently: {dev_status})", - "Install (Full)", - "Rebuild (Workspace only)", - "Launch", - "Exit" - ], - use_indicator=True - ).ask() - - if choice is None or choice == "Exit": - break + print("\n--- Lucy Workspace Manager (Native Windows) ---") + print(f"1. Toggle Developer Mode (Currently: {dev_status})") + print("2. Install (Full)") + print("3. Rebuild (Workspace only)") + print("4. Launch") + print("5. Exit") - if choice.startswith("Toggle"): - is_dev_mode = not is_dev_mode - set_dev_mode(is_dev_mode) - print(f"Developer mode set to: {'ON' if is_dev_mode else 'OFF'}") + try: + choice = input("\nEnter your choice (1-5): ").strip() + except KeyboardInterrupt: + break - elif choice == "Install (Full)": + if choice == '1': + set_dev_mode(not is_dev_mode) + print(f"Developer mode set to: {'ON' if not is_dev_mode else 'OFF'}") + + elif choice == '2': clone_or_update_repos() build_docker_image() build_workspace() print("--- Full install complete! ---") - - elif choice == "Rebuild (Workspace only)": + + elif choice == '3': build_workspace() print("--- Workspace rebuild complete! ---") - - elif choice == "Launch": + + elif choice == '4': launch_workspace() - - if choice != "Launch": - print("\nPress Enter to return to the menu.") - input() + + elif choice == '5': + break + + else: + print("Invalid choice, please try again.") + + if choice != '4' and choice != '5': + input("\nPress Enter to continue...") if __name__ == "__main__": + # This needs to be at the top level for PyInstaller to see it. os.chdir(PROJECT_ROOT) try: main() diff --git a/windows/README.md b/windows/README.md index b284ef1..0fa633e 100644 --- a/windows/README.md +++ b/windows/README.md @@ -4,27 +4,22 @@ This directory contains a Windows-native version of the main TUI (`Lucy.py`). It ## How it Works -The script is designed to be run from a **Git Bash** terminal. It executes the project's `.sh` scripts (`install.sh`, `launch_lucy.sh`) directly using the bash interpreter that comes with Git for Windows. +The script is a standalone Python application that calls `git.exe` and `docker.exe` directly. It does not have any external dependencies and can be run in a standard Windows Command Prompt or PowerShell. -If the script is run outside of Git Bash, it will attempt to fall back to using **WSL (Windows Subsystem for Linux)**. +It can also be compiled into a single `.exe` file using a tool like PyInstaller. ## Prerequisites -1. **Git for Windows**: You must have Git for Windows installed, which includes Git Bash. -2. **Docker Desktop**: Ensure Docker Desktop for Windows is installed and running. -3. **Python 3**: Python must be installed on your Windows system. -4. **questionary library**: This script depends on the `questionary` library to create the interactive command-line interface. - - Install it using pip: - ```bash - pip install questionary - ``` +1. **Python 3**: Must be installed and in your system's PATH. +2. **Git for Windows**: Must be installed and in your system's PATH. +3. **Docker Desktop**: Must be installed and running. ## Usage -1. **Open Git Bash**: Open a Git Bash terminal. -2. **Navigate to the project root**: `cd /path/to/lucy_ws` -3. **Run the script**: - ```bash - python windows/Lucy.py - ``` +From the project root, run the script using Python: + +```bash +python windows/Lucy.py +``` + +You will be presented with a simple numbered menu to manage the workspace. From 057c158eb5165b74c0b8a58b48728010fc4d2200 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Thu, 28 May 2026 09:10:57 +0900 Subject: [PATCH 10/15] devops(CICD): load .exe in releases Signed-off-by: Mael-RABOT --- .github/workflows/install-and-launch.yml | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/install-and-launch.yml b/.github/workflows/install-and-launch.yml index 8c3ade5..ab8acce 100644 --- a/.github/workflows/install-and-launch.yml +++ b/.github/workflows/install-and-launch.yml @@ -8,12 +8,14 @@ # - The launch smoke test uses `./launch_lucy.sh --headless `, which runs a # single command inside the container (no control panel, no auto Gazebo/RViz). -name: Install and launch +name: Install, Launch & Release on: workflow_dispatch: push: branches: [master, dev] + tags: + - 'v*' # Run on version tags like v1.0, v2.3.4 pull_request: branches: [master, dev] @@ -71,10 +73,14 @@ jobs: - name: Launch smoke test (headless, no TTY) run: ./launch_lucy.sh --headless ros2 doctor --report - build-windows-exe: - name: Build Windows Executable + build-and-release-windows-exe: + name: Build and Release Windows Executable + # Only run this job when a new tag is pushed + if: startsWith(github.ref, 'refs/tags/') runs-on: windows-latest - needs: install-and-launch # Optional: run only if the main tests pass + needs: install-and-launch + permissions: + contents: write # Required to create a release and upload assets steps: - name: Check out repository uses: actions/checkout@v4 @@ -90,8 +96,9 @@ jobs: - name: Build the executable run: pyinstaller --onefile --name Lucy windows/Lucy.py - - name: Upload the executable - uses: actions/upload-artifact@v4 + - name: Create Release and Upload Asset + uses: softprops/action-gh-release@v2 with: - name: Lucy-windows-exe - path: dist/Lucy.exe + files: dist/Lucy.exe + # This creates a draft release. Remove `draft: true` to publish it automatically. + draft: true From 34e0e9087974c501768e11596b7c232429dce582 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Thu, 28 May 2026 18:04:27 +0900 Subject: [PATCH 11/15] fix(windows): add x server support Signed-off-by: Mael-RABOT --- .gitignore | 1 + README.md | 2 + windows/Lucy.py | 122 ++++++++++++++++++++++++++++++++++++++++------ windows/README.md | 6 +++ 4 files changed, 117 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index c7ca731..6f48fec 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ __pycache__/ # IDE / OS .idea/ .vscode/ +.vs/ .DS_Store *~ diff --git a/README.md b/README.md index b25967a..6627114 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ From the repository root, run the manager for your platform: | **Linux / macOS** | `python3 Lucy.py` | | **Windows** | `python windows/Lucy.py` (See [Windows README](windows/README.md)) | +> The windows installation require the installation of a 3rd party software, as a Windows X Server is needed. + The manager includes a **Developer Mode** toggle. When ON, it configures the installer to pull repositories using SSH instead of HTTP. ### Using the Workspace diff --git a/windows/Lucy.py b/windows/Lucy.py index dda7d34..4f008bb 100644 --- a/windows/Lucy.py +++ b/windows/Lucy.py @@ -4,7 +4,7 @@ # # PREREQUISITES for running from source: # 1. Python 3 -# 2. Git for Windows (must be in the system's PATH) +# 2. Git for Windows (must be in your PATH) # 3. Docker Desktop for Windows (must be running) # @@ -85,13 +85,25 @@ def set_dev_mode(is_enabled): if not dev_found: f.write(f"DEV={str(is_enabled).lower()}\n") +def _format_volume_mapping(host_path, container_path): + """ + Return a Docker -v mapping string without extra quotes. + Normalize host path to an absolute path and use forward slashes to avoid + passing literal quote characters into the docker CLI. + """ + host_abs = os.path.abspath(host_path) + # Use forward slashes to reduce issues with escaping backslashes; + # Docker Desktop accepts Windows-style paths with forward slashes. + host_normalized = host_abs.replace('\\', '/') + return f"{host_normalized}:{container_path}" + # --- Core Logic Functions --- def clone_or_update_repos(): """Clones or updates repositories based on repos.json.""" is_dev = get_dev_mode() print(f"Developer mode is {'ON' if is_dev else 'OFF'}.") - + with open(REPOS_FILE, 'r') as f: repos = json.load(f)['repos'] @@ -131,36 +143,118 @@ def build_workspace(): '(cd src/lucy_control_panel && yarn install); ' 'fi' ) + volume_mapping = _format_volume_mapping(WORKSPACE_DIR_HOST, WORKSPACE_DIR_CONTAINER) + # Do NOT include an extra 'bash' argument; the image sets ENTRYPOINT to /bin/bash. + # Provide '-c' so the entrypoint receives the command string correctly. docker_cmd = [ 'docker', 'run', '--rm', - '-v', f'"{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}"', + '-v', volume_mapping, IMAGE_NAME, - 'bash', '-c', inner_cmd + '-c', inner_cmd ] run_command(docker_cmd) + +def _docker_gui_args(): + """Return Docker args for optional GUI/X11 forwarding.""" + gui_display = os.environ.get('DOCKER_GUI_DISPLAY', os.environ.get('DISPLAY', '')).strip() + if sys.platform == 'win32' and not gui_display: + # Docker Desktop can reach the Windows X server at host.docker.internal. + gui_display = 'host.docker.internal:0' + + if not gui_display: + return [] + + args = ['-e', f'DISPLAY={gui_display}', '-e', 'QT_X11_NO_MITSHM=1'] + + if os.environ.get('DOCKER_GUI_USE_HOST_NETWORK'): + if sys.platform == 'win32': + print("WARNING: DOCKER_GUI_USE_HOST_NETWORK is not supported on Windows; using DISPLAY only.") + return args + return ['--network=host', '-e', 'DISPLAY=:0', '-e', 'QT_X11_NO_MITSHM=1'] + + if sys.platform == 'win32': + if 'host.docker.internal' in gui_display: + args.extend(['--add-host', 'host.docker.internal:host-gateway']) + return args + + return args + ['-v', '/tmp/.X11-unix:/tmp/.X11-unix:rw'] + + +def _parse_display_host_port(display_value): + if display_value.startswith(':'): + return 'localhost', 6000 + int(display_value[1:].split('.')[0]) + host, _, display_str = display_value.rpartition(':') + if not host: + host = 'localhost' + try: + display_num = int(display_str) + except ValueError: + display_num = 0 + return host, 6000 + display_num + + +def _docker_gui_diagnostics(gui_display, gui_args): + print("--- GUI diagnostics ---") + print(f"DISPLAY value used inside the container: {gui_display}") + host, port = _parse_display_host_port(gui_display) + print(f"Checking TCP connectivity to X server at {host}:{port}...") + + python_check = ( + "import os, socket, sys\n" + "display = os.environ.get('DISPLAY', '')\n" + "print('container DISPLAY=' + display)\n" + f"host = '{host}'\n" + f"port = {port}\n" + "try:\n" + " s = socket.create_connection((host, port), timeout=3)\n" + " print('OK: connected to', host, port)\n" + " s.close()\n" + "except Exception as e:\n" + " print('FAIL: could not connect to', host, port, e)\n" + " sys.exit(1)\n" + ) + + docker_cmd = ['docker', 'run', '--rm'] + gui_args + [IMAGE_NAME, '-c', f'python3 -c "{python_check}"'] + run_command(docker_cmd, check=False) + + def launch_workspace(): """Launches the main tmux session in the container.""" print("Launching workspace...") - + container_script = ( - 'source /opt/ros/humble/setup.bash && ' - 'cd /workspace && source install/setup.bash && ' - 'tmux start-server && ' + "source /opt/ros/humble/setup.bash && " + "cd /workspace && source install/setup.bash && " + "tmux start-server && " "if ! tmux has-session -t lucy_ws 2>/dev/null; then " - "tmux new-session -d -s lucy_ws -n 'Lucy Workspace' 'launcher'; " - 'fi && ' - 'tmux attach-session -t lucy_ws' + "tmux new-session -d -s lucy_ws -n 'Lucy Workspace' 'python3 /workspace/launcher.py'; " + "fi && " + "tmux attach-session -t lucy_ws" ) + volume_mapping = _format_volume_mapping(WORKSPACE_DIR_HOST, WORKSPACE_DIR_CONTAINER) + gui_args = _docker_gui_args() + display_value = os.environ.get('DOCKER_GUI_DISPLAY', os.environ.get('DISPLAY', '')) + if sys.platform == 'win32' and not display_value: + display_value = 'host.docker.internal:0' + + if gui_args: + print(f"Enabling GUI forwarding with DISPLAY={display_value}") + _docker_gui_diagnostics(display_value, gui_args) + else: + print("No DISPLAY configured; running without GUI.") + + # Remove the extra 'bash' token; pass '-c' so the ENTRYPOINT (/bin/bash) runs the script. docker_cmd = [ 'docker', 'run', '-it', '--rm', '--name', 'lucy_dev_win', '-p', '9090:9090', '-p', '5000:5000', - '-v', f'"{WORKSPACE_DIR_HOST}:{WORKSPACE_DIR_CONTAINER}"', + '-v', volume_mapping, + ] + gui_args + [ IMAGE_NAME, - 'bash', '-c', container_script + '-c', container_script ] run_command(docker_cmd, interactive=True) @@ -220,4 +314,4 @@ def main(): print("\nExiting.") except Exception as e: print(f"An unexpected error occurred: {e}", file=sys.stderr) - sys.exit(1) + sys.exit(1) \ No newline at end of file diff --git a/windows/README.md b/windows/README.md index 0fa633e..30ccca1 100644 --- a/windows/README.md +++ b/windows/README.md @@ -13,6 +13,12 @@ It can also be compiled into a single `.exe` file using a tool like PyInstaller. 1. **Python 3**: Must be installed and in your system's PATH. 2. **Git for Windows**: Must be installed and in your system's PATH. 3. **Docker Desktop**: Must be installed and running. +4. **Windows X server**: Required for GUI apps such as `rqt` inside the Docker container. + - We recommend [VcXsrv](https://github.com/marchaesen/vcxsrv/releases). + - Start VcXsrv on display `0`, allow TCP connections, and disable access control if needed. + - Make sure Windows Firewall allows port `6000`. + +> If you intend to solely use the control panel visualizer alongside commande lines tools, you can skip the installation of a third-party Windows X Server. ## Usage From 1eb7670d3d7fc2ccb4e97bf00adb02664397bc1b Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Sat, 30 May 2026 17:25:52 +0900 Subject: [PATCH 12/15] fix(QA): updt UX/UI Signed-off-by: Mael-RABOT --- Lucy.py | 28 ++++++++++---- README.md | 5 +++ config/launcher_config.json | 3 ++ launcher.py | 75 +++++++++++++++++++++++++++---------- windows/Lucy.py | 48 ++++++++++++++---------- windows/README.md | 8 ++++ 6 files changed, 120 insertions(+), 47 deletions(-) diff --git a/Lucy.py b/Lucy.py index a70d8d6..6702c79 100644 --- a/Lucy.py +++ b/Lucy.py @@ -70,7 +70,7 @@ def main_tui(stdscr): is_dev_mode = get_dev_mode() current_idx = 0 - options = ["Developer Mode", "Install", "Rebuild", "Launch", "Exit"] + options = ["Install/Update", "Rebuild", "Launch", "---", "Exit", "---", "Developer Mode"] while True: stdscr.clear() @@ -79,6 +79,10 @@ def main_tui(stdscr): stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) for i, option in enumerate(options): + if option == "---": + stdscr.addstr(2 + i, 4, "----------------------") + continue + prefix = "> " if current_idx == i else " " if option == "Developer Mode": @@ -93,21 +97,25 @@ def main_tui(stdscr): key = stdscr.getch() if key == curses.KEY_UP: - current_idx = (current_idx - 1) % len(options) + current_idx = (current_idx - 1 + len(options)) % len(options) + if options[current_idx] == "---": + current_idx = (current_idx - 1 + len(options)) % len(options) elif key == curses.KEY_DOWN: current_idx = (current_idx + 1) % len(options) + if options[current_idx] == "---": + current_idx = (current_idx + 1) % len(options) elif key in [ord(' '), ord('\n')]: selected_option = options[current_idx] if selected_option == "Developer Mode": is_dev_mode = not is_dev_mode set_dev_mode(is_dev_mode) - elif selected_option == "Install": - return {"cmd": ["./install.sh"], "interactive": False} + elif selected_option == "Install/Update": + return {"cmd": ["./install.sh"], "interactive": False, "name": "Install"} elif selected_option == "Rebuild": - return {"cmd": ["./install.sh", "--build-only"], "interactive": False} + return {"cmd": ["./install.sh", "--build-only"], "interactive": False, "name": "Rebuild"} elif selected_option == "Launch": - return {"cmd": ["./launch_lucy.sh"], "interactive": True} + return {"cmd": ["./launch_lucy.sh"], "interactive": True, "name": "Launch"} elif selected_option == "Exit": return None @@ -123,8 +131,12 @@ def main_tui(stdscr): if task: rc = run_command(task["cmd"], interactive=task.get("interactive", False)) if not task.get("interactive", False): - print(f"--- Command finished with exit code {rc} ---") - print("Press Enter to exit.") + task_name = task.get("name") + if task_name == "Install" and rc == 0: + print("Press Enter to continue.") + else: + print(f"--- Command finished with exit code {rc} ---") + print("Press Enter to exit.") input() sys.exit(0) diff --git a/README.md b/README.md index 6627114..5d29199 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ Workspace bringup for the Lucy / InMoov humanoid. Everything (ROS 2 Humble, Gazebo, RViz, the web control panel) runs inside a single Docker container — you only need **Docker**, **Git** and **Python 3** on the host (plus **`xhost`** on Linux for GUI forwarding). +## Requirements + +- Python3 +- Docker + ## Install > For UNIX based system: diff --git a/config/launcher_config.json b/config/launcher_config.json index e070d4a..fd08300 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -18,6 +18,9 @@ "dependencies": ["core"], "conflicts": ["real"], "command": "gazebo:=true", + "lifecycle_hooks": { + "stop": "pkill gzserver || true" + }, "default_on": false }, { diff --git a/launcher.py b/launcher.py index d7a3307..eb83861 100644 --- a/launcher.py +++ b/launcher.py @@ -9,6 +9,8 @@ CONFIG_FILE = "/workspace/config/launcher_config.json" STATE_FILE = "/tmp/launcher_state.json" +MIN_TERM_HEIGHT = 22 +MIN_TERM_WIDTH = 65 def is_in_docker(): return os.path.exists('/.dockerenv') @@ -50,8 +52,9 @@ def __init__(self, data, running_modifiers): self.dependencies = data.get('dependencies', []) self.conflicts = data.get('conflicts', []) self.command = data.get('command', '') + self.lifecycle_hooks = data.get('lifecycle_hooks', {}) self.selected = data.get('default_on', False) - + self.update_running_status(running_modifiers) def update_running_status(self, running_modifiers): @@ -86,7 +89,7 @@ def toggle(self, pkg_id): missing_deps = [dep for dep in pkg.dependencies if not self.get_by_id(dep).selected] if missing_deps: return f"Needs: {', '.join(missing_deps)}" - + for conflict_id in pkg.conflicts: conflict_pkg = self.get_by_id(conflict_id) if conflict_pkg and conflict_pkg.selected: @@ -152,15 +155,29 @@ def draw_tui(stdscr, state, current_idx, error_msg, status_msg): def apply_changes(state): last_launched_window = None + # First Pass: Stop processes that should be turned off for pkg in state.packages: if pkg.is_complex_command(): + was_running = run_shell_command(pkg.command['is_running'], capture_output=True) + if not pkg.selected and was_running: + run_shell_command(pkg.command['stop']) + elif pkg.type == 'modifier': + if not pkg.selected and 'stop' in pkg.lifecycle_hooks: + run_shell_command(pkg.lifecycle_hooks['stop']) + + elif pkg.type == 'core' and not pkg.selected: + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + save_state({"modifiers": []}) + elif pkg.type in ['tool', 'interface'] and not pkg.selected: + run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") + + # Second Pass: Start processes that should be turned on + for pkg in state.packages: + if pkg.is_complex_command(): was_running = run_shell_command(pkg.command['is_running'], capture_output=True) if pkg.selected and not was_running: run_shell_command(pkg.command['start']) - elif not pkg.selected and was_running: - run_shell_command(pkg.command['stop']) - elif pkg.type == 'core': - if pkg.selected: + elif pkg.type == 'core' and pkg.selected: run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") base_cmd = pkg.command selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] @@ -169,25 +186,25 @@ def apply_changes(state): full_cmd = f"{base_cmd} {' '.join(modifier_args)}" run_shell_command(f"tmux new-window -d -t lucy_ws -n core '{full_cmd}; echo \"--- Process finished, press any key to close ---\"; read'") save_state({"modifiers": modifier_ids}) - else: - run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") - save_state({"modifiers": []}) - elif pkg.type in ['tool', 'interface']: + elif pkg.type in ['tool', 'interface'] and pkg.selected: run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") - if pkg.selected: - run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") - last_launched_window = pkg.id - + run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") + last_launched_window = pkg.id + if last_launched_window: run_shell_command(f"tmux select-window -t lucy_ws:{last_launched_window}") def main(stdscr): + h, w = stdscr.getmaxyx() + if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: + return "TerminalTooSmall", None + curses.curs_set(0) stdscr.nodelay(0) stdscr.timeout(-1) curses.start_color() curses.use_default_colors() - + if curses.has_colors(): curses.init_pair(1, curses.COLOR_YELLOW, -1) curses.init_pair(2, curses.COLOR_RED, -1) @@ -202,7 +219,7 @@ def main(stdscr): display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg) error_msg = None status_msg = None - + key = stdscr.getch() if key == curses.KEY_UP: @@ -224,30 +241,48 @@ def main(stdscr): if confirm_key in [ord('y'), ord('Y')]: return "ExitWorkspace", state elif key in [ord('q'), ord('Q'), 27]: - # Q is now a no-op, but we can keep it for future use if needed - pass + return "Quit", None if __name__ == "__main__": if not is_in_docker() or not is_in_tmux(): print("Error: This script must be run inside the 'lucy_ws' tmux session within the Docker container.", file=sys.stderr) sys.exit(1) + status, state = None, None try: status, state = curses.wrapper(main) - except Exception as e: + except curses.error as e: print(f"A terminal error occurred: {e}", file=sys.stderr) sys.exit(1) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) if status == "ExitWorkspace": print("\nStopping all processes and exiting workspace...") for pkg in state.packages: if pkg.is_complex_command(): run_shell_command(pkg.command['stop']) + elif 'stop' in pkg.lifecycle_hooks: + run_shell_command(pkg.lifecycle_hooks['stop']) + if os.path.exists(STATE_FILE): os.remove(STATE_FILE) print("Terminating tmux session...") time.sleep(0.5) run_shell_command("tmux kill-session -t lucy_ws 2>/dev/null") + elif status == "TerminalTooSmall": + print("Error: Terminal window is too small.", file=sys.stderr) + print(f"Please increase the terminal size to at least {MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} characters.", file=sys.stderr) + print("Alternatively, use the underlying shell scripts (install.sh, launch_lucy.sh).", file=sys.stderr) + print("\nPress any key to exit this session.", file=sys.stderr) + # Wait for a key press before exiting + curses.cbreak() + curses.noecho() + sys.stdin.read(1) + # Cleanly exit the tmux session + run_shell_command("tmux kill-session -t lucy_ws 2>/dev/null") + sys.exit(1) else: - # If we quit the loop for any other reason, just exit the script + # For "Quit" or other cases, just exit gracefully pass diff --git a/windows/Lucy.py b/windows/Lucy.py index 4f008bb..a358cb6 100644 --- a/windows/Lucy.py +++ b/windows/Lucy.py @@ -268,10 +268,11 @@ def main(): dev_status = "ON" if is_dev_mode else "OFF" print("\n--- Lucy Workspace Manager (Native Windows) ---") - print(f"1. Toggle Developer Mode (Currently: {dev_status})") - print("2. Install (Full)") - print("3. Rebuild (Workspace only)") - print("4. Launch") + print("1. Install (Full)") + print("2. Rebuild (Workspace only)") + print("3. Launch") + print("---------------------------------------------") + print(f"4. Toggle Developer Mode (Currently: {dev_status})") print("5. Exit") try: @@ -280,29 +281,38 @@ def main(): break if choice == '1': - set_dev_mode(not is_dev_mode) - print(f"Developer mode set to: {'ON' if not is_dev_mode else 'OFF'}") - + try: + clone_or_update_repos() + build_docker_image() + build_workspace() + print("--- Full install complete! ---") + input("\nPress Enter to continue...") + except Exception as e: + print(f"Install failed: {e}") + input("\nPress Enter to exit...") + elif choice == '2': - clone_or_update_repos() - build_docker_image() - build_workspace() - print("--- Full install complete! ---") - + try: + build_workspace() + print("--- Workspace rebuild complete! ---") + input("\nPress Enter to continue...") + except Exception as e: + print(f"Rebuild failed: {e}") + input("\nPress Enter to exit...") + elif choice == '3': - build_workspace() - print("--- Workspace rebuild complete! ---") - - elif choice == '4': launch_workspace() + elif choice == '4': + set_dev_mode(not is_dev_mode) + print(f"Developer mode set to: {'ON' if not is_dev_mode else 'OFF'}") + input("\nPress Enter to continue...") + elif choice == '5': break else: print("Invalid choice, please try again.") - - if choice != '4' and choice != '5': input("\nPress Enter to continue...") if __name__ == "__main__": @@ -314,4 +324,4 @@ def main(): print("\nExiting.") except Exception as e: print(f"An unexpected error occurred: {e}", file=sys.stderr) - sys.exit(1) \ No newline at end of file + sys.exit(1) diff --git a/windows/README.md b/windows/README.md index 30ccca1..63bb868 100644 --- a/windows/README.md +++ b/windows/README.md @@ -29,3 +29,11 @@ python windows/Lucy.py ``` You will be presented with a simple numbered menu to manage the workspace. + +## Terminal choice + +To run the project, you will need to have access to a terminal, you have multiple choices: + +- Default "command" application, will require `windows/Lucy.py` +- WSL, you will use the default `Lucy.py`. Be sure to enable WSL support in docker if you are using docker desktop +- Git bash, you will use the default `Lucy.py`. \ No newline at end of file From 620fc3af0015d517c0554770e5e129c9c5bbb841 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Sun, 31 May 2026 14:10:30 +0900 Subject: [PATCH 13/15] evol(QA): UI/UX Signed-off-by: Mael-RABOT --- .env.example | 5 ----- Lucy.py | 46 ++++++++++++++++++++++++++++++---------------- README.md | 8 +++++++- install.sh | 4 ++-- launch_lucy.sh | 2 +- launcher.py | 35 +++++++++++++++++++++++++++++++++-- windows/Lucy.py | 8 ++++---- 7 files changed, 77 insertions(+), 31 deletions(-) delete mode 100644 .env.example diff --git a/.env.example b/.env.example deleted file mode 100644 index fa8d723..0000000 --- a/.env.example +++ /dev/null @@ -1,5 +0,0 @@ -# Local overrides for install.sh (copy to `.env`; `.env` is gitignored). - -# When true, install.sh uses `url_ssh` from config/repos.json (default: `url_https`). -# Requires SSH keys configured for the host. -DEV=true diff --git a/Lucy.py b/Lucy.py index 6702c79..c57f30a 100644 --- a/Lucy.py +++ b/Lucy.py @@ -70,7 +70,7 @@ def main_tui(stdscr): is_dev_mode = get_dev_mode() current_idx = 0 - options = ["Install/Update", "Rebuild", "Launch", "---", "Exit", "---", "Developer Mode"] + options = ["Launch", "---", "Install/Update", "Rebuild", "Exit", "---", "Developer Mode"] while True: stdscr.clear() @@ -120,23 +120,37 @@ def main_tui(stdscr): return None if __name__ == "__main__": - task = None - try: - # curses.wrapper handles all the init/deinit of the terminal - task = curses.wrapper(main_tui) - except KeyboardInterrupt: - print("\nExiting.") - sys.exit(0) + while True: + task = None + try: + # curses.wrapper handles all the init/deinit of the terminal + task = curses.wrapper(main_tui) + except KeyboardInterrupt: + print("\nExiting.") + sys.exit(0) + + if not task: + # User selected Exit + break - if task: rc = run_command(task["cmd"], interactive=task.get("interactive", False)) - if not task.get("interactive", False): - task_name = task.get("name") - if task_name == "Install" and rc == 0: - print("Press Enter to continue.") - else: - print(f"--- Command finished with exit code {rc} ---") - print("Press Enter to exit.") + + if task.get("interactive", False): + # For interactive tasks like "Launch", when they finish, we exit the manager. + print(f"--- Session finished with exit code {rc} ---") + break + + # For non-interactive tasks + task_name = task.get("name") + if task_name in ["Install", "Rebuild"] and rc == 0: + print(f"\n--- Task '{task_name}' finished successfully. ---") + print("Press Enter to return to the menu.") + input() + # Loop back to show the TUI again + else: + print(f"\n--- Task '{task_name}' finished with exit code {rc} ---") + print("Press Enter to exit.") input() + break sys.exit(0) diff --git a/README.md b/README.md index 5d29199..9324b53 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,13 @@ From the repository root, run the manager for your platform: > The windows installation require the installation of a 3rd party software, as a Windows X Server is needed. -The manager includes a **Developer Mode** toggle. When ON, it configures the installer to pull repositories using SSH instead of HTTP. +### Developer mode + +The manager includes a **Developer Mode** toggle. When ON: +- repositories are pulled using SSH instead of HTTP +- The core & control panel aren't launch automatically + +This setting is stored in a .env file ### Using the Workspace diff --git a/install.sh b/install.sh index df4b14a..56e5bae 100755 --- a/install.sh +++ b/install.sh @@ -166,7 +166,7 @@ EOS if [ "$MODE" = "build-only" ]; then check_cmd docker docker_workspace_install - echo "Build complete. Run ./launch_lucy.sh to start the stack." + echo "Build complete. use `Launch` in `Lucy.py`" exit 0 fi @@ -233,4 +233,4 @@ done < <(parse_repos) # ---------------------------------------------------------------------------- docker_workspace_install -echo "Install complete. Run ./launch_lucy.sh to start the stack." +echo "Install complete. Run `Launch` in `Lucy.py`" diff --git a/launch_lucy.sh b/launch_lucy.sh index e9911ab..0010136 100755 --- a/launch_lucy.sh +++ b/launch_lucy.sh @@ -126,7 +126,7 @@ set -e source /opt/ros/humble/setup.bash cd /workspace if [[ ! -f install/setup.bash ]]; then - echo "Workspace not built. Run ./install.sh (or ./install.sh --build-only) on the host first." >&2 + echo "Workspace not built. Run Install/Update via Lucy.py" >&2 exit 1 fi source install/setup.bash diff --git a/launcher.py b/launcher.py index eb83861..6f92fe5 100644 --- a/launcher.py +++ b/launcher.py @@ -12,6 +12,16 @@ MIN_TERM_HEIGHT = 22 MIN_TERM_WIDTH = 65 +def get_dev_mode(): + env_path = "/workspace/.env" + if not os.path.exists(env_path): + return False + with open(env_path, "r") as f: + for line in f: + if line.strip().startswith("DEV="): + return line.strip().split("=")[1].lower() == "true" + return False + def is_in_docker(): return os.path.exists('/.dockerenv') @@ -107,7 +117,7 @@ def draw_tui(stdscr, state, current_idx, error_msg, status_msg): h, w = stdscr.getmaxyx() title = "Lucy Control Center" stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) - stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | X: Stop All & Exit Docker", curses.A_DIM) + stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | X: Stop All & Exit Docker", curses.A_BOLD) if status_msg: stdscr.addstr(h - 2, 2, status_msg, curses.A_BOLD) @@ -215,10 +225,30 @@ def main(stdscr): error_msg = None status_msg = None + # On first launch in production mode, start default services + if not get_dev_mode(): + core_pkg = state.get_by_id('core') + cp_pkg = state.get_by_id('control_panel') + + should_apply_defaults = False + if core_pkg and not core_pkg.selected: + core_pkg.selected = True + should_apply_defaults = True + if cp_pkg and not cp_pkg.selected: + cp_pkg.selected = True + should_apply_defaults = True + + if should_apply_defaults: + apply_changes(state) + status_msg = "Starting default services for production mode..." + # Reload state to reflect that services are now running + state = LauncherState(load_config()) + + while True: display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg) error_msg = None - status_msg = None + status_msg = None # Reset status message after one display key = stdscr.getch() @@ -232,6 +262,7 @@ def main(stdscr): elif key == ord('\n'): apply_changes(state) status_msg = "Configuration Applied!" + # Reload state to get the latest running status state = LauncherState(load_config()) elif key in [ord('x'), ord('X')]: h, w = stdscr.getmaxyx() diff --git a/windows/Lucy.py b/windows/Lucy.py index a358cb6..3ac6da0 100644 --- a/windows/Lucy.py +++ b/windows/Lucy.py @@ -281,6 +281,9 @@ def main(): break if choice == '1': + launch_workspace() + + elif choice == '2': try: clone_or_update_repos() build_docker_image() @@ -291,7 +294,7 @@ def main(): print(f"Install failed: {e}") input("\nPress Enter to exit...") - elif choice == '2': + elif choice == '3': try: build_workspace() print("--- Workspace rebuild complete! ---") @@ -299,9 +302,6 @@ def main(): except Exception as e: print(f"Rebuild failed: {e}") input("\nPress Enter to exit...") - - elif choice == '3': - launch_workspace() elif choice == '4': set_dev_mode(not is_dev_mode) From 5935f40485e553525caf46e60d20dcff2848dbd1 Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Sun, 31 May 2026 14:21:04 +0900 Subject: [PATCH 14/15] fix(QA): dynamic term size assert Signed-off-by: Mael-RABOT --- Lucy.py | 34 +++++++-- config/launcher_config.json | 3 - launcher.py | 139 +++++++++++++++++++----------------- 3 files changed, 102 insertions(+), 74 deletions(-) diff --git a/Lucy.py b/Lucy.py index c57f30a..5630949 100644 --- a/Lucy.py +++ b/Lucy.py @@ -5,6 +5,9 @@ import subprocess import sys +MIN_TERM_HEIGHT = 15 +MIN_TERM_WIDTH = 65 + def get_dev_mode(): if not os.path.exists(".env"): return False @@ -61,6 +64,10 @@ def run_command(command, interactive=False): def main_tui(stdscr): """The main curses TUI function. Returns the command to run.""" + h, w = stdscr.getmaxyx() + if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: + return "TerminalTooSmall" + curses.curs_set(0) stdscr.nodelay(0) stdscr.timeout(-1) @@ -120,14 +127,36 @@ def main_tui(stdscr): return None if __name__ == "__main__": + # This initial check is done before curses.wrapper to provide a clean error message + # without the screen flicker of initializing and de-initializing curses. + def check_initial_size(): + stdscr = curses.initscr() + h, w = stdscr.getmaxyx() + curses.endwin() + return h >= MIN_TERM_HEIGHT and w >= MIN_TERM_WIDTH + + if not check_initial_size(): + print("Error: Terminal window is too small.", file=sys.stderr) + print(f"Please increase the terminal size to at least {MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} characters.", file=sys.stderr) + sys.exit(1) + while True: task = None try: - # curses.wrapper handles all the init/deinit of the terminal task = curses.wrapper(main_tui) except KeyboardInterrupt: print("\nExiting.") sys.exit(0) + except curses.error as e: + print(f"A terminal error occurred: {e}", file=sys.stderr) + print("This might be due to resizing the window. Please restart.", file=sys.stderr) + sys.exit(1) + + if isinstance(task, str) and task == "TerminalTooSmall": + # This case is handled by the pre-check, but as a fallback. + print("Error: Terminal window is too small.", file=sys.stderr) + print(f"Please increase the terminal size to at least {MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} characters.", file=sys.stderr) + sys.exit(1) if not task: # User selected Exit @@ -136,17 +165,14 @@ def main_tui(stdscr): rc = run_command(task["cmd"], interactive=task.get("interactive", False)) if task.get("interactive", False): - # For interactive tasks like "Launch", when they finish, we exit the manager. print(f"--- Session finished with exit code {rc} ---") break - # For non-interactive tasks task_name = task.get("name") if task_name in ["Install", "Rebuild"] and rc == 0: print(f"\n--- Task '{task_name}' finished successfully. ---") print("Press Enter to return to the menu.") input() - # Loop back to show the TUI again else: print(f"\n--- Task '{task_name}' finished with exit code {rc} ---") print("Press Enter to exit.") diff --git a/config/launcher_config.json b/config/launcher_config.json index fd08300..e070d4a 100644 --- a/config/launcher_config.json +++ b/config/launcher_config.json @@ -18,9 +18,6 @@ "dependencies": ["core"], "conflicts": ["real"], "command": "gazebo:=true", - "lifecycle_hooks": { - "stop": "pkill gzserver || true" - }, "default_on": false }, { diff --git a/launcher.py b/launcher.py index 6f92fe5..2563045 100644 --- a/launcher.py +++ b/launcher.py @@ -64,7 +64,6 @@ def __init__(self, data, running_modifiers): self.command = data.get('command', '') self.lifecycle_hooks = data.get('lifecycle_hooks', {}) self.selected = data.get('default_on', False) - self.update_running_status(running_modifiers) def update_running_status(self, running_modifiers): @@ -94,12 +93,10 @@ def get_by_id(self, pkg_id): def toggle(self, pkg_id): pkg = self.get_by_id(pkg_id) if not pkg: return None - if not pkg.selected: missing_deps = [dep for dep in pkg.dependencies if not self.get_by_id(dep).selected] if missing_deps: return f"Needs: {', '.join(missing_deps)}" - for conflict_id in pkg.conflicts: conflict_pkg = self.get_by_id(conflict_id) if conflict_pkg and conflict_pkg.selected: @@ -112,9 +109,22 @@ def toggle(self, pkg_id): pkg.selected = False return None -def draw_tui(stdscr, state, current_idx, error_msg, status_msg): +def draw_too_small_message(stdscr): + h, w = stdscr.getmaxyx() stdscr.clear() + message = "Please increase terminal size" + message2 = f"({MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} required)" + stdscr.addstr(h // 2 - 1, max(0, (w - len(message)) // 2), message, curses.A_BOLD) + stdscr.addstr(h // 2, max(0, (w - len(message2)) // 2), message2, curses.A_DIM) + stdscr.refresh() + +def draw_tui(stdscr, state, current_idx, error_msg, status_msg): h, w = stdscr.getmaxyx() + if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: + draw_too_small_message(stdscr) + return None + + stdscr.clear() title = "Lucy Control Center" stdscr.addstr(0, max(0, (w - len(title)) // 2), title, curses.A_BOLD) stdscr.addstr(h - 1, 2, "Enter: Apply | Space: Toggle | X: Stop All & Exit Docker", curses.A_BOLD) @@ -164,8 +174,6 @@ def draw_tui(stdscr, state, current_idx, error_msg, status_msg): def apply_changes(state): last_launched_window = None - - # First Pass: Stop processes that should be turned off for pkg in state.packages: if pkg.is_complex_command(): was_running = run_shell_command(pkg.command['is_running'], capture_output=True) @@ -174,14 +182,12 @@ def apply_changes(state): elif pkg.type == 'modifier': if not pkg.selected and 'stop' in pkg.lifecycle_hooks: run_shell_command(pkg.lifecycle_hooks['stop']) - elif pkg.type == 'core' and not pkg.selected: run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") save_state({"modifiers": []}) elif pkg.type in ['tool', 'interface'] and not pkg.selected: run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") - # Second Pass: Start processes that should be turned on for pkg in state.packages: if pkg.is_complex_command(): was_running = run_shell_command(pkg.command['is_running'], capture_output=True) @@ -205,12 +211,8 @@ def apply_changes(state): run_shell_command(f"tmux select-window -t lucy_ws:{last_launched_window}") def main(stdscr): - h, w = stdscr.getmaxyx() - if h < MIN_TERM_HEIGHT or w < MIN_TERM_WIDTH: - return "TerminalTooSmall", None - curses.curs_set(0) - stdscr.nodelay(0) + stdscr.nodelay(0) stdscr.timeout(-1) curses.start_color() curses.use_default_colors() @@ -225,11 +227,9 @@ def main(stdscr): error_msg = None status_msg = None - # On first launch in production mode, start default services if not get_dev_mode(): core_pkg = state.get_by_id('core') cp_pkg = state.get_by_id('control_panel') - should_apply_defaults = False if core_pkg and not core_pkg.selected: core_pkg.selected = True @@ -237,42 +237,61 @@ def main(stdscr): if cp_pkg and not cp_pkg.selected: cp_pkg.selected = True should_apply_defaults = True - if should_apply_defaults: apply_changes(state) status_msg = "Starting default services for production mode..." - # Reload state to reflect that services are now running state = LauncherState(load_config()) - while True: - display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg) - error_msg = None - status_msg = None # Reset status message after one display - - key = stdscr.getch() - - if key == curses.KEY_UP: - current_idx = (current_idx - 1) % len(display_list) - elif key == curses.KEY_DOWN: - current_idx = (current_idx + 1) % len(display_list) - elif key == ord(' '): - pkg_to_toggle = display_list[current_idx] - error_msg = state.toggle(pkg_to_toggle.id) - elif key == ord('\n'): - apply_changes(state) - status_msg = "Configuration Applied!" - # Reload state to get the latest running status - state = LauncherState(load_config()) - elif key in [ord('x'), ord('X')]: - h, w = stdscr.getmaxyx() - stdscr.addstr(h - 2, 2, "Stop all processes and exit Docker? (y/n)", curses.A_BOLD | curses.color_pair(2)) - stdscr.refresh() - confirm_key = stdscr.getch() - if confirm_key in [ord('y'), ord('Y')]: - return "ExitWorkspace", state - elif key in [ord('q'), ord('Q'), 27]: - return "Quit", None + try: + display_list = draw_tui(stdscr, state, current_idx, error_msg, status_msg) + error_msg = None + status_msg = None + + if display_list is None: + # If display_list is None, it means the screen is too small. + # We switch to non-blocking getch to poll for resize events + stdscr.nodelay(1) + stdscr.timeout(100) + key = stdscr.getch() + if key != curses.KEY_RESIZE: + time.sleep(0.1) + continue + else: + # Normal operation, wait for input indefinitely + stdscr.nodelay(0) + stdscr.timeout(-1) + key = stdscr.getch() + + if key == curses.KEY_RESIZE: + continue + + if key == curses.KEY_UP: + current_idx = (current_idx - 1) % len(display_list) + elif key == curses.KEY_DOWN: + current_idx = (current_idx + 1) % len(display_list) + elif key == ord(' '): + pkg_to_toggle = display_list[current_idx] + error_msg = state.toggle(pkg_to_toggle.id) + elif key == ord('\n'): + apply_changes(state) + status_msg = "Configuration Applied!" + state = LauncherState(load_config()) + elif key in [ord('x'), ord('X')]: + h, w = stdscr.getmaxyx() + stdscr.addstr(h - 2, 2, "Stop all processes and exit Docker? (y/n)", curses.A_BOLD | curses.color_pair(2)) + stdscr.refresh() + confirm_key = stdscr.getch() + if confirm_key in [ord('y'), ord('Y')]: + return "ExitWorkspace", state + elif key in [ord('q'), ord('Q'), 27]: + return "Quit", None + + except curses.error: + # This will catch errors from addstr if the window is resized + # between the size check and the drawing. + time.sleep(0.1) + continue if __name__ == "__main__": if not is_in_docker() or not is_in_tmux(): @@ -282,38 +301,24 @@ def main(stdscr): status, state = None, None try: status, state = curses.wrapper(main) - except curses.error as e: - print(f"A terminal error occurred: {e}", file=sys.stderr) - sys.exit(1) except Exception as e: + # Clean up curses on any exception + curses.endwin() print(f"An unexpected error occurred: {e}", file=sys.stderr) sys.exit(1) if status == "ExitWorkspace": print("\nStopping all processes and exiting workspace...") - for pkg in state.packages: - if pkg.is_complex_command(): - run_shell_command(pkg.command['stop']) - elif 'stop' in pkg.lifecycle_hooks: - run_shell_command(pkg.lifecycle_hooks['stop']) - + if state: + for pkg in state.packages: + if pkg.is_complex_command(): + run_shell_command(pkg.command['stop']) + elif 'stop' in pkg.lifecycle_hooks: + run_shell_command(pkg.lifecycle_hooks['stop']) if os.path.exists(STATE_FILE): os.remove(STATE_FILE) print("Terminating tmux session...") time.sleep(0.5) run_shell_command("tmux kill-session -t lucy_ws 2>/dev/null") - elif status == "TerminalTooSmall": - print("Error: Terminal window is too small.", file=sys.stderr) - print(f"Please increase the terminal size to at least {MIN_TERM_WIDTH}x{MIN_TERM_HEIGHT} characters.", file=sys.stderr) - print("Alternatively, use the underlying shell scripts (install.sh, launch_lucy.sh).", file=sys.stderr) - print("\nPress any key to exit this session.", file=sys.stderr) - # Wait for a key press before exiting - curses.cbreak() - curses.noecho() - sys.stdin.read(1) - # Cleanly exit the tmux session - run_shell_command("tmux kill-session -t lucy_ws 2>/dev/null") - sys.exit(1) else: - # For "Quit" or other cases, just exit gracefully pass From ca3b9d781be51cab11ac463a123fc7dde1bc995c Mon Sep 17 00:00:00 2001 From: Mael-RABOT Date: Sun, 31 May 2026 14:24:08 +0900 Subject: [PATCH 15/15] fix(QA): avoid useless core reload Signed-off-by: Mael-RABOT --- launcher.py | 78 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/launcher.py b/launcher.py index 2563045..d76fcbb 100644 --- a/launcher.py +++ b/launcher.py @@ -64,19 +64,26 @@ def __init__(self, data, running_modifiers): self.command = data.get('command', '') self.lifecycle_hooks = data.get('lifecycle_hooks', {}) self.selected = data.get('default_on', False) + + # New property to store the actual running state + self.is_running = False self.update_running_status(running_modifiers) + + # Initialize selected state to match running state initially + if self.is_running: + self.selected = True def update_running_status(self, running_modifiers): if self.is_complex_command(): - self.selected = run_shell_command(self.command['is_running'], capture_output=True) + self.is_running = run_shell_command(self.command['is_running'], capture_output=True) elif self.type == 'modifier': - self.selected = self.id in running_modifiers + self.is_running = self.id in running_modifiers elif self.type == 'core': - self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) - if not self.selected: + self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) + if not self.is_running: save_state({"modifiers": []}) elif self.type in ['tool', 'interface']: - self.selected = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) + self.is_running = run_shell_command(f"tmux list-windows -F '#{{window_name}}' | grep -q '^{self.id}$'", capture_output=True) def is_complex_command(self): return isinstance(self.command, dict) @@ -174,27 +181,48 @@ def draw_tui(stdscr, state, current_idx, error_msg, status_msg): def apply_changes(state): last_launched_window = None + core_pkg = state.get_by_id('core') + + # Check if core modifiers have changed + modifiers_changed = False + if core_pkg and core_pkg.selected: + selected_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.selected) + running_modifier_ids = set(p.id for p in state.packages if p.type == 'modifier' and p.is_running) + if selected_modifier_ids != running_modifier_ids: + modifiers_changed = True + + # Force core restart if it's selected but modifiers changed + if modifiers_changed and core_pkg and core_pkg.selected: + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + save_state({"modifiers": []}) + # Update state to reflect it's stopped so it gets restarted + core_pkg.is_running = False + for mod in state.packages: + if mod.type == 'modifier': + if mod.is_running and 'stop' in mod.lifecycle_hooks: + run_shell_command(mod.lifecycle_hooks['stop']) + mod.is_running = False + + # First Pass: Stop processes that should be turned off (or were forced off) for pkg in state.packages: - if pkg.is_complex_command(): - was_running = run_shell_command(pkg.command['is_running'], capture_output=True) - if not pkg.selected and was_running: + if not pkg.selected and pkg.is_running: + if pkg.is_complex_command(): run_shell_command(pkg.command['stop']) - elif pkg.type == 'modifier': - if not pkg.selected and 'stop' in pkg.lifecycle_hooks: - run_shell_command(pkg.lifecycle_hooks['stop']) - elif pkg.type == 'core' and not pkg.selected: - run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") - save_state({"modifiers": []}) - elif pkg.type in ['tool', 'interface'] and not pkg.selected: - run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") + elif pkg.type == 'modifier' and 'stop' in pkg.lifecycle_hooks: + run_shell_command(pkg.lifecycle_hooks['stop']) + elif pkg.type == 'core': + run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + save_state({"modifiers": []}) + elif pkg.type in ['tool', 'interface']: + run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") + pkg.is_running = False # Update local state + # Second Pass: Start processes that should be turned on for pkg in state.packages: - if pkg.is_complex_command(): - was_running = run_shell_command(pkg.command['is_running'], capture_output=True) - if pkg.selected and not was_running: + if pkg.selected and not pkg.is_running: + if pkg.is_complex_command(): run_shell_command(pkg.command['start']) - elif pkg.type == 'core' and pkg.selected: - run_shell_command("tmux kill-window -t lucy_ws:core 2>/dev/null") + elif pkg.type == 'core': base_cmd = pkg.command selected_modifiers = [p for p in state.packages if p.type == 'modifier' and p.selected] modifier_args = [p.command for p in selected_modifiers] @@ -202,10 +230,10 @@ def apply_changes(state): full_cmd = f"{base_cmd} {' '.join(modifier_args)}" run_shell_command(f"tmux new-window -d -t lucy_ws -n core '{full_cmd}; echo \"--- Process finished, press any key to close ---\"; read'") save_state({"modifiers": modifier_ids}) - elif pkg.type in ['tool', 'interface'] and pkg.selected: - run_shell_command(f"tmux kill-window -t lucy_ws:{pkg.id} 2>/dev/null") - run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") - last_launched_window = pkg.id + elif pkg.type in ['tool', 'interface']: + run_shell_command(f"tmux new-window -d -t lucy_ws -n {pkg.id} '{pkg.command}; echo \"--- Process finished, press any key to close ---\"; read'") + last_launched_window = pkg.id + pkg.is_running = True # Update local state if last_launched_window: run_shell_command(f"tmux select-window -t lucy_ws:{last_launched_window}")