feat(gui): Add Path Configuration panel to Context Hub

This commit is contained in:
2026-03-12 16:44:22 -04:00
parent 7924d65438
commit d237d3b94d
4 changed files with 156 additions and 7 deletions

View File

@@ -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")

View File

@@ -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:
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() 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")

View File

@@ -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,20 +105,42 @@ 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
return _RESOLVED["conductor_dir"] 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: def get_logs_dir() -> Path:
if "logs_dir" not in _RESOLVED: 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: 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
View 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()