feat(gui): Add Path Configuration panel to Context Hub
This commit is contained in:
@@ -851,6 +851,10 @@ class AppController:
|
|||||||
self.ui_separate_tier3 = False
|
self.ui_separate_tier3 = False
|
||||||
self.ui_separate_tier4 = False
|
self.ui_separate_tier4 = False
|
||||||
self.config = models.load_config()
|
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)
|
theme.load_from_config(self.config)
|
||||||
ai_cfg = self.config.get("ai", {})
|
ai_cfg = self.config.get("ai", {})
|
||||||
self._current_provider = ai_cfg.get("provider", "gemini")
|
self._current_provider = ai_cfg.get("provider", "gemini")
|
||||||
|
|||||||
64
src/gui_2.py
64
src/gui_2.py
@@ -6,6 +6,7 @@ import math
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import copy
|
import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tkinter import filedialog, Tk
|
from tkinter import filedialog, Tk
|
||||||
@@ -451,7 +452,14 @@ class App:
|
|||||||
exp, opened = imgui.begin("Context Hub", self.show_windows["Context Hub"])
|
exp, opened = imgui.begin("Context Hub", self.show_windows["Context Hub"])
|
||||||
self.show_windows["Context Hub"] = bool(opened)
|
self.show_windows["Context Hub"] = bool(opened)
|
||||||
if exp:
|
if exp:
|
||||||
|
if imgui.begin_tab_bar('context_hub_tabs'):
|
||||||
|
if imgui.begin_tab_item('Projects')[0]:
|
||||||
self._render_projects_panel()
|
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()
|
imgui.end()
|
||||||
if self.show_windows.get("Files & Media", False):
|
if self.show_windows.get("Files & Media", False):
|
||||||
exp, opened = imgui.begin("Files & Media", self.show_windows["Files & Media"])
|
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_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)
|
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")
|
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:
|
def _render_track_proposal_modal(self) -> None:
|
||||||
if self._show_track_proposal_modal:
|
if self._show_track_proposal_modal:
|
||||||
imgui.open_popup("Track Proposal")
|
imgui.open_popup("Track Proposal")
|
||||||
|
|||||||
55
src/paths.py
55
src/paths.py
@@ -45,7 +45,7 @@ See Also:
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
import tomllib
|
import tomllib
|
||||||
from typing import Optional
|
from typing import Optional, Any
|
||||||
|
|
||||||
_RESOLVED: dict[str, Path] = {}
|
_RESOLVED: dict[str, Path] = {}
|
||||||
|
|
||||||
@@ -105,21 +105,43 @@ def _get_project_conductor_dir_from_toml(project_root: Path) -> Optional[Path]:
|
|||||||
if c_dir:
|
if c_dir:
|
||||||
p = Path(c_dir)
|
p = Path(c_dir)
|
||||||
if not p.is_absolute(): p = project_root / p
|
if not p.is_absolute(): p = project_root / p
|
||||||
return p
|
return p.resolve()
|
||||||
except: pass
|
except: pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_conductor_dir(project_path: Optional[str] = None) -> Path:
|
def get_conductor_dir(project_path: Optional[str] = None) -> Path:
|
||||||
if project_path:
|
if project_path:
|
||||||
project_root = Path(project_path)
|
project_root = Path(project_path).resolve()
|
||||||
p = _get_project_conductor_dir_from_toml(project_root)
|
p = _get_project_conductor_dir_from_toml(project_root)
|
||||||
if p: return p
|
if p: return p
|
||||||
return project_root / 'conductor'
|
|
||||||
|
|
||||||
if "conductor_dir" not in _RESOLVED:
|
if "conductor_dir" not in _RESOLVED:
|
||||||
_RESOLVED["conductor_dir"] = _resolve_path("SLOP_CONDUCTOR_DIR", "conductor_dir", "conductor")
|
# 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"]
|
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:
|
def get_logs_dir() -> Path:
|
||||||
if "logs_dir" not in _RESOLVED:
|
if "logs_dir" not in _RESOLVED:
|
||||||
_RESOLVED["logs_dir"] = _resolve_path("SLOP_LOGS_DIR", "logs_dir", "logs/sessions")
|
_RESOLVED["logs_dir"] = _resolve_path("SLOP_LOGS_DIR", "logs_dir", "logs/sessions")
|
||||||
@@ -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:
|
def get_archive_dir(project_path: Optional[str] = None) -> Path:
|
||||||
return get_conductor_dir(project_path) / "archive"
|
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:
|
def reset_resolved() -> None:
|
||||||
"""For testing only - clear cached resolutions."""
|
"""For testing only - clear cached resolutions."""
|
||||||
_RESOLVED.clear()
|
_RESOLVED.clear()
|
||||||
|
|||||||
36
tests/test_gui_paths.py
Normal file
36
tests/test_gui_paths.py
Normal file
@@ -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()
|
||||||
Reference in New Issue
Block a user