diff --git a/src/app_controller.py b/src/app_controller.py index 01b5cfc..dbbf4bc 100644 --- a/src/app_controller.py +++ b/src/app_controller.py @@ -851,6 +851,10 @@ class AppController: self.ui_separate_tier3 = False self.ui_separate_tier4 = False self.config = models.load_config() + path_info = paths.get_full_path_info() + self.ui_conductor_dir = str(path_info['conductor_dir']['path']) + self.ui_logs_dir = str(path_info['logs_dir']['path']) + self.ui_scripts_dir = str(path_info['scripts_dir']['path']) theme.load_from_config(self.config) ai_cfg = self.config.get("ai", {}) self._current_provider = ai_cfg.get("provider", "gemini") diff --git a/src/gui_2.py b/src/gui_2.py index 37fb206..9f53096 100644 --- a/src/gui_2.py +++ b/src/gui_2.py @@ -6,6 +6,7 @@ import math import json import sys import os +import shutil import copy from pathlib import Path from tkinter import filedialog, Tk @@ -451,7 +452,14 @@ class App: exp, opened = imgui.begin("Context Hub", self.show_windows["Context Hub"]) self.show_windows["Context Hub"] = bool(opened) if exp: - self._render_projects_panel() + if imgui.begin_tab_bar('context_hub_tabs'): + if imgui.begin_tab_item('Projects')[0]: + self._render_projects_panel() + imgui.end_tab_item() + if imgui.begin_tab_item('Paths')[0]: + self._render_paths_panel() + imgui.end_tab_item() + imgui.end_tab_bar() imgui.end() if self.show_windows.get("Files & Media", False): exp, opened = imgui.begin("Files & Media", self.show_windows["Files & Media"]) @@ -1462,6 +1470,62 @@ class App: ch, self.ui_auto_scroll_comms = imgui.checkbox("Auto-scroll Comms History", self.ui_auto_scroll_comms) ch, self.ui_auto_scroll_tool_calls = imgui.checkbox("Auto-scroll Tool History", self.ui_auto_scroll_tool_calls) if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_projects_panel") + + def _save_paths(self): + self.config["paths"] = { + "conductor_dir": self.ui_conductor_dir, + "logs_dir": self.ui_logs_dir, + "scripts_dir": self.ui_scripts_dir + } + cfg_path = paths.get_config_path() + if cfg_path.exists(): + shutil.copy(cfg_path, str(cfg_path) + ".bak") + models.save_config(self.config) + paths.reset_resolved() + self.ai_status = "paths saved - restart required" + + def _render_paths_panel(self) -> None: + if self.perf_profiling_enabled: self.perf_monitor.start_component("_render_paths_panel") + path_info = paths.get_full_path_info() + + imgui.text_colored(C_IN, "System Path Configuration") + imgui.separator() + + if self.ai_status == "paths saved - restart required": + imgui.text_colored(vec4(255, 50, 50), "Restart required for path changes to take effect.") + imgui.separator() + + def render_path_field(label: str, attr: str, key: str, tooltip: str): + info = path_info.get(key, {'source': 'unknown'}) + imgui.text(label) + if imgui.is_item_hovered(): imgui.set_tooltip(tooltip) + imgui.same_line() + imgui.text_disabled(f"(Source: {info['source']})") + + val = getattr(self, attr) + changed, new_val = imgui.input_text(f"##{key}", val) + if imgui.is_item_hovered(): imgui.set_tooltip(tooltip) + if changed: setattr(self, attr, new_val) + imgui.same_line() + if imgui.button(f"Browse##{key}"): + r = hide_tk_root() + d = filedialog.askdirectory(title=f"Select {label}") + r.destroy() + if d: setattr(self, attr, d) + + render_path_field("Conductor Directory", "ui_conductor_dir", "conductor_dir", "Base directory for implementation tracks and project state.") + render_path_field("Logs Directory", "ui_logs_dir", "logs_dir", "Directory where session JSON-L logs and artifacts are stored.") + render_path_field("Scripts Directory", "ui_scripts_dir", "scripts_dir", "Directory for AI-generated PowerShell scripts.") + + imgui.separator() + if imgui.button("Apply", imgui.ImVec2(120, 0)): + self._save_paths() + imgui.same_line() + if imgui.button("Reset", imgui.ImVec2(120, 0)): + self.init_state() + self.ai_status = "paths reset to defaults" + + if self.perf_profiling_enabled: self.perf_monitor.end_component("_render_paths_panel") def _render_track_proposal_modal(self) -> None: if self._show_track_proposal_modal: imgui.open_popup("Track Proposal") diff --git a/src/paths.py b/src/paths.py index 0e1e871..315adee 100644 --- a/src/paths.py +++ b/src/paths.py @@ -45,7 +45,7 @@ See Also: from pathlib import Path import os import tomllib -from typing import Optional +from typing import Optional, Any _RESOLVED: dict[str, Path] = {} @@ -105,20 +105,42 @@ def _get_project_conductor_dir_from_toml(project_root: Path) -> Optional[Path]: if c_dir: p = Path(c_dir) if not p.is_absolute(): p = project_root / p - return p + return p.resolve() except: pass return None def get_conductor_dir(project_path: Optional[str] = None) -> Path: if project_path: - project_root = Path(project_path) + project_root = Path(project_path).resolve() p = _get_project_conductor_dir_from_toml(project_root) if p: return p - return project_root / 'conductor' if "conductor_dir" not in _RESOLVED: - _RESOLVED["conductor_dir"] = _resolve_path("SLOP_CONDUCTOR_DIR", "conductor_dir", "conductor") - return _RESOLVED["conductor_dir"] + # Check env and config + root_dir = Path(__file__).resolve().parent.parent + env_val = os.environ.get("SLOP_CONDUCTOR_DIR") + if env_val: + p = Path(env_val) + if not p.is_absolute(): p = root_dir / p + _RESOLVED["conductor_dir"] = p.resolve() + else: + try: + with open(get_config_path(), "rb") as f: + cfg = tomllib.load(f) + if "paths" in cfg and "conductor_dir" in cfg["paths"]: + p = Path(cfg["paths"]["conductor_dir"]) + if not p.is_absolute(): p = root_dir / p + _RESOLVED["conductor_dir"] = p.resolve() + except: pass + + if "conductor_dir" in _RESOLVED: + return _RESOLVED["conductor_dir"] + + if project_path: + return (Path(project_path).resolve() / "conductor").resolve() + + root_dir = Path(__file__).resolve().parent.parent + return (root_dir / "conductor").resolve() def get_logs_dir() -> Path: if "logs_dir" not in _RESOLVED: @@ -139,6 +161,29 @@ def get_track_state_dir(track_id: str, project_path: Optional[str] = None) -> Pa def get_archive_dir(project_path: Optional[str] = None) -> Path: return get_conductor_dir(project_path) / "archive" +def _resolve_path_info(env_var: str, config_key: str, default: str) -> dict[str, Any]: + if env_var in os.environ: + return {'path': Path(os.environ[env_var]).resolve(), 'source': f'env:{env_var}'} + try: + with open(get_config_path(), 'rb') as f: + cfg = tomllib.load(f) + if 'paths' in cfg and config_key in cfg['paths']: + p = Path(cfg['paths'][config_key]) + if not p.is_absolute(): + p = (Path(__file__).resolve().parent.parent / p).resolve() + return {'path': p, 'source': 'config.toml'} + except: pass + root_dir = Path(__file__).resolve().parent.parent + p = (root_dir / default).resolve() + return {'path': p, 'source': 'default'} + +def get_full_path_info() -> dict[str, dict[str, Any]]: + return { + 'conductor_dir': _resolve_path_info('SLOP_CONDUCTOR_DIR', 'conductor_dir', 'conductor'), + 'logs_dir': _resolve_path_info('SLOP_LOGS_DIR', 'logs_dir', 'logs/sessions'), + 'scripts_dir': _resolve_path_info('SLOP_SCRIPTS_DIR', 'scripts_dir', 'scripts/generated') + } + def reset_resolved() -> None: """For testing only - clear cached resolutions.""" _RESOLVED.clear() diff --git a/tests/test_gui_paths.py b/tests/test_gui_paths.py new file mode 100644 index 0000000..8cce80e --- /dev/null +++ b/tests/test_gui_paths.py @@ -0,0 +1,36 @@ +import pytest +from unittest.mock import MagicMock, patch +from src import paths + +# We mock App to avoid the heavy initialization logic +class MockApp: + def __init__(self): + self.ui_conductor_dir = '/mock/conductor' + self.ui_logs_dir = '/mock/logs' + self.ui_scripts_dir = '/mock/scripts' + self.config = {"paths": {}} + self.ai_status = "" + + from src.gui_2 import App + _save_paths = App._save_paths + +def test_save_paths(): + mock_app = MockApp() + + with patch('src.models.save_config') as mock_save, \ + patch('shutil.copy') as mock_copy, \ + patch('src.paths.get_config_path') as mock_get_cfg, \ + patch('src.paths.reset_resolved') as mock_reset: + + mock_get_cfg.return_value = MagicMock() + mock_get_cfg.return_value.exists.return_value = True + + mock_app.ui_conductor_dir = '/new/conductor' + mock_app._save_paths() + + # Verify config update + assert mock_app.config['paths']['conductor_dir'] == '/new/conductor' + mock_save.assert_called_once() + mock_copy.assert_called_once() + assert 'restart required' in mock_app.ai_status + mock_reset.assert_called_once()